1use anyhow::Result;
8use chrono::NaiveDate;
9use tracing::{debug, warn};
10
11use crate::atlassian::adf::{AdfDocument, AdfMark, AdfNode};
12use crate::atlassian::attrs::{format_kv, parse_attrs, Attrs};
13use crate::atlassian::directive::{
14 is_container_close, try_parse_container_open, try_parse_inline_directive,
15 try_parse_leaf_directive,
16};
17
18pub fn markdown_to_adf(markdown: &str) -> Result<AdfDocument> {
22 debug!(
23 "markdown_to_adf: input {} bytes, {} lines",
24 markdown.len(),
25 markdown.lines().count()
26 );
27 let mut doc = AdfDocument::new();
28 let mut parser = MarkdownParser::new(markdown);
29 doc.content = parser.parse_blocks()?;
30 debug!(
31 "markdown_to_adf: produced {} top-level ADF nodes",
32 doc.content.len()
33 );
34 Ok(doc)
35}
36
37struct MarkdownParser<'a> {
39 lines: Vec<&'a str>,
40 pos: usize,
41}
42
43impl<'a> MarkdownParser<'a> {
44 fn new(input: &'a str) -> Self {
45 Self {
46 lines: input.lines().collect(),
47 pos: 0,
48 }
49 }
50
51 fn at_end(&self) -> bool {
52 self.pos >= self.lines.len()
53 }
54
55 fn current_line(&self) -> &'a str {
56 self.lines[self.pos]
57 }
58
59 fn advance(&mut self) {
60 self.pos += 1;
61 }
62
63 fn collect_hardbreak_continuations(&mut self, full_text: &mut String) {
71 while has_trailing_hard_break(full_text) && !self.at_end() {
72 if !self.try_append_hardbreak_continuation(full_text) {
73 break;
74 }
75 }
76 }
77
78 fn try_append_hardbreak_continuation(&mut self, full_text: &mut String) -> bool {
85 match self
91 .current_line()
92 .strip_prefix(" ")
93 .filter(|s| !is_block_level_continuation_marker(s.trim_start()))
94 {
95 Some(stripped) => {
96 full_text.push('\n');
97 full_text.push_str(stripped);
98 self.advance();
99 true
100 }
101 None => false,
102 }
103 }
104
105 fn parse_blocks(&mut self) -> Result<Vec<AdfNode>> {
106 let mut blocks = Vec::new();
107
108 while !self.at_end() {
109 let line = self.current_line();
110
111 if line.trim().is_empty() {
112 self.advance();
113 continue;
114 }
115
116 let mut node = if let Some(node) = self.try_heading() {
117 node
118 } else if let Some(node) = self.try_horizontal_rule() {
119 node
120 } else if let Some(node) = self.try_container_directive()? {
121 node
122 } else if let Some(node) = self.try_code_block()? {
123 node
124 } else if let Some(node) = self.try_table()? {
125 node
126 } else if let Some(node) = self.try_blockquote()? {
127 node
128 } else if let Some(node) = self.try_list()? {
129 node
130 } else if let Some(node) = self.try_leaf_directive() {
131 node
132 } else if let Some(node) = self.try_image() {
133 node
134 } else {
135 self.parse_paragraph()?
136 };
137
138 self.try_apply_block_attrs(&mut node);
140 blocks.push(node);
141 }
142
143 Ok(blocks)
144 }
145
146 fn try_heading(&mut self) -> Option<AdfNode> {
147 let line = self.current_line();
148 let trimmed = line.trim_start();
149
150 if !trimmed.starts_with('#') {
151 return None;
152 }
153
154 let level = trimmed.chars().take_while(|&c| c == '#').count();
155 if !(1..=6).contains(&level) || !trimmed[level..].starts_with(' ') {
156 return None;
157 }
158
159 let mut full_text = trimmed[level + 1..].to_string();
160 self.advance();
161 self.collect_hardbreak_continuations(&mut full_text);
163 let inline_nodes = parse_inline(&full_text);
164
165 #[allow(clippy::cast_possible_truncation)]
166 Some(AdfNode::heading(level as u8, inline_nodes))
167 }
168
169 fn try_horizontal_rule(&mut self) -> Option<AdfNode> {
170 let line = self.current_line().trim();
171 let is_rule = (line.starts_with("---") && line.chars().all(|c| c == '-'))
172 || (line.starts_with("***") && line.chars().all(|c| c == '*'))
173 || (line.starts_with("___") && line.chars().all(|c| c == '_'));
174
175 if is_rule && line.len() >= 3 {
176 self.advance();
177 Some(AdfNode::rule())
178 } else {
179 None
180 }
181 }
182
183 fn try_code_block(&mut self) -> Result<Option<AdfNode>> {
184 let line = self.current_line();
185 if !is_code_fence_opener(line) {
186 return Ok(None);
187 }
188
189 let language = line[3..].trim();
190 let language = if language == "\"\"" {
191 Some(String::new())
193 } else if language.is_empty() {
194 None
195 } else {
196 Some(language.to_string())
197 };
198
199 self.advance();
200 let mut code_lines = Vec::new();
201
202 while !self.at_end() {
203 let line = self.current_line();
204 if line.starts_with("```") {
205 self.advance();
206 break;
207 }
208 code_lines.push(line);
209 self.advance();
210 }
211
212 let code_text = code_lines.join("\n");
213
214 if language.as_deref() == Some("adf-unsupported") {
216 if let Ok(node) = serde_json::from_str::<AdfNode>(&code_text) {
217 return Ok(Some(node));
218 }
219 }
220
221 Ok(Some(AdfNode::code_block(language.as_deref(), &code_text)))
222 }
223
224 fn try_blockquote(&mut self) -> Result<Option<AdfNode>> {
225 let line = self.current_line();
226 if !line.starts_with('>') {
227 return Ok(None);
228 }
229
230 let mut quote_lines = Vec::new();
231 while !self.at_end() {
232 let line = self.current_line();
233 if let Some(rest) = line.strip_prefix("> ") {
234 quote_lines.push(rest);
235 self.advance();
236 } else if let Some(rest) = line.strip_prefix('>') {
237 quote_lines.push(rest);
238 self.advance();
239 } else {
240 break;
241 }
242 }
243
244 let quote_text = quote_lines.join("\n");
245 let mut inner_parser = MarkdownParser::new("e_text);
246 let inner_blocks = inner_parser.parse_blocks()?;
247
248 Ok(Some(AdfNode::blockquote(inner_blocks)))
249 }
250
251 fn try_list(&mut self) -> Result<Option<AdfNode>> {
252 let line = self.current_line();
253 let trimmed = line.trim_start();
254
255 let is_bullet =
256 trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ");
257 let ordered_match = parse_ordered_list_marker(trimmed);
258
259 if !is_bullet && ordered_match.is_none() {
260 return Ok(None);
261 }
262
263 if is_bullet {
264 self.parse_bullet_list()
265 } else {
266 let start = ordered_match.map_or(1, |(n, _)| n);
267 self.parse_ordered_list(start)
268 }
269 }
270
271 fn parse_bullet_list(&mut self) -> Result<Option<AdfNode>> {
272 let mut items = Vec::new();
273 let mut is_task_list = false;
274
275 while !self.at_end() {
276 let line = self.current_line();
277 let trimmed = line.trim_start();
278
279 if !(trimmed.starts_with("- ")
280 || trimmed.starts_with("* ")
281 || trimmed.starts_with("+ "))
282 {
283 break;
284 }
285
286 let after_marker = trimmed[2..].trim_start();
287
288 if let Some((state, text)) = try_parse_task_marker(after_marker) {
290 is_task_list = true;
291 self.advance();
292 let mut full_text = text.to_string();
296 self.collect_hardbreak_continuations(&mut full_text);
297 let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
298 let inline_nodes = parse_inline(item_text);
299 let content = if let Some(ref plid) = para_local_id {
303 let mut para = AdfNode::paragraph(inline_nodes);
304 if plid != "_" {
305 para.attrs = Some(serde_json::json!({"localId": plid}));
306 }
307 vec![para]
308 } else {
309 inline_nodes
310 };
311 let mut task = AdfNode::task_item(state, content);
312 if let Some(id) = local_id {
314 if let Some(ref mut attrs) = task.attrs {
315 attrs["localId"] = serde_json::Value::String(id);
316 }
317 }
318 let mut sub_lines: Vec<String> = Vec::new();
322 while !self.at_end() && self.current_line().starts_with(" ") {
323 let stripped = &self.current_line()[2..];
324 sub_lines.push(stripped.to_string());
325 self.advance();
326 }
327 if !sub_lines.is_empty() {
328 let sub_text = sub_lines.join("\n");
329 let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
330 let is_empty = task.content.as_ref().map_or(true, Vec::is_empty);
338 if is_empty && nested.len() == 1 && nested[0].node_type == "taskList" {
339 if let Some(task_items) = nested.remove(0).content {
340 task.content = Some(task_items);
341 }
342 if let Some(ref mut attrs) = task.attrs {
343 if let Some(obj) = attrs.as_object_mut() {
344 obj.remove("state");
345 }
346 }
347 items.push(task);
348 } else {
349 let mut sibling_task_lists = Vec::new();
355 let mut child_nodes = Vec::new();
356 for n in nested {
357 if n.node_type == "taskList" {
358 sibling_task_lists.push(n);
359 } else {
360 child_nodes.push(n);
361 }
362 }
363 if !child_nodes.is_empty() {
364 match task.content {
365 Some(ref mut content) => content.append(&mut child_nodes),
366 None => task.content = Some(child_nodes),
367 }
368 }
369 items.push(task);
370 items.append(&mut sibling_task_lists);
371 }
372 } else {
373 items.push(task);
374 }
375 } else {
376 let first_line = &trimmed[2..];
377 self.advance();
378 let mut full_text = first_line.to_string();
379 self.collect_hardbreak_continuations(&mut full_text);
380 let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
381 let mut sub_lines: Vec<String> = Vec::new();
385 while !self.at_end() {
386 let next = self.current_line();
387 if let Some(stripped) = next.strip_prefix(" ") {
388 sub_lines.push(stripped.to_string());
389 self.advance();
390 continue;
391 }
392 break;
393 }
394 let item_content =
395 parse_list_item_first_line(item_text, sub_lines, local_id, para_local_id)?;
396 items.push(item_content);
397 }
398 }
399
400 if items.is_empty() {
401 Ok(None)
402 } else if is_task_list {
403 Ok(Some(AdfNode::task_list(items)))
404 } else {
405 Ok(Some(AdfNode::bullet_list(items)))
406 }
407 }
408
409 fn parse_ordered_list(&mut self, start: u32) -> Result<Option<AdfNode>> {
410 let mut items = Vec::new();
411
412 while !self.at_end() {
413 let line = self.current_line();
414 let trimmed = line.trim_start();
415
416 if let Some((_, rest)) = parse_ordered_list_marker(trimmed) {
417 let first_line = rest.trim_start_matches(|c: char| c.is_ascii_whitespace());
418 self.advance();
419 let mut full_text = first_line.to_string();
420 self.collect_hardbreak_continuations(&mut full_text);
421 let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
422 let mut sub_lines: Vec<String> = Vec::new();
424 while !self.at_end() {
425 let next = self.current_line();
426 if let Some(stripped) = next.strip_prefix(" ") {
427 sub_lines.push(stripped.to_string());
428 self.advance();
429 continue;
430 }
431 break;
432 }
433 let item_content =
434 parse_list_item_first_line(item_text, sub_lines, local_id, para_local_id)?;
435 items.push(item_content);
436 } else {
437 break;
438 }
439 }
440
441 if items.is_empty() {
442 Ok(None)
443 } else {
444 let order = if start == 1 { None } else { Some(start) };
445 Ok(Some(AdfNode::ordered_list(items, order)))
446 }
447 }
448
449 fn try_apply_block_attrs(&mut self, node: &mut AdfNode) {
450 if self.at_end() {
451 return;
452 }
453 let line = self.current_line().trim();
454 if !line.starts_with('{') {
455 return;
456 }
457 let Some((_, attrs)) = parse_attrs(line, 0) else {
458 return;
459 };
460
461 let mut marks = Vec::new();
462 if let Some(align) = attrs.get("align") {
463 marks.push(AdfMark::alignment(align));
464 }
465 if let Some(indent) = attrs.get("indent") {
466 if let Ok(level) = indent.parse::<u32>() {
467 marks.push(AdfMark::indentation(level));
468 }
469 }
470 if let Some(mode) = attrs.get("breakout") {
471 let width = attrs
472 .get("breakoutWidth")
473 .and_then(|w| w.parse::<u32>().ok());
474 marks.push(AdfMark::breakout(mode, width));
475 }
476
477 let local_id = attrs.get("localId").map(str::to_string);
479
480 let order = if node.node_type == "orderedList" {
482 attrs.get("order").and_then(|v| v.parse::<u32>().ok())
483 } else {
484 None
485 };
486
487 let has_attrs = !marks.is_empty() || local_id.is_some() || order.is_some();
488 if has_attrs {
489 if !marks.is_empty() {
490 let existing = node.marks.get_or_insert_with(Vec::new);
491 existing.extend(marks);
492 }
493 if let Some(id) = local_id {
494 let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
495 node_attrs["localId"] = serde_json::Value::String(id);
496 }
497 if let Some(n) = order {
498 let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
499 node_attrs["order"] = serde_json::json!(n);
500 }
501 self.advance(); }
503 }
504
505 fn try_container_directive(&mut self) -> Result<Option<AdfNode>> {
506 let line = self.current_line();
507 let Some((d, colon_count)) = try_parse_container_open(line) else {
508 return Ok(None);
509 };
510 self.advance(); let mut inner_lines = Vec::new();
514 let mut depth: usize = 0;
515 while !self.at_end() {
516 let current = self.current_line();
517 if try_parse_container_open(current).is_some() {
518 depth += 1;
519 } else if depth == 0 && is_container_close(current, colon_count) {
520 self.advance(); break;
522 } else if depth > 0 && is_container_close(current, 3) {
523 depth -= 1;
524 }
525 inner_lines.push(current.to_string());
526 self.advance();
527 }
528
529 let inner_text = inner_lines.join("\n");
530
531 let node = match d.name.as_str() {
532 "panel" => {
533 let panel_type = d
534 .attrs
535 .as_ref()
536 .and_then(|a| a.get("type"))
537 .unwrap_or("info");
538 let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
539 let mut node = AdfNode::panel(panel_type, inner_blocks);
540 if let Some(ref attrs) = d.attrs {
542 if let Some(ref mut node_attrs) = node.attrs {
543 if let Some(icon) = attrs.get("icon") {
544 node_attrs["panelIcon"] = serde_json::Value::String(icon.to_string());
545 }
546 if let Some(color) = attrs.get("color") {
547 node_attrs["panelColor"] = serde_json::Value::String(color.to_string());
548 }
549 }
550 }
551 node
552 }
553 "expand" => {
554 let title = d.attrs.as_ref().and_then(|a| a.get("title"));
555 let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
556 let mut node = AdfNode::expand(title, inner_blocks);
557 pass_through_expand_params(&d.attrs, &mut node);
558 node
559 }
560 "nested-expand" => {
561 let title = d.attrs.as_ref().and_then(|a| a.get("title"));
562 let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
563 let mut node = AdfNode::nested_expand(title, inner_blocks);
564 pass_through_expand_params(&d.attrs, &mut node);
565 node
566 }
567 "layout" => {
568 let columns = self.parse_layout_columns(&inner_text)?;
570 AdfNode::layout_section(columns)
571 }
572 "decisions" => {
573 let items = parse_decision_items(&inner_text);
574 AdfNode::decision_list(items)
575 }
576 "table" => {
577 let rows = self.parse_directive_table_rows(&inner_text)?;
578 let mut table_attrs = serde_json::json!({});
579 if let Some(ref attrs) = d.attrs {
580 if let Some(layout) = attrs.get("layout") {
581 table_attrs["layout"] = serde_json::Value::String(layout.to_string());
582 }
583 if attrs.has_flag("numbered") {
584 table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
585 } else if attrs.get("numbered") == Some("false") {
586 table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
587 }
588 if let Some(tw) = attrs.get("width") {
589 if let Some(w) = parse_numeric_attr(tw) {
590 table_attrs["width"] = w;
591 }
592 }
593 if let Some(local_id) = attrs.get("localId") {
594 table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
595 }
596 }
597 if table_attrs == serde_json::json!({}) {
598 AdfNode::table(rows)
599 } else {
600 AdfNode::table_with_attrs(rows, table_attrs)
601 }
602 }
603 "extension" => {
604 let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
605 let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
606 let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
607 let mut node = AdfNode::bodied_extension(ext_type, ext_key, inner_blocks);
608 if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
609 {
610 if let Some(layout) = dir_attrs.get("layout") {
611 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
612 }
613 if let Some(local_id) = dir_attrs.get("localId") {
614 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
615 }
616 if let Some(params_str) = dir_attrs.get("params") {
617 if let Ok(params_val) =
618 serde_json::from_str::<serde_json::Value>(params_str)
619 {
620 node_attrs["parameters"] = params_val;
621 }
622 }
623 }
624 node
625 }
626 _ => return Ok(None),
627 };
628
629 Ok(Some(node))
630 }
631
632 fn parse_layout_columns(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
633 let mut columns = Vec::new();
634 let mut current_column_lines: Vec<String> = Vec::new();
635 let mut current_width: serde_json::Value = serde_json::json!(50);
636 let mut current_dir_attrs: Option<crate::atlassian::attrs::Attrs> = None;
637 let mut in_column = false;
638 let mut depth: usize = 0;
639
640 let lines: Vec<&str> = inner_text.lines().collect();
641 let mut i = 0;
642
643 while i < lines.len() {
644 let line = lines[i];
645 if let Some((col_d, _)) = try_parse_container_open(line) {
646 if col_d.name == "column" && depth == 0 {
647 if in_column && !current_column_lines.is_empty() {
649 let col_text = current_column_lines.join("\n");
650 let blocks = MarkdownParser::new(&col_text).parse_blocks()?;
651 let mut col = AdfNode::layout_column(current_width.clone(), blocks);
652 pass_through_local_id(¤t_dir_attrs, &mut col);
653 columns.push(col);
654 current_column_lines.clear();
655 }
656 current_width = col_d
657 .attrs
658 .as_ref()
659 .and_then(|a| a.get("width"))
660 .and_then(parse_numeric_attr)
661 .unwrap_or_else(|| serde_json::json!(50));
662 current_dir_attrs = col_d.attrs;
663 in_column = true;
664 i += 1;
665 continue;
666 }
667 if in_column {
668 depth += 1;
669 }
670 }
671 if in_column && is_container_close(line, 3) {
672 if depth > 0 {
673 depth -= 1;
674 current_column_lines.push(line.to_string());
675 i += 1;
676 continue;
677 }
678 let col_text = current_column_lines.join("\n");
680 let blocks = MarkdownParser::new(&col_text).parse_blocks()?;
681 let mut col = AdfNode::layout_column(current_width.clone(), blocks);
682 pass_through_local_id(¤t_dir_attrs, &mut col);
683 columns.push(col);
684 current_column_lines.clear();
685 current_dir_attrs = None;
686 in_column = false;
687 i += 1;
688 continue;
689 }
690 if in_column {
691 current_column_lines.push(line.to_string());
692 }
693 i += 1;
694 }
695
696 if in_column && !current_column_lines.is_empty() {
698 let col_text = current_column_lines.join("\n");
699 let blocks = MarkdownParser::new(&col_text).parse_blocks()?;
700 let mut col = AdfNode::layout_column(current_width, blocks);
701 pass_through_local_id(¤t_dir_attrs, &mut col);
702 columns.push(col);
703 }
704
705 Ok(columns)
706 }
707
708 fn parse_directive_table_rows(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
710 debug!(
711 "parse_directive_table_rows: {} lines of inner text",
712 inner_text.lines().count()
713 );
714 let mut rows = Vec::new();
715 let lines: Vec<&str> = inner_text.lines().collect();
716 let mut i = 0;
717
718 while i < lines.len() {
719 let line = lines[i];
720 if let Some((d, _)) = try_parse_container_open(line) {
721 if d.name == "tr" {
722 let tr_attrs = d.attrs.clone();
723 i += 1;
724 let (mut row, next_i) = self.parse_directive_table_row(&lines, i)?;
725 if let Some(ref attrs) = tr_attrs {
727 if let Some(local_id) = attrs.get("localId") {
728 let row_attrs = row.attrs.get_or_insert_with(|| serde_json::json!({}));
729 row_attrs["localId"] = serde_json::Value::String(local_id.to_string());
730 }
731 }
732 rows.push(row);
733 i = next_i;
734 continue;
735 }
736 if d.name == "caption" {
737 let dir_attrs = d.attrs.clone();
738 i += 1;
739 let mut caption_lines = Vec::new();
740 while i < lines.len() {
741 if is_container_close(lines[i], 3) {
742 i += 1;
743 break;
744 }
745 caption_lines.push(lines[i]);
746 i += 1;
747 }
748 let caption_text = caption_lines.join("\n");
749 let inline_nodes = parse_inline(&caption_text);
750 let mut caption = AdfNode::caption(inline_nodes);
751 pass_through_local_id(&dir_attrs, &mut caption);
752 rows.push(caption);
753 continue;
754 }
755 }
756 i += 1;
757 }
758
759 Ok(rows)
760 }
761
762 fn parse_directive_table_row(&self, lines: &[&str], start: usize) -> Result<(AdfNode, usize)> {
764 let mut cells = Vec::new();
765 let mut i = start;
766 let mut depth: usize = 0;
767
768 while i < lines.len() {
769 let line = lines[i];
770 if is_container_close(line, 3) {
771 if depth == 0 {
772 i += 1;
774 break;
775 }
776 depth -= 1;
777 i += 1;
778 continue;
779 }
780 if let Some((d, _)) = try_parse_container_open(line) {
781 if depth == 0 && (d.name == "th" || d.name == "td") {
782 let is_header = d.name == "th";
783 let cell_attrs = d.attrs.clone();
784 i += 1;
785 let (cell, next_i) =
786 self.parse_directive_table_cell(lines, i, is_header, cell_attrs)?;
787 cells.push(cell);
788 i = next_i;
789 continue;
790 }
791 depth += 1;
792 }
793 i += 1;
794 }
795
796 if cells.is_empty() {
797 let context = lines[start.saturating_sub(1)..lines.len().min(start + 3)].to_vec();
798 warn!(
799 "Directive table row at line {start} has no cells — \
800 Confluence requires at least one. Nearby lines: {context:?}"
801 );
802 }
803 debug!("Parsed directive table row: {} cells", cells.len());
804
805 Ok((AdfNode::table_row(cells), i))
806 }
807
808 fn parse_directive_table_cell(
810 &self,
811 lines: &[&str],
812 start: usize,
813 is_header: bool,
814 cell_attrs: Option<crate::atlassian::attrs::Attrs>,
815 ) -> Result<(AdfNode, usize)> {
816 let mut cell_lines = Vec::new();
817 let mut i = start;
818 let mut depth: usize = 0;
819
820 while i < lines.len() {
821 let line = lines[i];
822 if try_parse_container_open(line).is_some() {
823 depth += 1;
824 } else if is_container_close(line, 3) {
825 if depth == 0 {
826 i += 1;
827 break;
828 }
829 depth -= 1;
830 }
831 cell_lines.push(line.to_string());
832 i += 1;
833 }
834
835 let cell_text = cell_lines.join("\n");
836 let blocks = MarkdownParser::new(&cell_text).parse_blocks()?;
837
838 let adf_attrs = cell_attrs.as_ref().map(build_cell_attrs);
839 let cell_marks = cell_attrs
840 .as_ref()
841 .map(build_border_marks)
842 .unwrap_or_default();
843
844 let cell = if cell_marks.is_empty() {
845 if is_header {
846 if let Some(attrs) = adf_attrs {
847 AdfNode::table_header_with_attrs(blocks, attrs)
848 } else {
849 AdfNode::table_header(blocks)
850 }
851 } else if let Some(attrs) = adf_attrs {
852 AdfNode::table_cell_with_attrs(blocks, attrs)
853 } else {
854 AdfNode::table_cell(blocks)
855 }
856 } else if is_header {
857 AdfNode::table_header_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
858 } else {
859 AdfNode::table_cell_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
860 };
861
862 Ok((cell, i))
863 }
864
865 fn try_leaf_directive(&mut self) -> Option<AdfNode> {
866 let line = self.current_line();
867 let d = try_parse_leaf_directive(line)?;
868
869 let node = match d.name.as_str() {
870 "card" => {
871 let content = d.content.as_deref().unwrap_or("");
872 let url = match d.attrs.as_ref().and_then(|a| a.get("url")) {
877 Some(u) => u,
878 None => content,
879 };
880 let mut node = AdfNode::block_card(url);
881 if let Some(ref attrs) = d.attrs {
883 if let Some(ref mut node_attrs) = node.attrs {
884 if let Some(layout) = attrs.get("layout") {
885 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
886 }
887 if let Some(width) = attrs.get("width") {
888 if let Ok(w) = width.parse::<u64>() {
889 node_attrs["width"] = serde_json::json!(w);
890 }
891 }
892 }
893 }
894 node
895 }
896 "embed" => {
897 let url = d.content.as_deref().unwrap_or("");
898 let layout = d.attrs.as_ref().and_then(|a| a.get("layout"));
899 let original_height = d
900 .attrs
901 .as_ref()
902 .and_then(|a| a.get("originalHeight"))
903 .and_then(|v| v.parse::<f64>().ok());
904 let width = d
905 .attrs
906 .as_ref()
907 .and_then(|a| a.get("width"))
908 .and_then(|w| w.parse::<f64>().ok());
909 AdfNode::embed_card(url, layout, original_height, width)
910 }
911 "extension" => {
912 let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
913 let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
914 let params = d
915 .attrs
916 .as_ref()
917 .and_then(|a| a.get("params"))
918 .and_then(|p| serde_json::from_str(p).ok());
919 let mut node = AdfNode::extension(ext_type, ext_key, params);
920 if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
921 {
922 if let Some(layout) = dir_attrs.get("layout") {
923 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
924 }
925 if let Some(local_id) = dir_attrs.get("localId") {
926 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
927 }
928 }
929 node
930 }
931 "paragraph" => {
932 let mut node = if let Some(ref text) = d.content {
933 AdfNode::paragraph(parse_inline(text))
934 } else {
935 AdfNode::paragraph(vec![])
936 };
937 pass_through_local_id(&d.attrs, &mut node);
938 node
939 }
940 _ => return None,
941 };
942
943 self.advance();
944 Some(node)
945 }
946
947 fn try_image(&mut self) -> Option<AdfNode> {
948 let line = self.current_line().trim();
949 let mut node = try_parse_media_single_from_line(line)?;
950 self.advance();
951
952 if !self.at_end() {
954 if let Some((d, _)) = try_parse_container_open(self.current_line()) {
955 if d.name == "caption" {
956 let dir_attrs = d.attrs;
957 self.advance(); let mut caption_lines = Vec::new();
959 while !self.at_end() {
960 if is_container_close(self.current_line(), 3) {
961 self.advance(); break;
963 }
964 caption_lines.push(self.current_line());
965 self.advance();
966 }
967 let caption_text = caption_lines.join("\n");
968 let inline_nodes = parse_inline(&caption_text);
969 let mut caption = AdfNode::caption(inline_nodes);
970 pass_through_local_id(&dir_attrs, &mut caption);
971 if let Some(ref mut content) = node.content {
972 content.push(caption);
973 }
974 }
975 }
976 }
977
978 Some(node)
979 }
980
981 fn try_table(&mut self) -> Result<Option<AdfNode>> {
982 let line = self.current_line();
983 if !line.contains('|') || !line.trim_start().starts_with('|') {
984 return Ok(None);
985 }
986
987 if self.pos + 1 >= self.lines.len() {
989 return Ok(None);
990 }
991 let next_line = self.lines[self.pos + 1];
992 if !is_table_separator(next_line) {
993 return Ok(None);
994 }
995
996 let header_cells = parse_table_row(line);
998 self.advance(); let sep_line = self.current_line();
1002 let alignments = parse_table_alignments(sep_line);
1003 self.advance(); let mut rows = Vec::new();
1006
1007 let header_adf_cells: Vec<AdfNode> = header_cells
1009 .iter()
1010 .enumerate()
1011 .map(|(col_idx, cell)| {
1012 let (cell_text, cell_attrs) = extract_cell_attrs(cell);
1013 let mut para = AdfNode::paragraph(parse_inline(&cell_text));
1014 apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
1015 if let Some(attrs) = cell_attrs {
1016 AdfNode::table_header_with_attrs(vec![para], attrs)
1017 } else {
1018 AdfNode::table_header(vec![para])
1019 }
1020 })
1021 .collect();
1022 if header_adf_cells.is_empty() {
1023 warn!(
1024 "Pipe table header row at line {} has no cells",
1025 self.pos - 1
1026 );
1027 }
1028 rows.push(AdfNode::table_row(header_adf_cells));
1029
1030 while !self.at_end() {
1032 let line = self.current_line();
1033 if !line.contains('|') || line.trim().is_empty() {
1034 break;
1035 }
1036
1037 let cells = parse_table_row(line);
1038 let adf_cells: Vec<AdfNode> = cells
1039 .iter()
1040 .enumerate()
1041 .map(|(col_idx, cell)| {
1042 let (cell_text, cell_attrs) = extract_cell_attrs(cell);
1043 let mut para = AdfNode::paragraph(parse_inline(&cell_text));
1044 apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
1045 if let Some(attrs) = cell_attrs {
1046 AdfNode::table_cell_with_attrs(vec![para], attrs)
1047 } else {
1048 AdfNode::table_cell(vec![para])
1049 }
1050 })
1051 .collect();
1052 if adf_cells.is_empty() {
1053 warn!("Pipe table body row at line {} has no cells", self.pos);
1054 }
1055 rows.push(AdfNode::table_row(adf_cells));
1056 self.advance();
1057 }
1058
1059 debug!("Parsed pipe table with {} rows", rows.len());
1060 let mut table = AdfNode::table(rows);
1061
1062 if !self.at_end() {
1064 let next = self.current_line().trim();
1065 if next.starts_with('{') {
1066 if let Some((_, attrs)) = parse_attrs(next, 0) {
1067 let mut table_attrs = serde_json::json!({});
1068 if let Some(layout) = attrs.get("layout") {
1069 table_attrs["layout"] = serde_json::Value::String(layout.to_string());
1070 }
1071 if attrs.has_flag("numbered") {
1072 table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
1073 } else if attrs.get("numbered") == Some("false") {
1074 table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
1075 }
1076 if let Some(tw) = attrs.get("width") {
1077 if let Some(w) = parse_numeric_attr(tw) {
1078 table_attrs["width"] = w;
1079 }
1080 }
1081 if let Some(local_id) = attrs.get("localId") {
1082 table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1083 }
1084 if table_attrs != serde_json::json!({}) {
1085 table.attrs = Some(table_attrs);
1086 self.advance(); }
1088 }
1089 }
1090 }
1091
1092 Ok(Some(table))
1093 }
1094
1095 fn parse_paragraph(&mut self) -> Result<AdfNode> {
1096 let mut lines: Vec<&str> = Vec::new();
1097
1098 while !self.at_end() {
1099 let line = self.current_line();
1100 if (line.trim().is_empty()
1109 && !lines
1110 .last()
1111 .is_some_and(|prev| has_trailing_hard_break(prev)))
1112 || is_code_fence_opener(line)
1113 || (is_horizontal_rule(line) && !lines.is_empty())
1114 {
1115 break;
1116 }
1117 let is_hardbreak_cont = !lines.is_empty()
1120 && line.starts_with(" ")
1121 && lines
1122 .last()
1123 .is_some_and(|prev| has_trailing_hard_break(prev));
1124 if is_hardbreak_cont {
1125 lines.push(&line[2..]);
1126 self.advance();
1127 continue;
1128 }
1129 if !lines.is_empty()
1130 && (line.starts_with('#') || line.starts_with('>') || is_list_start(line))
1131 {
1132 break;
1133 }
1134 if !lines.is_empty() && is_block_attrs_line(line) {
1136 break;
1137 }
1138 lines.push(line);
1139 self.advance();
1140 }
1141
1142 let text = lines.join("\n");
1143 let inline_nodes = parse_inline(&text);
1144 Ok(AdfNode::paragraph(inline_nodes))
1145 }
1146}
1147
1148fn build_cell_attrs(attrs: &crate::atlassian::attrs::Attrs) -> serde_json::Value {
1151 let mut adf = serde_json::json!({});
1152 if let Some(bg) = attrs.get("bg") {
1153 adf["background"] = serde_json::Value::String(bg.to_string());
1154 }
1155 if let Some(colspan) = attrs.get("colspan") {
1156 if let Ok(n) = colspan.parse::<u32>() {
1157 adf["colspan"] = serde_json::json!(n);
1158 }
1159 }
1160 if let Some(rowspan) = attrs.get("rowspan") {
1161 if let Ok(n) = rowspan.parse::<u32>() {
1162 adf["rowspan"] = serde_json::json!(n);
1163 }
1164 }
1165 if let Some(colwidth) = attrs.get("colwidth") {
1166 let widths: Vec<serde_json::Value> = colwidth
1167 .split(',')
1168 .filter_map(|s| parse_numeric_attr(s.trim()))
1169 .collect();
1170 if !widths.is_empty() {
1171 adf["colwidth"] = serde_json::Value::Array(widths);
1172 }
1173 }
1174 if let Some(local_id) = attrs.get("localId") {
1175 adf["localId"] = serde_json::Value::String(local_id.to_string());
1176 }
1177 adf
1178}
1179
1180fn build_border_marks(attrs: &crate::atlassian::attrs::Attrs) -> Vec<AdfMark> {
1182 let mut marks = Vec::new();
1183 let border_color = attrs.get("border-color");
1184 let border_size = attrs.get("border-size");
1185 if border_color.is_some() || border_size.is_some() {
1186 let color = border_color.unwrap_or("#000000");
1187 let size = border_size.and_then(|s| s.parse::<u32>().ok()).unwrap_or(1);
1188 marks.push(AdfMark::border(color, size));
1189 }
1190 marks
1191}
1192
1193fn iso_date_to_epoch_ms(date_str: &str) -> String {
1196 if date_str.chars().all(|c| c.is_ascii_digit()) {
1198 return date_str.to_string();
1199 }
1200 if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
1201 let epoch_ms = date
1202 .and_hms_opt(0, 0, 0)
1203 .map_or(0, |dt| dt.and_utc().timestamp_millis());
1204 epoch_ms.to_string()
1205 } else {
1206 date_str.to_string()
1208 }
1209}
1210
1211fn epoch_ms_to_iso_date(timestamp: &str) -> String {
1214 if timestamp.contains('-') {
1216 return timestamp.to_string();
1217 }
1218 if let Ok(ms) = timestamp.parse::<i64>() {
1219 let secs = ms / 1000;
1220 if let Some(dt) = chrono::DateTime::from_timestamp(secs, 0) {
1221 return dt.format("%Y-%m-%d").to_string();
1222 }
1223 }
1224 timestamp.to_string()
1226}
1227
1228fn is_block_attrs_line(line: &str) -> bool {
1230 let trimmed = line.trim();
1231 if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
1232 return false;
1233 }
1234 if let Some((_, attrs)) = parse_attrs(trimmed, 0) {
1235 attrs.get("align").is_some()
1237 || attrs.get("indent").is_some()
1238 || attrs.get("breakout").is_some()
1239 || attrs.get("breakoutWidth").is_some()
1240 || attrs.get("localId").is_some()
1241 } else {
1242 false
1243 }
1244}
1245
1246fn parse_decision_items(text: &str) -> Vec<AdfNode> {
1249 let mut items = Vec::new();
1250 for line in text.lines() {
1251 let trimmed = line.trim();
1252 if let Some(rest) = trimmed.strip_prefix("- <> ") {
1253 let inline_nodes = parse_inline(rest);
1254 items.push(AdfNode::decision_item("DECIDED", inline_nodes));
1255 }
1256 }
1257 items
1258}
1259
1260fn try_parse_task_marker(text: &str) -> Option<(&str, &str)> {
1268 if let Some(rest) = strip_task_checkbox(text, "[ ]") {
1269 Some(("TODO", rest))
1270 } else if let Some(rest) =
1271 strip_task_checkbox(text, "[x]").or_else(|| strip_task_checkbox(text, "[X]"))
1272 {
1273 Some(("DONE", rest))
1274 } else {
1275 None
1276 }
1277}
1278
1279fn strip_task_checkbox<'a>(text: &'a str, checkbox: &str) -> Option<&'a str> {
1283 let rest = text.strip_prefix(checkbox)?;
1284 if rest.is_empty() {
1285 Some(rest)
1286 } else {
1287 rest.strip_prefix(' ')
1288 }
1289}
1290
1291fn starts_with_task_marker(s: &str) -> bool {
1299 let after = if let Some(rest) = s.strip_prefix("[ ]") {
1300 rest
1301 } else if let Some(rest) = s.strip_prefix("[x]").or_else(|| s.strip_prefix("[X]")) {
1302 rest
1303 } else {
1304 return false;
1305 };
1306 after.is_empty() || after.starts_with(' ') || after.starts_with('\n')
1307}
1308
1309fn parse_ordered_list_marker(line: &str) -> Option<(u32, &str)> {
1311 let digit_end = line.find(|c: char| !c.is_ascii_digit())?;
1312 if digit_end == 0 {
1313 return None;
1314 }
1315 let rest = &line[digit_end..];
1316 let after_marker = rest.strip_prefix(". ")?;
1317 let num: u32 = line[..digit_end].parse().ok()?;
1318 Some((num, after_marker))
1319}
1320
1321fn has_trailing_hard_break(line: &str) -> bool {
1324 line.ends_with('\\') || line.ends_with(" ")
1325}
1326
1327fn is_block_level_continuation_marker(trimmed: &str) -> bool {
1335 trimmed.starts_with("![") || trimmed.starts_with("```") || trimmed.starts_with(":::")
1336}
1337
1338fn is_list_start(line: &str) -> bool {
1340 let trimmed = line.trim_start();
1341 trimmed.starts_with("- ")
1342 || trimmed.starts_with("* ")
1343 || trimmed.starts_with("+ ")
1344 || parse_ordered_list_marker(trimmed).is_some()
1345}
1346
1347fn escape_emphasis_markers(text: &str) -> String {
1360 escape_emphasis_with(text, false)
1361}
1362
1363fn escape_emphasis_markers_with_underscore(text: &str) -> String {
1371 escape_emphasis_with(text, true)
1372}
1373
1374fn escape_emphasis_with(text: &str, escape_underscore_always: bool) -> String {
1380 let chars: Vec<char> = text.chars().collect();
1381 let mut out = String::with_capacity(text.len());
1382 let mut idx = 0;
1383 while idx < chars.len() {
1384 let ch = chars[idx];
1385 if ch == '*' {
1386 out.push('\\');
1387 out.push(ch);
1388 idx += 1;
1389 } else if ch == '_' {
1390 let run_start = idx;
1394 let mut run_end = idx;
1395 while run_end < chars.len() && chars[run_end] == '_' {
1396 run_end += 1;
1397 }
1398 let escape_run = if escape_underscore_always {
1399 true
1400 } else {
1401 let before_alnum = run_start > 0 && chars[run_start - 1].is_alphanumeric();
1402 let after_alnum = chars.get(run_end).is_some_and(|c| c.is_alphanumeric());
1403 !(before_alnum && after_alnum)
1404 };
1405 for _ in run_start..run_end {
1406 if escape_run {
1407 out.push('\\');
1408 }
1409 out.push('_');
1410 }
1411 idx = run_end;
1412 } else {
1413 out.push(ch);
1414 idx += 1;
1415 }
1416 }
1417 out
1418}
1419
1420fn escape_backticks(text: &str) -> String {
1426 let mut out = String::with_capacity(text.len());
1427 for ch in text.chars() {
1428 if ch == '`' {
1429 out.push('\\');
1430 }
1431 out.push(ch);
1432 }
1433 out
1434}
1435
1436fn inline_code_delimiter(text: &str) -> (usize, bool) {
1444 let mut max_run = 0usize;
1445 let mut current = 0usize;
1446 for ch in text.chars() {
1447 if ch == '`' {
1448 current += 1;
1449 if current > max_run {
1450 max_run = current;
1451 }
1452 } else {
1453 current = 0;
1454 }
1455 }
1456 let n = max_run + 1;
1457 let starts_bt = text.starts_with('`');
1458 let ends_bt = text.ends_with('`');
1459 let starts_sp = text.starts_with(' ');
1460 let ends_sp = text.ends_with(' ');
1461 let all_sp = !text.is_empty() && text.chars().all(|c| c == ' ');
1462 let needs_pad = starts_bt || ends_bt || (starts_sp && ends_sp && !all_sp);
1463 (n, needs_pad)
1464}
1465
1466fn render_inline_code(text: &str, output: &mut String) {
1469 let (n, pad) = inline_code_delimiter(text);
1470 for _ in 0..n {
1471 output.push('`');
1472 }
1473 if pad {
1474 output.push(' ');
1475 }
1476 output.push_str(text);
1477 if pad {
1478 output.push(' ');
1479 }
1480 for _ in 0..n {
1481 output.push('`');
1482 }
1483}
1484
1485fn escape_pipes_in_cell(text: &str) -> String {
1492 let mut out = String::with_capacity(text.len());
1493 for ch in text.chars() {
1494 if ch == '|' {
1495 out.push('\\');
1496 }
1497 out.push(ch);
1498 }
1499 out
1500}
1501
1502fn escape_link_brackets(text: &str) -> String {
1507 let mut out = String::with_capacity(text.len());
1508 for ch in text.chars() {
1509 if ch == '[' || ch == ']' {
1510 out.push('\\');
1511 }
1512 out.push(ch);
1513 }
1514 out
1515}
1516
1517fn escape_bare_urls(text: &str) -> String {
1523 let mut result = String::with_capacity(text.len());
1524 for (i, ch) in text.char_indices() {
1525 if ch == 'h' {
1526 let rest = &text[i..];
1527 if rest.starts_with("http://") || rest.starts_with("https://") {
1528 result.push('\\');
1529 }
1530 }
1531 result.push(ch);
1532 }
1533 result
1534}
1535
1536fn url_safe_in_bracket_content(s: &str) -> bool {
1545 if s.contains('\n') {
1546 return false;
1547 }
1548 let mut depth: i32 = 1;
1549 for ch in s.chars() {
1550 match ch {
1551 '[' => depth += 1,
1552 ']' => {
1553 depth -= 1;
1554 if depth == 0 {
1555 return false;
1556 }
1557 }
1558 _ => {}
1559 }
1560 }
1561 true
1562}
1563
1564fn escape_emoji_shortcodes(text: &str) -> String {
1575 let mut result = String::with_capacity(text.len());
1576
1577 for (i, ch) in text.char_indices() {
1578 if ch == ':' {
1579 let after = i + 1;
1582 if after < text.len() {
1583 let name_end = text[after..]
1584 .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')
1585 .map_or(text[after..].len(), |pos| pos);
1586 if name_end > 0
1587 && after + name_end < text.len()
1588 && text.as_bytes()[after + name_end] == b':'
1589 {
1590 result.push('\\');
1592 }
1593 }
1594 }
1595 result.push(ch);
1596 }
1597
1598 result
1599}
1600
1601fn escape_list_marker(line: &str) -> String {
1605 if let Some(dot_pos) = line.find(". ") {
1606 if parse_ordered_list_marker(line).is_some() {
1607 let mut s = String::with_capacity(line.len() + 1);
1608 s.push_str(&line[..dot_pos]);
1609 s.push('\\');
1610 s.push_str(&line[dot_pos..]);
1611 return s;
1612 }
1613 }
1614 for prefix in &["- ", "* ", "+ "] {
1615 if line.starts_with(prefix) {
1616 let mut s = String::with_capacity(line.len() + 1);
1617 s.push('\\');
1618 s.push_str(line);
1619 return s;
1620 }
1621 }
1622 line.to_string()
1623}
1624
1625fn is_code_fence_opener(line: &str) -> bool {
1632 if !line.starts_with("```") {
1633 return false;
1634 }
1635 !line[3..].contains('`')
1636}
1637
1638fn is_horizontal_rule(line: &str) -> bool {
1640 let trimmed = line.trim();
1641 trimmed.len() >= 3
1642 && ((trimmed.starts_with("---") && trimmed.chars().all(|c| c == '-'))
1643 || (trimmed.starts_with("***") && trimmed.chars().all(|c| c == '*'))
1644 || (trimmed.starts_with("___") && trimmed.chars().all(|c| c == '_')))
1645}
1646
1647fn is_table_separator(line: &str) -> bool {
1649 let trimmed = line.trim();
1650 trimmed.contains('|')
1651 && trimmed
1652 .chars()
1653 .all(|c| c == '|' || c == '-' || c == ':' || c == ' ')
1654}
1655
1656fn parse_table_row(line: &str) -> Vec<String> {
1663 let trimmed = line.trim();
1664 let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1665 let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1666
1667 let mut cells: Vec<String> = Vec::new();
1668 let mut current = String::new();
1669 let mut chars = trimmed.chars().peekable();
1670 while let Some(ch) = chars.next() {
1671 if ch == '\\' && chars.peek() == Some(&'|') {
1672 current.push('|');
1673 chars.next();
1674 } else if ch == '|' {
1675 cells.push(std::mem::take(&mut current));
1676 } else {
1677 current.push(ch);
1678 }
1679 }
1680 cells.push(current);
1681
1682 cells
1683 .iter()
1684 .map(|s| {
1685 let stripped = s.strip_prefix(' ').unwrap_or(s.as_str());
1688 let stripped = stripped.strip_suffix(' ').unwrap_or(stripped);
1689 stripped.to_string()
1690 })
1691 .collect()
1692}
1693
1694fn parse_table_alignments(separator_line: &str) -> Vec<Option<&'static str>> {
1697 let trimmed = separator_line.trim();
1698 let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1699 let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1700
1701 trimmed
1702 .split('|')
1703 .map(|cell| {
1704 let cell = cell.trim();
1705 let starts_colon = cell.starts_with(':');
1706 let ends_colon = cell.ends_with(':');
1707 match (starts_colon, ends_colon) {
1708 (true, true) => Some("center"),
1709 (false, true) => Some("end"),
1710 _ => None, }
1712 })
1713 .collect()
1714}
1715
1716fn apply_column_alignment(para: &mut AdfNode, alignment: Option<&str>) {
1718 if let Some(align) = alignment {
1719 para.marks = Some(vec![AdfMark::alignment(align)]);
1720 }
1721}
1722
1723fn extract_cell_attrs(cell_text: &str) -> (String, Option<serde_json::Value>) {
1726 let trimmed = cell_text.trim_start();
1727 if !trimmed.starts_with('{') {
1728 return (cell_text.to_string(), None);
1729 }
1730 if let Some((end_pos, attrs)) = parse_attrs(trimmed, 0) {
1731 let remaining = trimmed[end_pos..].trim_start().to_string();
1732 let adf_attrs = build_cell_attrs(&attrs);
1733 (remaining, Some(adf_attrs))
1734 } else {
1735 (cell_text.to_string(), None)
1736 }
1737}
1738
1739fn try_parse_media_single_from_line(line: &str) -> Option<AdfNode> {
1742 let line = line.trim();
1743 if !line.starts_with("? + 1; let img_end = find_closing_paren(line, paren_open)? + 1;
1752 let after_img = line[img_end..].trim_start();
1753
1754 if after_img.starts_with('{') {
1755 if let Some((_, attrs)) = parse_attrs(after_img, 0) {
1756 if attrs.get("type") == Some("file") || attrs.get("id").is_some() {
1758 let mut media_attrs = serde_json::json!({"type": "file"});
1759 if let Some(id) = attrs.get("id") {
1760 media_attrs["id"] = serde_json::Value::String(id.to_string());
1761 }
1762 if let Some(collection) = attrs.get("collection") {
1763 media_attrs["collection"] = serde_json::Value::String(collection.to_string());
1764 }
1765 if let Some(occurrence_key) = attrs.get("occurrenceKey") {
1766 media_attrs["occurrenceKey"] =
1767 serde_json::Value::String(occurrence_key.to_string());
1768 }
1769 if let Some(height) = attrs.get("height") {
1770 if let Some(h) = parse_numeric_attr(height) {
1771 media_attrs["height"] = h;
1772 }
1773 }
1774 if let Some(width) = attrs.get("width") {
1775 if let Some(w) = parse_numeric_attr(width) {
1776 media_attrs["width"] = w;
1777 }
1778 }
1779 if let Some(alt_text) = alt_opt {
1780 media_attrs["alt"] = serde_json::Value::String(alt_text.to_string());
1781 }
1782 if let Some(local_id) = attrs.get("localId") {
1783 media_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1784 }
1785 let mut ms_attrs = serde_json::json!({"layout": "center"});
1786 if let Some(layout) = attrs.get("layout") {
1787 ms_attrs["layout"] = serde_json::Value::String(layout.to_string());
1788 }
1789 if let Some(ms_width) = attrs.get("mediaWidth") {
1790 if let Some(w) = parse_numeric_attr(ms_width) {
1791 ms_attrs["width"] = w;
1792 }
1793 }
1794 if let Some(wt) = attrs.get("widthType") {
1795 ms_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1796 }
1797 if let Some(mode) = attrs.get("mode") {
1798 ms_attrs["mode"] = serde_json::Value::String(mode.to_string());
1799 }
1800 let border_marks = build_border_marks(&attrs);
1801 let media_marks = if border_marks.is_empty() {
1802 None
1803 } else {
1804 Some(border_marks)
1805 };
1806 return Some(AdfNode {
1807 node_type: "mediaSingle".to_string(),
1808 attrs: Some(ms_attrs),
1809 content: Some(vec![AdfNode {
1810 node_type: "media".to_string(),
1811 attrs: Some(media_attrs),
1812 content: None,
1813 text: None,
1814 marks: media_marks,
1815 local_id: None,
1816 parameters: None,
1817 }]),
1818 text: None,
1819 marks: None,
1820 local_id: None,
1821 parameters: None,
1822 });
1823 }
1824
1825 let mut node = AdfNode::media_single(url, alt_opt);
1827 if let Some(ref mut node_attrs) = node.attrs {
1828 if let Some(layout) = attrs.get("layout") {
1829 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
1830 }
1831 if let Some(width) = attrs.get("width") {
1832 if let Some(w) = parse_numeric_attr(width) {
1833 node_attrs["width"] = w;
1834 }
1835 }
1836 if let Some(wt) = attrs.get("widthType") {
1837 node_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1838 }
1839 if let Some(mode) = attrs.get("mode") {
1840 node_attrs["mode"] = serde_json::Value::String(mode.to_string());
1841 }
1842 }
1843 if let Some(ref mut content) = node.content {
1844 if let Some(media) = content.first_mut() {
1845 if let Some(local_id) = attrs.get("localId") {
1846 if let Some(ref mut media_attrs) = media.attrs {
1847 media_attrs["localId"] =
1848 serde_json::Value::String(local_id.to_string());
1849 }
1850 }
1851 let border_marks = build_border_marks(&attrs);
1852 if !border_marks.is_empty() {
1853 media.marks = Some(border_marks);
1854 }
1855 }
1856 }
1857 return Some(node);
1858 }
1859 }
1860
1861 Some(AdfNode::media_single(url, alt_opt))
1862}
1863
1864fn parse_image_syntax(line: &str) -> Option<(&str, &str)> {
1866 let line = line.trim();
1867 if !line.starts_with("?;
1872 let alt = &line[2..alt_end];
1873 let paren_start = alt_end + 1; let url_end = find_closing_paren(line, paren_start)?;
1875 let url = &line[paren_start + 1..url_end];
1876
1877 Some((alt, url))
1878}
1879
1880fn parse_inline(text: &str) -> Vec<AdfNode> {
1888 parse_inline_impl(text, true)
1889}
1890
1891fn parse_inline_no_auto_cards(text: &str) -> Vec<AdfNode> {
1898 parse_inline_impl(text, false)
1899}
1900
1901fn parse_inline_impl(text: &str, auto_inline_card: bool) -> Vec<AdfNode> {
1906 let mut nodes = Vec::new();
1907 let mut chars = text.char_indices().peekable();
1908 let mut plain_start = 0;
1909
1910 while let Some(&(i, ch)) = chars.peek() {
1911 match ch {
1912 '*' | '_' => {
1913 if let Some((end, content, is_bold)) = try_parse_emphasis(text, i) {
1914 flush_plain(text, plain_start, i, &mut nodes);
1915 let mark = if is_bold {
1916 AdfMark::strong()
1917 } else {
1918 AdfMark::em()
1919 };
1920 let inner = parse_inline_no_auto_cards(content);
1921 for mut node in inner {
1922 prepend_mark(&mut node, mark.clone());
1923 nodes.push(node);
1924 }
1925 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1927 chars.next();
1928 }
1929 plain_start = end;
1930 continue;
1931 }
1932 if ch == '_' {
1937 while chars.peek().is_some_and(|&(_, c)| c == '_') {
1938 chars.next();
1939 }
1940 } else {
1941 chars.next();
1942 }
1943 }
1944 '~' => {
1945 if let Some((end, content)) = try_parse_strikethrough(text, i) {
1946 flush_plain(text, plain_start, i, &mut nodes);
1947 let inner = parse_inline_no_auto_cards(content);
1948 for mut node in inner {
1949 prepend_mark(&mut node, AdfMark::strike());
1950 nodes.push(node);
1951 }
1952 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1953 chars.next();
1954 }
1955 plain_start = end;
1956 continue;
1957 }
1958 chars.next();
1959 }
1960 '`' => {
1961 if let Some((end, content)) = try_parse_inline_code(text, i) {
1962 flush_plain(text, plain_start, i, &mut nodes);
1963 nodes.push(AdfNode::text_with_marks(content, vec![AdfMark::code()]));
1964 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1965 chars.next();
1966 }
1967 plain_start = end;
1968 continue;
1969 }
1970 while chars.peek().is_some_and(|&(_, c)| c == '`') {
1973 chars.next();
1974 }
1975 }
1976 '[' => {
1977 if let Some((end, link_text, href)) = try_parse_link(text, i) {
1978 flush_plain(text, plain_start, i, &mut nodes);
1979 if link_text.starts_with("http://") || link_text.starts_with("https://") {
1980 nodes.push(AdfNode::text_with_marks(
1985 link_text,
1986 vec![AdfMark::link(href)],
1987 ));
1988 } else {
1989 let inner = parse_inline_no_auto_cards(link_text);
1990 for mut node in inner {
1991 prepend_mark(&mut node, AdfMark::link(href));
1992 nodes.push(node);
1993 }
1994 }
1995 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1996 chars.next();
1997 }
1998 plain_start = end;
1999 continue;
2000 }
2001 if let Some((end, span_nodes)) = try_parse_bracketed_span(text, i) {
2003 flush_plain(text, plain_start, i, &mut nodes);
2004 nodes.extend(span_nodes);
2005 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2006 chars.next();
2007 }
2008 plain_start = end;
2009 continue;
2010 }
2011 chars.next();
2012 }
2013 ':' => {
2014 if let Some(node) = try_dispatch_inline_directive(text, i) {
2016 flush_plain(text, plain_start, i, &mut nodes);
2017 let end = node.1;
2018 nodes.push(node.0);
2019 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2020 chars.next();
2021 }
2022 plain_start = end;
2023 continue;
2024 }
2025 if let Some((end, short_name)) = try_parse_emoji_shortcode(text, i) {
2027 flush_plain(text, plain_start, i, &mut nodes);
2028 let (final_end, emoji_node) = parse_emoji_with_attrs(text, end, short_name);
2029 nodes.push(emoji_node);
2030 while chars.peek().is_some_and(|&(idx, _)| idx < final_end) {
2031 chars.next();
2032 }
2033 plain_start = final_end;
2034 continue;
2035 }
2036 chars.next();
2037 }
2038 ' ' if text[i..].starts_with(" \n") => {
2039 flush_plain(text, plain_start, i, &mut nodes);
2042 nodes.push(AdfNode::hard_break());
2043 while chars.peek().is_some_and(|&(_, c)| c == ' ') {
2045 chars.next();
2046 }
2047 if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2049 chars.next();
2050 }
2051 plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2052 }
2053 '!' if text[i..].starts_with("![") => {
2054 chars.next();
2058 }
2059 'h' if auto_inline_card
2060 && (text[i..].starts_with("http://") || text[i..].starts_with("https://")) =>
2061 {
2062 if let Some((end, url)) = try_parse_bare_url(text, i) {
2063 flush_plain(text, plain_start, i, &mut nodes);
2064 nodes.push(AdfNode::inline_card(url));
2065 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2066 chars.next();
2067 }
2068 plain_start = end;
2069 continue;
2070 }
2071 chars.next();
2072 }
2073 '\\' if text.as_bytes().get(i + 1) == Some(&b'n')
2074 && text.as_bytes().get(i + 2) != Some(&b'\n') =>
2075 {
2076 flush_plain(text, plain_start, i, &mut nodes);
2080 nodes.push(AdfNode::text("\n"));
2081 chars.next(); chars.next(); plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2084 }
2085 '\\' if i + 1 < text.len() && !text[i..].starts_with("\\\n") => {
2086 flush_plain(text, plain_start, i, &mut nodes);
2091 chars.next(); plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2096 chars.next(); }
2098 '\\' if text[i..].starts_with("\\\n") => {
2099 flush_plain(text, plain_start, i, &mut nodes);
2101 nodes.push(AdfNode::hard_break());
2102 chars.next(); if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2105 chars.next();
2106 }
2107 plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2108 }
2109 '\\' if i + 1 == text.len() => {
2110 flush_plain(text, plain_start, i, &mut nodes);
2112 nodes.push(AdfNode::hard_break());
2113 chars.next(); plain_start = text.len();
2115 }
2116 _ => {
2117 chars.next();
2118 }
2119 }
2120 }
2121
2122 if plain_start < text.len() {
2124 let remaining = &text[plain_start..];
2125 if !remaining.is_empty() {
2126 nodes.push(AdfNode::text(remaining));
2127 }
2128 }
2129
2130 merge_adjacent_text(&mut nodes);
2133
2134 nodes
2135}
2136
2137fn merge_adjacent_text(nodes: &mut Vec<AdfNode>) {
2139 let mut i = 0;
2140 while i + 1 < nodes.len() {
2141 if nodes[i].node_type == "text"
2142 && nodes[i + 1].node_type == "text"
2143 && nodes[i].marks.is_none()
2144 && nodes[i + 1].marks.is_none()
2145 {
2146 let next_text = nodes[i + 1].text.clone().unwrap_or_default();
2147 if let Some(ref mut t) = nodes[i].text {
2148 t.push_str(&next_text);
2149 }
2150 nodes.remove(i + 1);
2151 } else {
2152 i += 1;
2153 }
2154 }
2155}
2156
2157fn flush_plain(text: &str, start: usize, end: usize, nodes: &mut Vec<AdfNode>) {
2159 if start < end {
2160 let plain = &text[start..end];
2161 if !plain.is_empty() {
2162 nodes.push(AdfNode::text(plain));
2163 }
2164 }
2165}
2166
2167#[cfg(test)]
2169fn add_mark(node: &mut AdfNode, mark: AdfMark) {
2170 if let Some(ref mut marks) = node.marks {
2171 marks.push(mark);
2172 } else {
2173 node.marks = Some(vec![mark]);
2174 }
2175}
2176
2177fn prepend_mark(node: &mut AdfNode, mark: AdfMark) {
2179 if let Some(ref mut marks) = node.marks {
2180 marks.insert(0, mark);
2181 } else {
2182 node.marks = Some(vec![mark]);
2183 }
2184}
2185
2186fn is_intraword_underscore(text: &str, delim_pos: usize, len: usize) -> bool {
2191 let before = text[..delim_pos]
2192 .chars()
2193 .next_back()
2194 .is_some_and(char::is_alphanumeric);
2195 let after = text[delim_pos + len..]
2196 .chars()
2197 .next()
2198 .is_some_and(char::is_alphanumeric);
2199 before && after
2200}
2201
2202fn find_unescaped(haystack: &str, needle: &str) -> Option<usize> {
2206 let needle_bytes = needle.as_bytes();
2207 let hay_bytes = haystack.as_bytes();
2208 let mut i = 0;
2209 while i < hay_bytes.len() {
2210 if hay_bytes[i] == b'\\' {
2211 i += 2; continue;
2213 }
2214 if hay_bytes[i..].starts_with(needle_bytes) {
2215 return Some(i);
2216 }
2217 i += 1;
2218 }
2219 None
2220}
2221
2222fn find_unescaped_char(haystack: &str, ch: u8) -> Option<usize> {
2225 let hay_bytes = haystack.as_bytes();
2226 let mut i = 0;
2227 while i < hay_bytes.len() {
2228 if hay_bytes[i] == b'\\' {
2229 i += 2;
2230 continue;
2231 }
2232 if hay_bytes[i] == ch {
2233 return Some(i);
2234 }
2235 i += 1;
2236 }
2237 None
2238}
2239
2240fn try_parse_emphasis(text: &str, i: usize) -> Option<(usize, &str, bool)> {
2250 let rest = &text[i..];
2251
2252 if rest.starts_with("***") || rest.starts_with("___") {
2256 let is_underscore = rest.starts_with("___");
2257 if is_underscore && is_intraword_underscore(text, i, 3) {
2258 return None;
2259 }
2260 let triple = &rest[..3];
2261 let after = &rest[3..];
2262 if let Some(close) = find_unescaped(after, triple) {
2263 if close > 0 {
2264 let close_pos = i + 3 + close;
2265 if is_underscore && is_intraword_underscore(text, close_pos, 3) {
2266 return None;
2267 }
2268 let content = &rest[2..=3 + close];
2272 let end = i + 3 + close + 3;
2273 return Some((end, content, true));
2274 }
2275 }
2276 }
2277
2278 if rest.starts_with("**") || rest.starts_with("__") {
2280 let is_underscore = rest.starts_with("__");
2281 if is_underscore && is_intraword_underscore(text, i, 2) {
2282 return None;
2283 }
2284 let delimiter = &rest[..2];
2285 let after = &rest[2..];
2286 let close = find_unescaped(after, delimiter)?;
2287 if close == 0 {
2288 return None;
2289 }
2290 let close_pos = i + 2 + close;
2291 if is_underscore && is_intraword_underscore(text, close_pos, 2) {
2292 return None;
2293 }
2294 let content = &after[..close];
2295 let end = i + 2 + close + 2;
2296 return Some((end, content, true));
2297 }
2298
2299 if rest.starts_with('*') || rest.starts_with('_') {
2301 let delim_char = rest.as_bytes()[0];
2302 let is_underscore = delim_char == b'_';
2303 if is_underscore && is_intraword_underscore(text, i, 1) {
2304 return None;
2305 }
2306 let after = &rest[1..];
2307 let close = find_unescaped_char(after, delim_char)?;
2308 if close == 0 {
2309 return None;
2310 }
2311 let close_pos = i + 1 + close;
2312 if is_underscore && is_intraword_underscore(text, close_pos, 1) {
2313 return None;
2314 }
2315 let content = &after[..close];
2316 let end = i + 1 + close + 1;
2317 return Some((end, content, false));
2318 }
2319
2320 None
2321}
2322
2323fn try_parse_strikethrough(text: &str, i: usize) -> Option<(usize, &str)> {
2325 let rest = &text[i..];
2326 if !rest.starts_with("~~") {
2327 return None;
2328 }
2329 let after = &rest[2..];
2330 let close = after.find("~~")?;
2331 if close == 0 {
2332 return None;
2333 }
2334 let content = &after[..close];
2335 Some((i + 2 + close + 2, content))
2336}
2337
2338fn try_parse_inline_code(text: &str, i: usize) -> Option<(usize, &str)> {
2345 let rest = &text[i..];
2346 let bytes = rest.as_bytes();
2347 if bytes.is_empty() || bytes[0] != b'`' {
2348 return None;
2349 }
2350 let mut opening = 0usize;
2351 while opening < bytes.len() && bytes[opening] == b'`' {
2352 opening += 1;
2353 }
2354
2355 let mut j = opening;
2356 while j < bytes.len() {
2357 if bytes[j] == b'`' {
2358 let run_start = j;
2359 while j < bytes.len() && bytes[j] == b'`' {
2360 j += 1;
2361 }
2362 if j - run_start == opening {
2363 let content = &rest[opening..run_start];
2364 let content = strip_code_span_padding(content);
2365 return Some((i + j, content));
2366 }
2367 } else {
2368 j += 1;
2369 }
2370 }
2371 None
2372}
2373
2374fn strip_code_span_padding(content: &str) -> &str {
2378 let bytes = content.as_bytes();
2379 if bytes.len() >= 2
2380 && bytes[0] == b' '
2381 && bytes[bytes.len() - 1] == b' '
2382 && content.bytes().any(|b| b != b' ')
2383 {
2384 &content[1..content.len() - 1]
2385 } else {
2386 content
2387 }
2388}
2389
2390fn try_parse_bracketed_span(text: &str, i: usize) -> Option<(usize, Vec<AdfNode>)> {
2393 let rest = &text[i..];
2394 if !rest.starts_with('[') {
2395 return None;
2396 }
2397
2398 let mut depth: usize = 0;
2402 let mut bracket_close = None;
2403 let bs_bytes = rest.as_bytes();
2404 for (j, ch) in rest.char_indices() {
2405 match ch {
2406 '\\' if j + 1 < bs_bytes.len()
2407 && (bs_bytes[j + 1] == b'[' || bs_bytes[j + 1] == b']') => {}
2408 '[' if j == 0 || bs_bytes[j - 1] != b'\\' => depth += 1,
2409 ']' if j == 0 || bs_bytes[j - 1] != b'\\' => {
2410 depth -= 1;
2411 if depth == 0 {
2412 bracket_close = Some(j);
2413 break;
2414 }
2415 }
2416 _ => {}
2417 }
2418 }
2419 let bracket_close = bracket_close?;
2420 let after_bracket = &rest[bracket_close + 1..];
2422 if !after_bracket.starts_with('{') {
2423 return None;
2424 }
2425
2426 let span_text = &rest[1..bracket_close];
2427 let attrs_start = i + bracket_close + 1;
2428 let (attrs_end, attrs) = parse_attrs(text, attrs_start)?;
2429
2430 let mut marks = Vec::new();
2431 if attrs.has_flag("underline") {
2432 marks.push(AdfMark::underline());
2433 }
2434 let ann_ids = attrs.get_all("annotation-id");
2435 let ann_types = attrs.get_all("annotation-type");
2436 for (idx, ann_id) in ann_ids.iter().enumerate() {
2437 let ann_type = ann_types.get(idx).copied().unwrap_or("inlineComment");
2438 marks.push(AdfMark::annotation(ann_id, ann_type));
2439 }
2440
2441 if marks.is_empty() {
2442 return None; }
2444
2445 let inner = parse_inline_no_auto_cards(span_text);
2446 let result: Vec<AdfNode> = inner
2447 .into_iter()
2448 .map(|mut node| {
2449 let mut combined = marks.clone();
2452 if let Some(ref existing) = node.marks {
2453 combined.extend(existing.iter().cloned());
2454 }
2455 node.marks = Some(combined);
2456 node
2457 })
2458 .collect();
2459
2460 Some((attrs_end, result))
2461}
2462
2463fn try_dispatch_inline_directive(text: &str, pos: usize) -> Option<(AdfNode, usize)> {
2466 let d = try_parse_inline_directive(text, pos)?;
2467 let content = d.content.as_deref().unwrap_or("");
2468
2469 let node = match d.name.as_str() {
2470 "card" => {
2471 let url = d
2476 .attrs
2477 .as_ref()
2478 .and_then(|a| a.get("url"))
2479 .unwrap_or(content);
2480 let mut node = AdfNode::inline_card(url);
2481 pass_through_local_id(&d.attrs, &mut node);
2482 node
2483 }
2484 "status" => {
2485 let color = d
2486 .attrs
2487 .as_ref()
2488 .and_then(|a| a.get("color"))
2489 .unwrap_or("neutral");
2490 let mut node = AdfNode::status(content, color);
2491 if let Some(ref attrs) = d.attrs {
2493 if let Some(ref mut node_attrs) = node.attrs {
2494 if let Some(style) = attrs.get("style") {
2495 node_attrs["style"] = serde_json::Value::String(style.to_string());
2496 }
2497 if let Some(local_id) = attrs.get("localId") {
2498 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2499 }
2500 }
2501 }
2502 node
2503 }
2504 "date" => {
2505 let timestamp = d
2506 .attrs
2507 .as_ref()
2508 .and_then(|a| a.get("timestamp"))
2509 .map_or_else(|| iso_date_to_epoch_ms(content), ToString::to_string);
2510 let mut node = AdfNode::date(×tamp);
2511 pass_through_local_id(&d.attrs, &mut node);
2512 node
2513 }
2514 "mention" => {
2515 let id = d.attrs.as_ref().and_then(|a| a.get("id")).unwrap_or("");
2516 let mut node = AdfNode::mention(id, content);
2517 if let Some(ref attrs) = d.attrs {
2519 if let (Some(ref mut node_attrs), true) = (
2520 &mut node.attrs,
2521 attrs.get("userType").is_some() || attrs.get("accessLevel").is_some(),
2522 ) {
2523 if let Some(ut) = attrs.get("userType") {
2524 node_attrs["userType"] = serde_json::Value::String(ut.to_string());
2525 }
2526 if let Some(al) = attrs.get("accessLevel") {
2527 node_attrs["accessLevel"] = serde_json::Value::String(al.to_string());
2528 }
2529 }
2530 }
2531 pass_through_local_id(&d.attrs, &mut node);
2532 node
2533 }
2534 "span" => {
2535 let mut marks = Vec::new();
2536 if let Some(ref attrs) = d.attrs {
2537 if let Some(color) = attrs.get("color") {
2538 marks.push(AdfMark::text_color(color));
2539 }
2540 if let Some(bg) = attrs.get("bg") {
2541 marks.push(AdfMark::background_color(bg));
2542 }
2543 if attrs.has_flag("sub") {
2544 marks.push(AdfMark::subsup("sub"));
2545 }
2546 if attrs.has_flag("sup") {
2547 marks.push(AdfMark::subsup("sup"));
2548 }
2549 }
2550 if marks.is_empty() {
2551 AdfNode::text(content)
2552 } else {
2553 let inner = parse_inline_no_auto_cards(content);
2556 let mut nodes: Vec<AdfNode> = inner
2557 .into_iter()
2558 .map(|mut node| {
2559 let mut combined = marks.clone();
2560 if let Some(ref existing) = node.marks {
2561 combined.extend(existing.iter().cloned());
2562 }
2563 node.marks = Some(combined);
2564 node
2565 })
2566 .collect();
2567 nodes.remove(0)
2569 }
2570 }
2571 "placeholder" => AdfNode::placeholder(content),
2572 "media-inline" => {
2573 let mut json_attrs = serde_json::Map::new();
2574 if let Some(ref attrs) = d.attrs {
2575 for key in &["type", "id", "collection", "url", "alt", "width", "height"] {
2576 if let Some(val) = attrs.get(key) {
2577 if *key == "width" || *key == "height" {
2578 if let Ok(n) = val.parse::<u64>() {
2579 json_attrs.insert(
2580 (*key).to_string(),
2581 serde_json::Value::Number(n.into()),
2582 );
2583 continue;
2584 }
2585 }
2586 json_attrs.insert(
2587 (*key).to_string(),
2588 serde_json::Value::String(val.to_string()),
2589 );
2590 }
2591 }
2592 if let Some(local_id) = attrs.get("localId") {
2593 json_attrs.insert(
2594 "localId".to_string(),
2595 serde_json::Value::String(local_id.to_string()),
2596 );
2597 }
2598 }
2599 AdfNode::media_inline(serde_json::Value::Object(json_attrs))
2600 }
2601 "extension" => {
2602 let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
2603 let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
2604 AdfNode::inline_extension(ext_type, ext_key, Some(content))
2605 }
2606 _ => return None, };
2608
2609 Some((node, d.end_pos))
2610}
2611
2612fn try_parse_bare_url(text: &str, i: usize) -> Option<(usize, &str)> {
2615 let rest = &text[i..];
2616 if !rest.starts_with("http://") && !rest.starts_with("https://") {
2617 return None;
2618 }
2619 let end = rest
2621 .find(|c: char| c.is_whitespace() || c == ')' || c == ']' || c == '>')
2622 .unwrap_or(rest.len());
2623 let url = rest[..end].trim_end_matches(['.', ',', ';', '!', '?']);
2625 if url.len() <= "https://".len() {
2626 return None; }
2628 Some((i + url.len(), url))
2629}
2630
2631fn try_parse_emoji_shortcode(text: &str, i: usize) -> Option<(usize, &str)> {
2634 let rest = &text[i..];
2635 if !rest.starts_with(':') {
2636 return None;
2637 }
2638 let after = &rest[1..];
2639 let name_end =
2640 after.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')?;
2641 if name_end == 0 {
2642 return None;
2643 }
2644 if after.as_bytes().get(name_end) != Some(&b':') {
2645 return None;
2646 }
2647 let name = &after[..name_end];
2648 Some((i + 1 + name_end + 1, name))
2649}
2650
2651fn parse_emoji_with_attrs(text: &str, shortcode_end: usize, short_name: &str) -> (usize, AdfNode) {
2654 let mut chain_end = shortcode_end;
2659 while let Some((next_end, _)) = try_parse_emoji_shortcode(text, chain_end) {
2660 chain_end = next_end;
2661 }
2662 if chain_end > shortcode_end {
2663 if let Some((attr_end, attrs)) = parse_attrs(text, chain_end) {
2664 return (attr_end, build_emoji_node(&attrs, short_name));
2665 }
2666 }
2667
2668 if let Some((attr_end, attrs)) = parse_attrs(text, shortcode_end) {
2669 (attr_end, build_emoji_node(&attrs, short_name))
2670 } else {
2671 (shortcode_end, AdfNode::emoji(&format!(":{short_name}:")))
2672 }
2673}
2674
2675fn build_emoji_node(attrs: &Attrs, short_name: &str) -> AdfNode {
2678 let resolved_name = attrs
2679 .get("shortName")
2680 .map_or_else(|| format!(":{short_name}:"), str::to_string);
2681 let mut emoji_attrs = serde_json::json!({ "shortName": resolved_name });
2682 if let Some(id) = attrs.get("id") {
2683 emoji_attrs["id"] = serde_json::Value::String(id.to_string());
2684 }
2685 if let Some(t) = attrs.get("text") {
2686 emoji_attrs["text"] = serde_json::Value::String(t.to_string());
2687 }
2688 if let Some(lid) = attrs.get("localId") {
2689 emoji_attrs["localId"] = serde_json::Value::String(lid.to_string());
2690 }
2691 AdfNode {
2692 node_type: "emoji".to_string(),
2693 attrs: Some(emoji_attrs),
2694 content: None,
2695 text: None,
2696 marks: None,
2697 local_id: None,
2698 parameters: None,
2699 }
2700}
2701
2702fn find_closing_paren(s: &str, open: usize) -> Option<usize> {
2707 let mut depth: usize = 0;
2708 for (j, ch) in s[open..].char_indices() {
2709 match ch {
2710 '(' => depth += 1,
2711 ')' => {
2712 depth -= 1;
2713 if depth == 0 {
2714 return Some(open + j);
2715 }
2716 }
2717 _ => {}
2718 }
2719 }
2720 None
2721}
2722
2723fn try_parse_link(text: &str, i: usize) -> Option<(usize, &str, &str)> {
2729 let rest = &text[i..];
2730 if !rest.starts_with('[') {
2731 return None;
2732 }
2733
2734 let mut depth: usize = 0;
2736 let mut text_end = None;
2737 let bytes = rest.as_bytes();
2738 for (j, ch) in rest.char_indices() {
2739 match ch {
2740 '\\' if j + 1 < bytes.len() && (bytes[j + 1] == b'[' || bytes[j + 1] == b']') => {
2741 }
2743 '[' if j == 0 || bytes[j - 1] != b'\\' => depth += 1,
2744 ']' if j == 0 || bytes[j - 1] != b'\\' => {
2745 depth -= 1;
2746 if depth == 0 {
2747 text_end = Some(j);
2748 break;
2749 }
2750 }
2751 _ => {}
2752 }
2753 }
2754
2755 let text_end = text_end?;
2756 let link_text = &rest[1..text_end];
2757 let after_bracket = &rest[text_end + 1..];
2759 if !after_bracket.starts_with('(') {
2760 return None;
2761 }
2762 let url_start = text_end + 1; let url_end = find_closing_paren(rest, url_start)?;
2764 let href = &rest[url_start + 1..url_end];
2765
2766 Some((i + url_end + 1, link_text, href))
2767}
2768
2769#[derive(Debug, Clone, Default)]
2773pub struct RenderOptions {
2774 pub strip_local_ids: bool,
2776}
2777
2778pub fn adf_to_markdown(doc: &AdfDocument) -> Result<String> {
2780 adf_to_markdown_with_options(doc, &RenderOptions::default())
2781}
2782
2783pub fn adf_to_markdown_with_options(doc: &AdfDocument, opts: &RenderOptions) -> Result<String> {
2785 let mut output = String::new();
2786
2787 for (i, node) in doc.content.iter().enumerate() {
2788 if i > 0 {
2789 output.push('\n');
2790 }
2791 render_block_node(node, &mut output, opts);
2792 }
2793
2794 Ok(output)
2795}
2796
2797#[must_use]
2806pub fn adf_to_plain_text(doc: &AdfDocument) -> String {
2807 let mut out = String::new();
2808 for node in &doc.content {
2809 collect_plain_text(node, &mut out);
2810 if !out.is_empty() && !out.ends_with(' ') {
2811 out.push(' ');
2812 }
2813 }
2814 out.truncate(out.trim_end().len());
2815 out
2816}
2817
2818fn collect_plain_text(node: &AdfNode, out: &mut String) {
2819 if let Some(text) = &node.text {
2820 out.push_str(text);
2821 }
2822 if let Some(children) = &node.content {
2823 for child in children {
2824 collect_plain_text(child, out);
2825 }
2826 }
2827}
2828
2829fn pass_through_local_id(dir_attrs: &Option<crate::atlassian::attrs::Attrs>, node: &mut AdfNode) {
2833 if let Some(ref attrs) = dir_attrs {
2834 if let Some(local_id) = attrs.get("localId") {
2835 if let Some(ref mut node_attrs) = node.attrs {
2836 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2837 } else {
2838 node.attrs = Some(serde_json::json!({"localId": local_id}));
2839 }
2840 }
2841 }
2842}
2843
2844fn pass_through_expand_params(
2847 dir_attrs: &Option<crate::atlassian::attrs::Attrs>,
2848 node: &mut AdfNode,
2849) {
2850 if let Some(ref attrs) = dir_attrs {
2851 if let Some(local_id) = attrs.get("localId") {
2852 node.local_id = Some(local_id.to_string());
2853 }
2854 if let Some(params_str) = attrs.get("params") {
2855 if let Ok(params) = serde_json::from_str(params_str) {
2856 node.parameters = Some(params);
2857 }
2858 }
2859 }
2860}
2861
2862fn extract_trailing_local_id(text: &str) -> (&str, Option<String>, Option<String>) {
2872 let trimmed = text.trim_end();
2873 if !trimmed.ends_with('}') {
2874 return (text, None, None);
2875 }
2876 if let Some(brace_pos) = trimmed.rfind('{') {
2881 if brace_pos > 0 && !trimmed.as_bytes()[brace_pos - 1].is_ascii_whitespace() {
2882 return (text, None, None);
2883 }
2884 let attr_str = &trimmed[brace_pos..];
2885 if let Some((_, attrs)) = parse_attrs(attr_str, 0) {
2886 let local_id = attrs.get("localId").map(str::to_string);
2887 let para_local_id = attrs.get("paraLocalId").map(str::to_string);
2888 if local_id.is_some() || para_local_id.is_some() {
2889 let before = trimmed[..brace_pos]
2890 .strip_suffix(' ')
2891 .unwrap_or(&trimmed[..brace_pos]);
2892 return (before, local_id, para_local_id);
2893 }
2894 }
2895 }
2896 (text, None, None)
2897}
2898
2899fn parse_list_item_first_line(
2906 item_text: &str,
2907 sub_lines: Vec<String>,
2908 local_id: Option<String>,
2909 para_local_id: Option<String>,
2910) -> Result<AdfNode> {
2911 if item_text.starts_with("```") {
2912 let mut all_lines = vec![item_text.to_string()];
2914 all_lines.extend(sub_lines);
2915 let combined = all_lines.join("\n");
2916 let nested = MarkdownParser::new(&combined).parse_blocks()?;
2917 Ok(list_item_with_local_id(nested, local_id, para_local_id))
2918 } else if let Some(media) = try_parse_media_single_from_line(item_text) {
2919 if sub_lines.is_empty() {
2921 Ok(list_item_with_local_id(
2922 vec![media],
2923 local_id,
2924 para_local_id,
2925 ))
2926 } else {
2927 let sub_text = sub_lines.join("\n");
2928 let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
2929 let mut content = vec![media];
2930 content.append(&mut nested);
2931 Ok(list_item_with_local_id(content, local_id, para_local_id))
2932 }
2933 } else {
2934 let first_node = AdfNode::paragraph(parse_inline(item_text));
2935 if sub_lines.is_empty() {
2936 Ok(list_item_with_local_id(
2937 vec![first_node],
2938 local_id,
2939 para_local_id,
2940 ))
2941 } else {
2942 let sub_text = sub_lines.join("\n");
2943 let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
2944 let mut content = vec![first_node];
2945 content.append(&mut nested);
2946 Ok(list_item_with_local_id(content, local_id, para_local_id))
2947 }
2948 }
2949}
2950
2951fn list_item_with_local_id(
2952 mut content: Vec<AdfNode>,
2953 local_id: Option<String>,
2954 para_local_id: Option<String>,
2955) -> AdfNode {
2956 if let Some(id) = ¶_local_id {
2957 if let Some(first) = content.first_mut() {
2958 if first.node_type == "paragraph" {
2959 let node_attrs = first.attrs.get_or_insert_with(|| serde_json::json!({}));
2960 node_attrs["localId"] = serde_json::Value::String(id.clone());
2961 }
2962 }
2963 }
2964 let mut item = AdfNode::list_item(content);
2965 if let Some(id) = local_id {
2966 item.attrs = Some(serde_json::json!({"localId": id}));
2967 }
2968 item
2969}
2970
2971fn maybe_push_local_id(attrs: &serde_json::Value, parts: &mut Vec<String>, opts: &RenderOptions) {
2972 if opts.strip_local_ids {
2973 return;
2974 }
2975 if let Some(local_id) = attrs.get("localId").and_then(serde_json::Value::as_str) {
2976 if !local_id.is_empty() && local_id != "00000000-0000-0000-0000-000000000000" {
2977 parts.push(format_kv("localId", local_id));
2978 }
2979 }
2980}
2981
2982fn render_block_children(children: &[AdfNode], output: &mut String, opts: &RenderOptions) {
2984 for (i, child) in children.iter().enumerate() {
2985 if i > 0 {
2986 output.push('\n');
2987 }
2988 render_block_node(child, output, opts);
2989 }
2990}
2991
2992fn fmt_f64_attr(v: f64) -> String {
2995 if v.fract() == 0.0 {
2996 format!("{}", v as i64)
2997 } else {
2998 v.to_string()
2999 }
3000}
3001
3002fn parse_numeric_attr(s: &str) -> Option<serde_json::Value> {
3009 if s.contains('.') || s.contains('e') || s.contains('E') {
3010 s.parse::<f64>().ok().map(serde_json::Value::from)
3011 } else {
3012 s.parse::<i64>().ok().map(serde_json::Value::from)
3013 }
3014}
3015
3016fn fmt_numeric_attr(v: &serde_json::Value) -> Option<String> {
3024 if let Some(n) = v.as_i64() {
3025 return Some(n.to_string());
3026 }
3027 if let Some(n) = v.as_u64() {
3028 return Some(n.to_string());
3029 }
3030 if let Some(n) = v.as_f64() {
3031 if n.fract() == 0.0 && n.is_finite() {
3032 return Some(format!("{n:.1}"));
3033 }
3034 return Some(n.to_string());
3035 }
3036 None
3037}
3038
3039fn render_block_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3041 match node.node_type.as_str() {
3042 "paragraph" => {
3043 let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3044 let dir_attrs = {
3046 let mut parts = Vec::new();
3047 if let Some(ref attrs) = node.attrs {
3048 maybe_push_local_id(attrs, &mut parts, opts);
3049 }
3050 if parts.is_empty() {
3051 String::new()
3052 } else {
3053 format!("{{{}}}", parts.join(" "))
3054 }
3055 };
3056 if is_empty {
3057 output.push_str(&format!("::paragraph{dir_attrs}\n"));
3058 } else {
3059 let mut buf = String::new();
3061 render_inline_content(node, &mut buf, opts);
3062 if buf.trim().is_empty() && !buf.is_empty() {
3063 output.push_str(&format!("::paragraph[{buf}]{dir_attrs}\n"));
3066 } else {
3067 let mut is_first_line = true;
3072 for line in buf.split('\n') {
3073 if is_first_line {
3074 if is_list_start(line) {
3075 output.push_str(&escape_list_marker(line));
3076 } else {
3077 output.push_str(line);
3078 }
3079 is_first_line = false;
3080 } else {
3081 output.push('\n');
3082 if !line.is_empty() {
3083 output.push_str(" ");
3084 }
3085 output.push_str(line);
3086 }
3087 }
3088 output.push('\n');
3089 }
3090 }
3091 }
3092 "heading" => {
3093 let level = node
3094 .attrs
3095 .as_ref()
3096 .and_then(|a| a.get("level"))
3097 .and_then(serde_json::Value::as_u64)
3098 .unwrap_or(1);
3099 for _ in 0..level {
3100 output.push('#');
3101 }
3102 output.push(' ');
3103 let mut buf = String::new();
3104 render_inline_content(node, &mut buf, opts);
3105 let mut is_first_line = true;
3108 for line in buf.split('\n') {
3109 if is_first_line {
3110 output.push_str(line);
3111 is_first_line = false;
3112 } else {
3113 output.push('\n');
3114 if !line.is_empty() {
3115 output.push_str(" ");
3116 }
3117 output.push_str(line);
3118 }
3119 }
3120 output.push('\n');
3121 }
3122 "codeBlock" => {
3123 let language_value = node.attrs.as_ref().and_then(|a| a.get("language"));
3124 let language = language_value
3125 .and_then(serde_json::Value::as_str)
3126 .unwrap_or("");
3127 output.push_str("```");
3128 if language.is_empty() && language_value.is_some() {
3129 output.push_str("\"\"");
3132 } else {
3133 output.push_str(language);
3134 }
3135 output.push('\n');
3136 if let Some(ref content) = node.content {
3137 for child in content {
3138 if let Some(ref text) = child.text {
3139 output.push_str(text);
3140 }
3141 }
3142 }
3143 output.push_str("\n```\n");
3144 }
3145 "blockquote" => {
3146 if let Some(ref content) = node.content {
3147 for (i, child) in content.iter().enumerate() {
3148 if i > 0
3152 && child.node_type == "paragraph"
3153 && content[i - 1].node_type == "paragraph"
3154 {
3155 output.push_str(">\n");
3156 }
3157 let mut inner = String::new();
3158 render_block_node(child, &mut inner, opts);
3159 for line in inner.lines() {
3160 output.push_str("> ");
3161 output.push_str(line);
3162 output.push('\n');
3163 }
3164 }
3165 }
3166 }
3167 "bulletList" => {
3168 if let Some(ref items) = node.content {
3169 for item in items {
3170 output.push_str("- ");
3171 let content_start = output.len();
3172 render_list_item_content(item, output, opts);
3173 if starts_with_task_marker(&output[content_start..]) {
3179 output.insert(content_start, '\\');
3180 }
3181 }
3182 }
3183 }
3184 "orderedList" => {
3185 let start = node
3186 .attrs
3187 .as_ref()
3188 .and_then(|a| a.get("order"))
3189 .and_then(serde_json::Value::as_u64)
3190 .unwrap_or(1);
3191 if let Some(ref items) = node.content {
3192 for (i, item) in items.iter().enumerate() {
3193 let num = start + i as u64;
3194 output.push_str(&format!("{num}. "));
3195 render_list_item_content(item, output, opts);
3196 }
3197 }
3198 }
3199 "taskList" => {
3200 if let Some(ref items) = node.content {
3201 for item in items {
3202 if item.node_type == "taskList" {
3203 let mut nested = String::new();
3207 render_block_node(item, &mut nested, opts);
3208 for line in nested.lines() {
3209 output.push_str(" ");
3210 output.push_str(line);
3211 output.push('\n');
3212 }
3213 } else {
3214 let state = item
3215 .attrs
3216 .as_ref()
3217 .and_then(|a| a.get("state"))
3218 .and_then(serde_json::Value::as_str)
3219 .unwrap_or("TODO");
3220 if state == "DONE" {
3221 output.push_str("- [x] ");
3222 } else {
3223 output.push_str("- [ ] ");
3224 }
3225 render_list_item_content(item, output, opts);
3226 }
3227 }
3228 }
3229 }
3230 "rule" => {
3231 output.push_str("---\n");
3232 }
3233 "table" => {
3234 render_table(node, output, opts);
3235 }
3236 "mediaSingle" => {
3237 if let Some(ref content) = node.content {
3238 for child in content {
3239 if child.node_type == "media" {
3240 render_media(child, node.attrs.as_ref(), output, opts);
3241 }
3242 }
3243 for child in content {
3244 if child.node_type == "caption" {
3245 let mut cap_parts = Vec::new();
3246 if let Some(ref attrs) = child.attrs {
3247 maybe_push_local_id(attrs, &mut cap_parts, opts);
3248 }
3249 if cap_parts.is_empty() {
3250 output.push_str(":::caption\n");
3251 } else {
3252 output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3253 }
3254 if let Some(ref caption_content) = child.content {
3255 for inline in caption_content {
3256 render_inline_node(inline, output, opts);
3257 }
3258 output.push('\n');
3259 }
3260 output.push_str(":::\n");
3261 }
3262 }
3263 }
3264 }
3265 "blockCard" => {
3266 if let Some(ref attrs) = node.attrs {
3267 let url = attrs
3268 .get("url")
3269 .and_then(serde_json::Value::as_str)
3270 .unwrap_or("");
3271 let mut attr_parts = Vec::new();
3272 if url_safe_in_bracket_content(url) {
3273 output.push_str(&format!("::card[{url}]"));
3274 } else {
3275 output.push_str("::card[]");
3277 let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
3278 attr_parts.push(format!("url=\"{escaped}\""));
3279 }
3280 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3281 attr_parts.push(format!("layout={layout}"));
3282 }
3283 if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
3284 attr_parts.push(format!("width={width}"));
3285 }
3286 if !attr_parts.is_empty() {
3287 output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3288 }
3289 output.push('\n');
3290 }
3291 }
3292 "embedCard" => {
3293 if let Some(ref attrs) = node.attrs {
3294 let url = attrs
3295 .get("url")
3296 .and_then(serde_json::Value::as_str)
3297 .unwrap_or("");
3298 output.push_str(&format!("::embed[{url}]"));
3299 let mut attr_parts = Vec::new();
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(h) = attrs
3304 .get("originalHeight")
3305 .and_then(serde_json::Value::as_f64)
3306 {
3307 attr_parts.push(format!("originalHeight={}", fmt_f64_attr(h)));
3308 }
3309 if let Some(w) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3310 attr_parts.push(format!("width={}", fmt_f64_attr(w)));
3311 }
3312 if !attr_parts.is_empty() {
3313 output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3314 }
3315 output.push('\n');
3316 }
3317 }
3318 "extension" => {
3319 if let Some(ref attrs) = node.attrs {
3320 let ext_type = attrs
3321 .get("extensionType")
3322 .and_then(serde_json::Value::as_str)
3323 .unwrap_or("");
3324 let ext_key = attrs
3325 .get("extensionKey")
3326 .and_then(serde_json::Value::as_str)
3327 .unwrap_or("");
3328 let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3329 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3330 attr_parts.push(format!("layout={layout}"));
3331 }
3332 if let Some(params) = attrs.get("parameters") {
3333 if let Ok(json_str) = serde_json::to_string(params) {
3334 attr_parts.push(format!("params='{json_str}'"));
3335 }
3336 }
3337 maybe_push_local_id(attrs, &mut attr_parts, opts);
3338 output.push_str(&format!("::extension{{{}}}\n", attr_parts.join(" ")));
3339 }
3340 }
3341 "panel" => {
3342 let panel_type = node
3343 .attrs
3344 .as_ref()
3345 .and_then(|a| a.get("panelType"))
3346 .and_then(serde_json::Value::as_str)
3347 .unwrap_or("info");
3348 let mut attr_parts = vec![format!("type={panel_type}")];
3349 if let Some(ref attrs) = node.attrs {
3350 if let Some(icon) = attrs.get("panelIcon").and_then(serde_json::Value::as_str) {
3351 attr_parts.push(format!("icon=\"{icon}\""));
3352 }
3353 if let Some(color) = attrs.get("panelColor").and_then(serde_json::Value::as_str) {
3354 attr_parts.push(format!("color=\"{color}\""));
3355 }
3356 }
3357 output.push_str(&format!(":::panel{{{}}}\n", attr_parts.join(" ")));
3358 if let Some(ref content) = node.content {
3359 render_block_children(content, output, opts);
3360 }
3361 output.push_str(":::\n");
3362 }
3363 "expand" | "nestedExpand" => {
3364 let directive_name = if node.node_type == "nestedExpand" {
3365 "nested-expand"
3366 } else {
3367 "expand"
3368 };
3369 let mut attr_parts = Vec::new();
3370 if let Some(t) = node
3371 .attrs
3372 .as_ref()
3373 .and_then(|a| a.get("title"))
3374 .and_then(serde_json::Value::as_str)
3375 {
3376 attr_parts.push(format!("title=\"{t}\""));
3377 }
3378 if let Some(ref lid) = node.local_id {
3380 if !opts.strip_local_ids && lid != "00000000-0000-0000-0000-000000000000" {
3381 attr_parts.push(format!("localId={lid}"));
3382 }
3383 } else if let Some(ref attrs) = node.attrs {
3384 maybe_push_local_id(attrs, &mut attr_parts, opts);
3385 }
3386 if let Some(ref params) = node.parameters {
3388 if let Ok(json_str) = serde_json::to_string(params) {
3389 attr_parts.push(format!("params='{json_str}'"));
3390 }
3391 }
3392 if attr_parts.is_empty() {
3393 output.push_str(&format!(":::{directive_name}\n"));
3394 } else {
3395 output.push_str(&format!(
3396 ":::{directive_name}{{{}}}\n",
3397 attr_parts.join(" ")
3398 ));
3399 }
3400 if let Some(ref content) = node.content {
3401 render_block_children(content, output, opts);
3402 }
3403 output.push_str(":::\n");
3404 }
3405 "layoutSection" => {
3406 output.push_str("::::layout\n");
3407 if let Some(ref content) = node.content {
3408 for child in content {
3409 if child.node_type == "layoutColumn" {
3410 let width_str = child
3411 .attrs
3412 .as_ref()
3413 .and_then(|a| a.get("width"))
3414 .and_then(fmt_numeric_attr)
3415 .unwrap_or_else(|| "50".to_string());
3416 let mut parts = vec![format!("width={width_str}")];
3417 if let Some(ref attrs) = child.attrs {
3418 maybe_push_local_id(attrs, &mut parts, opts);
3419 }
3420 output.push_str(&format!(":::column{{{}}}\n", parts.join(" ")));
3421 if let Some(ref col_content) = child.content {
3422 render_block_children(col_content, output, opts);
3423 }
3424 output.push_str(":::\n");
3425 }
3426 }
3427 }
3428 output.push_str("::::\n");
3429 }
3430 "decisionList" => {
3431 output.push_str(":::decisions\n");
3432 if let Some(ref content) = node.content {
3433 for item in content {
3434 output.push_str("- <> ");
3435 render_list_item_content(item, output, opts);
3436 }
3437 }
3438 output.push_str(":::\n");
3439 }
3440 "bodiedExtension" => {
3441 if let Some(ref attrs) = node.attrs {
3442 let ext_type = attrs
3443 .get("extensionType")
3444 .and_then(serde_json::Value::as_str)
3445 .unwrap_or("");
3446 let ext_key = attrs
3447 .get("extensionKey")
3448 .and_then(serde_json::Value::as_str)
3449 .unwrap_or("");
3450 let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3451 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3452 attr_parts.push(format!("layout={layout}"));
3453 }
3454 if let Some(params) = attrs.get("parameters") {
3455 if let Ok(json_str) = serde_json::to_string(params) {
3456 attr_parts.push(format!("params='{json_str}'"));
3457 }
3458 }
3459 maybe_push_local_id(attrs, &mut attr_parts, opts);
3460 output.push_str(&format!(":::extension{{{}}}\n", attr_parts.join(" ")));
3461 if let Some(ref content) = node.content {
3462 render_block_children(content, output, opts);
3463 }
3464 output.push_str(":::\n");
3465 }
3466 }
3467 _ => {
3468 if let Ok(json) = serde_json::to_string_pretty(node) {
3470 output.push_str("```adf-unsupported\n");
3471 output.push_str(&json);
3472 output.push_str("\n```\n");
3473 }
3474 }
3475 }
3476
3477 let mut parts = Vec::new();
3479 if let Some(ref marks) = node.marks {
3480 for mark in marks {
3481 match mark.mark_type.as_str() {
3482 "alignment" => {
3483 if let Some(align) = mark
3484 .attrs
3485 .as_ref()
3486 .and_then(|a| a.get("align"))
3487 .and_then(serde_json::Value::as_str)
3488 {
3489 parts.push(format!("align={align}"));
3490 }
3491 }
3492 "indentation" => {
3493 if let Some(level) = mark
3494 .attrs
3495 .as_ref()
3496 .and_then(|a| a.get("level"))
3497 .and_then(serde_json::Value::as_u64)
3498 {
3499 parts.push(format!("indent={level}"));
3500 }
3501 }
3502 "breakout" => {
3503 if let Some(mode) = mark
3504 .attrs
3505 .as_ref()
3506 .and_then(|a| a.get("mode"))
3507 .and_then(serde_json::Value::as_str)
3508 {
3509 parts.push(format!("breakout={mode}"));
3510 }
3511 if let Some(width) = mark
3512 .attrs
3513 .as_ref()
3514 .and_then(|a| a.get("width"))
3515 .and_then(serde_json::Value::as_u64)
3516 {
3517 parts.push(format!("breakoutWidth={width}"));
3518 }
3519 }
3520 _ => {}
3521 }
3522 }
3523 }
3524 let para_used_directive = node.node_type == "paragraph" && {
3528 let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3529 if is_empty {
3530 true
3531 } else {
3532 let mut buf = String::new();
3533 render_inline_content(node, &mut buf, opts);
3534 buf.trim().is_empty() && !buf.is_empty()
3535 }
3536 };
3537 if !matches!(node.node_type.as_str(), "expand" | "nestedExpand") && !para_used_directive {
3538 if let Some(ref attrs) = node.attrs {
3539 maybe_push_local_id(attrs, &mut parts, opts);
3540 }
3541 }
3542 if node.node_type == "orderedList" {
3547 if let Some(ref attrs) = node.attrs {
3548 if attrs.get("order").and_then(serde_json::Value::as_u64) == Some(1) {
3549 parts.push("order=1".to_string());
3550 }
3551 }
3552 }
3553 if !parts.is_empty() {
3554 output.push_str(&format!("{{{}}}\n", parts.join(" ")));
3555 }
3556}
3557
3558fn render_list_item_content(item: &AdfNode, output: &mut String, opts: &RenderOptions) {
3566 let Some(ref content) = item.content else {
3567 let bare = AdfNode::text("");
3569 emit_list_item_local_ids(item, &bare, output, opts);
3570 output.push('\n');
3571 return;
3572 };
3573 if content.is_empty() {
3574 let bare = AdfNode::text("");
3575 emit_list_item_local_ids(item, &bare, output, opts);
3576 output.push('\n');
3577 return;
3578 }
3579 let first = &content[0];
3580 let rest_start;
3581 if first.node_type == "paragraph" {
3582 let mut buf = String::new();
3583 render_inline_content(first, &mut buf, opts);
3584 let buf = buf.trim_end_matches('\n');
3590 let mut is_first_line = true;
3593 for line in buf.split('\n') {
3594 if is_first_line {
3595 output.push_str(line);
3596 is_first_line = false;
3597 } else {
3598 output.push('\n');
3599 if !line.is_empty() {
3600 output.push_str(" ");
3601 }
3602 output.push_str(line);
3603 }
3604 }
3605 emit_list_item_local_ids(item, first, output, opts);
3607 output.push('\n');
3608 rest_start = 1;
3609 } else if is_inline_node_type(&first.node_type) {
3610 rest_start = content
3612 .iter()
3613 .position(|c| !is_inline_node_type(&c.node_type))
3614 .unwrap_or(content.len());
3615 let mut buf = String::new();
3616 for child in &content[..rest_start] {
3617 render_inline_node(child, &mut buf, opts);
3618 }
3619 let buf = buf.trim_end_matches('\n');
3622 let mut is_first_line = true;
3623 for line in buf.split('\n') {
3624 if is_first_line {
3625 output.push_str(line);
3626 is_first_line = false;
3627 } else {
3628 output.push('\n');
3629 if !line.is_empty() {
3630 output.push_str(" ");
3631 }
3632 output.push_str(line);
3633 }
3634 }
3635 let bare = AdfNode::text("");
3637 emit_list_item_local_ids(item, &bare, output, opts);
3638 output.push('\n');
3639 } else if first.node_type == "taskItem" {
3642 let bare = AdfNode::text("");
3647 emit_list_item_local_ids(item, &bare, output, opts);
3648 output.push('\n');
3649 for child in content {
3650 if child.node_type == "taskItem" {
3651 let state = child
3652 .attrs
3653 .as_ref()
3654 .and_then(|a| a.get("state"))
3655 .and_then(serde_json::Value::as_str)
3656 .unwrap_or("TODO");
3657 let marker = if state == "DONE" { "- [x] " } else { "- [ ] " };
3658 output.push_str(" ");
3659 output.push_str(marker);
3660 render_list_item_content(child, output, opts);
3661 } else {
3662 let mut nested = String::new();
3663 render_block_node(child, &mut nested, opts);
3664 for line in nested.lines() {
3665 output.push_str(" ");
3666 output.push_str(line);
3667 output.push('\n');
3668 }
3669 }
3670 }
3671 return;
3672 } else {
3673 let mut buf = String::new();
3679 render_block_node(first, &mut buf, opts);
3680 let bare = AdfNode::text("");
3681 let mut is_first = true;
3682 for line in buf.lines() {
3683 if is_first {
3684 output.push_str(line);
3685 emit_list_item_local_ids(item, &bare, output, opts);
3686 output.push('\n');
3687 is_first = false;
3688 } else {
3689 output.push_str(" ");
3690 output.push_str(line);
3691 output.push('\n');
3692 }
3693 }
3694 rest_start = 1;
3695 }
3696 let rest = &content[rest_start..];
3697 for (i, child) in rest.iter().enumerate() {
3698 if child.node_type == "paragraph" {
3702 let prev_is_para = if i == 0 {
3703 first.node_type == "paragraph"
3706 } else {
3707 rest[i - 1].node_type == "paragraph"
3708 };
3709 if prev_is_para {
3710 output.push_str(" \n");
3711 }
3712 }
3713 let mut nested = String::new();
3714 render_block_node(child, &mut nested, opts);
3715 for line in nested.lines() {
3716 output.push_str(" ");
3717 output.push_str(line);
3718 output.push('\n');
3719 }
3720 }
3721}
3722
3723fn is_inline_node_type(node_type: &str) -> bool {
3725 matches!(
3726 node_type,
3727 "text"
3728 | "hardBreak"
3729 | "inlineCard"
3730 | "emoji"
3731 | "mention"
3732 | "status"
3733 | "date"
3734 | "placeholder"
3735 | "mediaInline"
3736 )
3737}
3738
3739fn emit_list_item_local_ids(
3742 item: &AdfNode,
3743 paragraph: &AdfNode,
3744 output: &mut String,
3745 opts: &RenderOptions,
3746) {
3747 if opts.strip_local_ids {
3748 return;
3749 }
3750 let mut parts = Vec::new();
3751 if let Some(ref attrs) = item.attrs {
3752 maybe_push_local_id(attrs, &mut parts, opts);
3753 }
3754 if paragraph.node_type == "paragraph" {
3755 let has_real_id = paragraph
3756 .attrs
3757 .as_ref()
3758 .and_then(|a| a.get("localId"))
3759 .and_then(serde_json::Value::as_str)
3760 .filter(|id| !id.is_empty() && *id != "00000000-0000-0000-0000-000000000000");
3761 if let Some(local_id) = has_real_id {
3762 parts.push(format!("paraLocalId={local_id}"));
3763 } else if item.node_type == "taskItem" {
3764 parts.push("paraLocalId=_".to_string());
3768 }
3769 }
3770 if !parts.is_empty() {
3771 output.push_str(&format!(" {{{}}}", parts.join(" ")));
3772 }
3773}
3774
3775fn render_table(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3777 let Some(ref rows) = node.content else {
3778 return;
3779 };
3780
3781 if table_qualifies_for_pipe_syntax(rows) {
3782 render_pipe_table(node, rows, output, opts);
3783 } else {
3784 render_directive_table(node, rows, output, opts);
3785 }
3786}
3787
3788fn table_qualifies_for_pipe_syntax(rows: &[AdfNode]) -> bool {
3795 if rows.iter().any(|n| n.node_type == "caption") {
3797 return false;
3798 }
3799 let mut first_row_has_header = false;
3800 for (row_idx, row) in rows.iter().enumerate() {
3801 let Some(ref cells) = row.content else {
3802 continue;
3803 };
3804 for cell in cells {
3805 if row_idx > 0 && cell.node_type == "tableHeader" {
3807 return false;
3808 }
3809 if row_idx == 0 && cell.node_type == "tableHeader" {
3810 first_row_has_header = true;
3811 }
3812 let Some(ref content) = cell.content else {
3814 continue;
3815 };
3816 if content.len() != 1 || content[0].node_type != "paragraph" {
3817 return false;
3818 }
3819 if cell_contains_hard_break(&content[0]) {
3822 return false;
3823 }
3824 if cell.marks.as_ref().is_some_and(|m| !m.is_empty()) {
3827 return false;
3828 }
3829 if content[0]
3832 .attrs
3833 .as_ref()
3834 .and_then(|a| a.get("localId"))
3835 .is_some()
3836 {
3837 return false;
3838 }
3839 }
3840 }
3841 first_row_has_header
3844}
3845
3846fn cell_contains_hard_break(paragraph: &AdfNode) -> bool {
3848 paragraph
3849 .content
3850 .as_ref()
3851 .is_some_and(|nodes| nodes.iter().any(|n| n.node_type == "hardBreak"))
3852}
3853
3854fn render_pipe_table(node: &AdfNode, rows: &[AdfNode], output: &mut String, opts: &RenderOptions) {
3856 for (row_idx, row) in rows.iter().enumerate() {
3857 let Some(ref cells) = row.content else {
3858 continue;
3859 };
3860
3861 output.push('|');
3862 for cell in cells {
3863 output.push(' ');
3864 let mut cell_buf = String::new();
3865 render_cell_attrs_prefix(cell, &mut cell_buf);
3866 render_inline_content_from_first_paragraph(cell, &mut cell_buf, opts);
3867 output.push_str(&escape_pipes_in_cell(&cell_buf));
3868 output.push_str(" |");
3869 }
3870 output.push('\n');
3871
3872 if row_idx == 0 {
3874 output.push('|');
3875 for cell in cells {
3876 let align = get_cell_paragraph_alignment(cell);
3877 match align {
3878 Some("center") => output.push_str(" :---: |"),
3879 Some("end") => output.push_str(" ---: |"),
3880 _ => output.push_str(" --- |"),
3881 }
3882 }
3883 output.push('\n');
3884 }
3885 }
3886
3887 render_table_level_attrs(node, output, opts);
3889}
3890
3891fn render_directive_table(
3893 node: &AdfNode,
3894 rows: &[AdfNode],
3895 output: &mut String,
3896 opts: &RenderOptions,
3897) {
3898 let mut attr_parts = Vec::new();
3900 if let Some(ref attrs) = node.attrs {
3901 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3902 attr_parts.push(format!("layout={layout}"));
3903 }
3904 if let Some(numbered) = attrs
3905 .get("isNumberColumnEnabled")
3906 .and_then(serde_json::Value::as_bool)
3907 {
3908 if numbered {
3909 attr_parts.push("numbered".to_string());
3910 } else {
3911 attr_parts.push("numbered=false".to_string());
3912 }
3913 }
3914 if let Some(tw) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3915 let tw_str = if tw.fract() == 0.0 {
3916 (tw as u64).to_string()
3917 } else {
3918 tw.to_string()
3919 };
3920 attr_parts.push(format!("width={tw_str}"));
3921 }
3922 maybe_push_local_id(attrs, &mut attr_parts, opts);
3923 }
3924 if attr_parts.is_empty() {
3925 output.push_str("::::table\n");
3926 } else {
3927 output.push_str(&format!("::::table{{{}}}\n", attr_parts.join(" ")));
3928 }
3929
3930 for row in rows {
3931 if row.node_type == "caption" {
3932 let mut cap_parts = Vec::new();
3933 if let Some(ref attrs) = row.attrs {
3934 maybe_push_local_id(attrs, &mut cap_parts, opts);
3935 }
3936 if cap_parts.is_empty() {
3937 output.push_str(":::caption\n");
3938 } else {
3939 output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3940 }
3941 if let Some(ref content) = row.content {
3942 for child in content {
3943 render_inline_node(child, output, opts);
3944 }
3945 output.push('\n');
3946 }
3947 output.push_str(":::\n");
3948 continue;
3949 }
3950 let Some(ref cells) = row.content else {
3951 continue;
3952 };
3953 let mut tr_attrs = Vec::new();
3955 if let Some(ref attrs) = row.attrs {
3956 maybe_push_local_id(attrs, &mut tr_attrs, opts);
3957 }
3958 if tr_attrs.is_empty() {
3959 output.push_str(":::tr\n");
3960 } else {
3961 output.push_str(&format!(":::tr{{{}}}\n", tr_attrs.join(" ")));
3962 }
3963 for cell in cells {
3964 let directive_name = if cell.node_type == "tableHeader" {
3965 "th"
3966 } else {
3967 "td"
3968 };
3969 let mut cell_attr_str = build_cell_attrs_string(cell);
3970 if let Some(ref attrs) = cell.attrs {
3972 let mut lid_parts = Vec::new();
3973 maybe_push_local_id(attrs, &mut lid_parts, opts);
3974 if !lid_parts.is_empty() {
3975 if !cell_attr_str.is_empty() {
3976 cell_attr_str.push(' ');
3977 }
3978 cell_attr_str.push_str(&lid_parts.join(" "));
3979 }
3980 }
3981 if let Some(ref marks) = cell.marks {
3983 for mark in marks {
3984 if mark.mark_type == "border" {
3985 if let Some(ref attrs) = mark.attrs {
3986 if let Some(color) =
3987 attrs.get("color").and_then(serde_json::Value::as_str)
3988 {
3989 if !cell_attr_str.is_empty() {
3990 cell_attr_str.push(' ');
3991 }
3992 cell_attr_str.push_str(&format!("border-color={color}"));
3993 }
3994 if let Some(size) =
3995 attrs.get("size").and_then(serde_json::Value::as_u64)
3996 {
3997 if !cell_attr_str.is_empty() {
3998 cell_attr_str.push(' ');
3999 }
4000 cell_attr_str.push_str(&format!("border-size={size}"));
4001 }
4002 }
4003 }
4004 }
4005 }
4006 let has_marks = cell.marks.as_ref().is_some_and(|m| !m.is_empty());
4007 if cell_attr_str.is_empty() && cell.attrs.is_none() && !has_marks {
4008 output.push_str(&format!(":::{directive_name}\n"));
4009 } else {
4010 output.push_str(&format!(":::{directive_name}{{{cell_attr_str}}}\n"));
4011 }
4012 if let Some(ref content) = cell.content {
4013 render_block_children(content, output, opts);
4014 }
4015 output.push_str(":::\n");
4016 }
4017 output.push_str(":::\n");
4018 }
4019
4020 output.push_str("::::\n");
4021}
4022
4023fn needs_attr_quoting(value: &str) -> bool {
4027 value.contains(|c: char| c.is_whitespace() || c == '}' || c == '(' || c == ')' || c == ',')
4028}
4029
4030fn build_cell_attrs_string(cell: &AdfNode) -> String {
4032 let Some(ref attrs) = cell.attrs else {
4033 return String::new();
4034 };
4035 let mut parts = Vec::new();
4036 if let Some(colspan) = attrs.get("colspan").and_then(serde_json::Value::as_u64) {
4037 parts.push(format!("colspan={colspan}"));
4038 }
4039 if let Some(rowspan) = attrs.get("rowspan").and_then(serde_json::Value::as_u64) {
4040 parts.push(format!("rowspan={rowspan}"));
4041 }
4042 if let Some(bg) = attrs.get("background").and_then(serde_json::Value::as_str) {
4043 if needs_attr_quoting(bg) {
4044 let escaped = bg.replace('\\', "\\\\").replace('"', "\\\"");
4045 parts.push(format!("bg=\"{escaped}\""));
4046 } else {
4047 parts.push(format!("bg={bg}"));
4048 }
4049 }
4050 if let Some(colwidth) = attrs.get("colwidth").and_then(serde_json::Value::as_array) {
4051 let widths: Vec<String> = colwidth
4052 .iter()
4053 .filter_map(|v| {
4054 if let Some(n) = v.as_u64() {
4057 Some(n.to_string())
4058 } else if let Some(n) = v.as_f64() {
4059 if n.fract() == 0.0 {
4060 format!("{n:.1}")
4061 } else {
4062 n.to_string()
4063 }
4064 .into()
4065 } else {
4066 None
4067 }
4068 })
4069 .collect();
4070 if !widths.is_empty() {
4071 parts.push(format!("colwidth={}", widths.join(",")));
4072 }
4073 }
4074 parts.join(" ")
4075}
4076
4077fn render_cell_attrs_prefix(cell: &AdfNode, output: &mut String) {
4079 let Some(ref _attrs) = cell.attrs else {
4080 return;
4081 };
4082 let attr_str = build_cell_attrs_string(cell);
4083 if attr_str.is_empty() {
4084 output.push_str("{} ");
4085 } else {
4086 output.push_str(&format!("{{{attr_str}}} "));
4087 }
4088}
4089
4090fn get_cell_paragraph_alignment(cell: &AdfNode) -> Option<&str> {
4092 let content = cell.content.as_ref()?;
4093 let para = content.first()?;
4094 let marks = para.marks.as_ref()?;
4095 marks.iter().find_map(|m| {
4096 if m.mark_type == "alignment" {
4097 m.attrs
4098 .as_ref()
4099 .and_then(|a| a.get("align"))
4100 .and_then(serde_json::Value::as_str)
4101 } else {
4102 None
4103 }
4104 })
4105}
4106
4107fn render_table_level_attrs(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4109 if let Some(ref attrs) = node.attrs {
4110 let mut parts = Vec::new();
4111 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
4112 parts.push(format!("layout={layout}"));
4113 }
4114 if let Some(numbered) = attrs
4115 .get("isNumberColumnEnabled")
4116 .and_then(serde_json::Value::as_bool)
4117 {
4118 if numbered {
4119 parts.push("numbered".to_string());
4120 } else {
4121 parts.push("numbered=false".to_string());
4122 }
4123 }
4124 if let Some(tw_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4125 parts.push(format!("width={tw_str}"));
4126 }
4127 maybe_push_local_id(attrs, &mut parts, opts);
4128 if !parts.is_empty() {
4129 output.push_str(&format!("{{{}}}\n", parts.join(" ")));
4130 }
4131 }
4132}
4133
4134fn render_inline_content_from_first_paragraph(
4136 cell: &AdfNode,
4137 output: &mut String,
4138 opts: &RenderOptions,
4139) {
4140 if let Some(ref content) = cell.content {
4141 if let Some(first) = content.first() {
4142 if first.node_type == "paragraph" {
4143 render_inline_content(first, output, opts);
4144 }
4145 }
4146 }
4147}
4148
4149fn push_border_mark_attrs(marks: &Option<Vec<AdfMark>>, parts: &mut Vec<String>) {
4151 if let Some(ref marks) = marks {
4152 for mark in marks {
4153 if mark.mark_type == "border" {
4154 if let Some(ref attrs) = mark.attrs {
4155 if let Some(color) = attrs.get("color").and_then(serde_json::Value::as_str) {
4156 parts.push(format!("border-color={color}"));
4157 }
4158 if let Some(size) = attrs.get("size").and_then(serde_json::Value::as_u64) {
4159 parts.push(format!("border-size={size}"));
4160 }
4161 }
4162 }
4163 }
4164 }
4165}
4166
4167fn render_media(
4169 node: &AdfNode,
4170 parent_attrs: Option<&serde_json::Value>,
4171 output: &mut String,
4172 opts: &RenderOptions,
4173) {
4174 if let Some(ref attrs) = node.attrs {
4175 let media_type = attrs
4176 .get("type")
4177 .and_then(serde_json::Value::as_str)
4178 .unwrap_or("external");
4179 let alt = attrs
4180 .get("alt")
4181 .and_then(serde_json::Value::as_str)
4182 .unwrap_or("");
4183
4184 if media_type == "file" {
4185 output.push_str(&format!("![{alt}]()"));
4187 let mut parts = vec!["type=file".to_string()];
4188 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4189 parts.push(format_kv("id", id));
4190 }
4191 if let Some(collection) = attrs.get("collection").and_then(serde_json::Value::as_str) {
4192 parts.push(format_kv("collection", collection));
4193 }
4194 if let Some(occurrence_key) = attrs
4195 .get("occurrenceKey")
4196 .and_then(serde_json::Value::as_str)
4197 {
4198 parts.push(format_kv("occurrenceKey", occurrence_key));
4199 }
4200 if let Some(height_str) = attrs.get("height").and_then(fmt_numeric_attr) {
4201 parts.push(format!("height={height_str}"));
4202 }
4203 if let Some(width_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4204 parts.push(format!("width={width_str}"));
4205 }
4206 maybe_push_local_id(attrs, &mut parts, opts);
4207 if let Some(p_attrs) = parent_attrs {
4209 if let Some(layout) = p_attrs.get("layout").and_then(serde_json::Value::as_str) {
4210 if layout != "center" {
4211 parts.push(format!("layout={layout}"));
4212 }
4213 }
4214 if let Some(ms_width_str) = p_attrs.get("width").and_then(fmt_numeric_attr) {
4215 parts.push(format!("mediaWidth={ms_width_str}"));
4216 }
4217 if let Some(wt) = p_attrs.get("widthType").and_then(serde_json::Value::as_str) {
4218 parts.push(format!("widthType={wt}"));
4219 }
4220 if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4221 parts.push(format!("mode={mode}"));
4222 }
4223 }
4224 push_border_mark_attrs(&node.marks, &mut parts);
4225 output.push_str(&format!("{{{}}}", parts.join(" ")));
4226 } else {
4227 let url = attrs
4229 .get("url")
4230 .and_then(serde_json::Value::as_str)
4231 .unwrap_or("");
4232 output.push_str(&format!(""));
4233
4234 {
4236 let mut parts = Vec::new();
4237 if let Some(p_attrs) = parent_attrs {
4238 let layout = p_attrs.get("layout").and_then(serde_json::Value::as_str);
4239 let width_str = p_attrs.get("width").and_then(fmt_numeric_attr);
4240 let width_type = p_attrs.get("widthType").and_then(serde_json::Value::as_str);
4241 if let Some(l) = layout {
4242 if l != "center" {
4243 parts.push(format!("layout={l}"));
4244 }
4245 }
4246 if let Some(w) = width_str {
4247 parts.push(format!("width={w}"));
4248 }
4249 if let Some(wt) = width_type {
4250 parts.push(format!("widthType={wt}"));
4251 }
4252 if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4253 parts.push(format!("mode={mode}"));
4254 }
4255 }
4256 maybe_push_local_id(attrs, &mut parts, opts);
4257 push_border_mark_attrs(&node.marks, &mut parts);
4258 if !parts.is_empty() {
4259 output.push_str(&format!("{{{}}}", parts.join(" ")));
4260 }
4261 }
4262 }
4263
4264 output.push('\n');
4265 }
4266}
4267
4268fn render_inline_content(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4270 if let Some(ref content) = node.content {
4271 for child in content {
4272 render_inline_node(child, output, opts);
4273 }
4274 }
4275}
4276
4277fn render_inline_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4279 match node.node_type.as_str() {
4280 "text" => {
4281 let text = node.text.as_deref().unwrap_or("");
4282 let marks = node.marks.as_deref().unwrap_or(&[]);
4283 let has_code = marks.iter().any(|m| m.mark_type == "code");
4284 let owned;
4289 let text = if !has_code {
4290 owned = text.replace('\\', "\\\\");
4291 owned.as_str()
4292 } else {
4293 text
4294 };
4295 let owned_nl;
4300 let text = if text.contains('\n') {
4301 owned_nl = text.replace('\n', "\\n");
4302 owned_nl.as_str()
4303 } else {
4304 text
4305 };
4306 let owned_ts;
4312 let text = if !has_code && text.ends_with(" ") {
4313 let mut s = text.to_string();
4314 s.insert(s.len() - 1, '\\');
4316 owned_ts = s;
4317 owned_ts.as_str()
4318 } else {
4319 text
4320 };
4321 render_marked_text(text, marks, output);
4322 }
4323 "hardBreak" => {
4324 output.push_str("\\\n");
4325 }
4326 other => {
4327 let mut body = String::new();
4331 render_non_text_inline_body(other, node, &mut body, opts);
4332
4333 let annotations: Vec<&AdfMark> = node
4334 .marks
4335 .as_deref()
4336 .unwrap_or(&[])
4337 .iter()
4338 .filter(|m| m.mark_type == "annotation")
4339 .collect();
4340
4341 if annotations.is_empty() {
4342 output.push_str(&body);
4343 } else {
4344 let mut attr_parts = Vec::new();
4345 for ann in &annotations {
4346 if let Some(ref attrs) = ann.attrs {
4347 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4348 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4349 attr_parts.push(format!("annotation-id=\"{escaped}\""));
4350 }
4351 if let Some(at) = attrs
4352 .get("annotationType")
4353 .and_then(serde_json::Value::as_str)
4354 {
4355 attr_parts.push(format!("annotation-type={at}"));
4356 }
4357 }
4358 }
4359 output.push('[');
4360 output.push_str(&body);
4361 output.push_str("]{");
4362 output.push_str(&attr_parts.join(" "));
4363 output.push('}');
4364 }
4365 }
4366 }
4367}
4368
4369fn render_non_text_inline_body(
4371 node_type: &str,
4372 node: &AdfNode,
4373 output: &mut String,
4374 opts: &RenderOptions,
4375) {
4376 match node_type {
4377 "inlineCard" => {
4378 if let Some(ref attrs) = node.attrs {
4379 if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4380 let mut attr_parts = Vec::new();
4381 if url_safe_in_bracket_content(url) {
4382 output.push_str(":card[");
4383 output.push_str(url);
4384 output.push(']');
4385 } else {
4386 output.push_str(":card[]");
4390 let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
4391 attr_parts.push(format!("url=\"{escaped}\""));
4392 }
4393 maybe_push_local_id(attrs, &mut attr_parts, opts);
4394 if !attr_parts.is_empty() {
4395 output.push('{');
4396 output.push_str(&attr_parts.join(" "));
4397 output.push('}');
4398 }
4399 }
4400 }
4401 }
4402 "emoji" => {
4403 if let Some(ref attrs) = node.attrs {
4404 if let Some(short_name) = attrs.get("shortName").and_then(serde_json::Value::as_str)
4405 {
4406 output.push(':');
4407 let name = short_name.strip_prefix(':').unwrap_or(short_name);
4408 let name = name.strip_suffix(':').unwrap_or(name);
4409 output.push_str(name);
4410 output.push(':');
4411
4412 let mut parts = Vec::new();
4413 let escaped_sn = short_name.replace('\\', "\\\\").replace('"', "\\\"");
4414 parts.push(format!("shortName=\"{escaped_sn}\""));
4415 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4416 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4417 parts.push(format!("id=\"{escaped}\""));
4418 }
4419 if let Some(text) = attrs.get("text").and_then(serde_json::Value::as_str) {
4420 let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
4421 parts.push(format!("text=\"{escaped}\""));
4422 }
4423 maybe_push_local_id(attrs, &mut parts, opts);
4424 output.push('{');
4425 output.push_str(&parts.join(" "));
4426 output.push('}');
4427 }
4428 }
4429 }
4430 "status" => {
4431 if let Some(ref attrs) = node.attrs {
4432 let text = attrs
4433 .get("text")
4434 .and_then(serde_json::Value::as_str)
4435 .unwrap_or("");
4436 let color = attrs
4437 .get("color")
4438 .and_then(serde_json::Value::as_str)
4439 .unwrap_or("neutral");
4440 let mut attr_parts = vec![format!("color={color}")];
4441 if let Some(style) = attrs.get("style").and_then(serde_json::Value::as_str) {
4442 attr_parts.push(format!("style={style}"));
4443 }
4444 maybe_push_local_id(attrs, &mut attr_parts, opts);
4445 output.push_str(&format!(":status[{text}]{{{}}}", attr_parts.join(" ")));
4446 }
4447 }
4448 "date" => {
4449 if let Some(ref attrs) = node.attrs {
4450 if let Some(timestamp) = attrs.get("timestamp").and_then(serde_json::Value::as_str)
4451 {
4452 let display = epoch_ms_to_iso_date(timestamp);
4453 let mut attr_parts = vec![format!("timestamp={timestamp}")];
4454 maybe_push_local_id(attrs, &mut attr_parts, opts);
4455 output.push_str(&format!(":date[{display}]{{{}}}", attr_parts.join(" ")));
4456 }
4457 }
4458 }
4459 "mention" => {
4460 if let Some(ref attrs) = node.attrs {
4461 let id = attrs
4462 .get("id")
4463 .and_then(serde_json::Value::as_str)
4464 .unwrap_or("");
4465 let text = attrs
4466 .get("text")
4467 .and_then(serde_json::Value::as_str)
4468 .unwrap_or("");
4469 let mut attr_parts = vec![format!("id={id}")];
4470 if let Some(ut) = attrs.get("userType").and_then(serde_json::Value::as_str) {
4471 attr_parts.push(format!("userType={ut}"));
4472 }
4473 if let Some(al) = attrs.get("accessLevel").and_then(serde_json::Value::as_str) {
4474 attr_parts.push(format!("accessLevel={al}"));
4475 }
4476 maybe_push_local_id(attrs, &mut attr_parts, opts);
4477 output.push_str(&format!(":mention[{text}]{{{}}}", attr_parts.join(" ")));
4478 }
4479 }
4480 "placeholder" => {
4481 if let Some(ref attrs) = node.attrs {
4482 let text = attrs
4483 .get("text")
4484 .and_then(serde_json::Value::as_str)
4485 .unwrap_or("");
4486 output.push_str(&format!(":placeholder[{text}]"));
4487 }
4488 }
4489 "inlineExtension" => {
4490 if let Some(ref attrs) = node.attrs {
4491 let ext_type = attrs
4492 .get("extensionType")
4493 .and_then(serde_json::Value::as_str)
4494 .unwrap_or("");
4495 let ext_key = attrs
4496 .get("extensionKey")
4497 .and_then(serde_json::Value::as_str)
4498 .unwrap_or("");
4499 let fallback = node.text.as_deref().unwrap_or("");
4500 output.push_str(&format!(
4501 ":extension[{fallback}]{{type={ext_type} key={ext_key}}}"
4502 ));
4503 }
4504 }
4505 "mediaInline" => {
4506 if let Some(ref attrs) = node.attrs {
4507 let mut attr_parts = Vec::new();
4508 if let Some(media_type) = attrs.get("type").and_then(serde_json::Value::as_str) {
4509 attr_parts.push(format_kv("type", media_type));
4510 }
4511 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4512 attr_parts.push(format_kv("id", id));
4513 }
4514 if let Some(collection) =
4515 attrs.get("collection").and_then(serde_json::Value::as_str)
4516 {
4517 attr_parts.push(format_kv("collection", collection));
4518 }
4519 if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4520 attr_parts.push(format_kv("url", url));
4521 }
4522 if let Some(alt) = attrs.get("alt").and_then(serde_json::Value::as_str) {
4523 attr_parts.push(format_kv("alt", alt));
4524 }
4525 if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
4526 attr_parts.push(format!("width={width}"));
4527 }
4528 if let Some(height) = attrs.get("height").and_then(serde_json::Value::as_u64) {
4529 attr_parts.push(format!("height={height}"));
4530 }
4531 maybe_push_local_id(attrs, &mut attr_parts, opts);
4532 output.push_str(&format!(":media-inline[]{{{}}}", attr_parts.join(" ")));
4533 }
4534 }
4535 _ => {
4536 output.push_str(&format!("<!-- unsupported inline: {} -->", node.node_type));
4537 }
4538 }
4539}
4540
4541fn render_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4554 if marks.iter().any(|m| m.mark_type == "code") {
4555 render_code_marked_text(text, marks, output);
4556 return;
4557 }
4558
4559 let has_link = marks.iter().any(|m| m.mark_type == "link");
4560 let has_strong = marks.iter().any(|m| m.mark_type == "strong");
4561 let has_em = marks.iter().any(|m| m.mark_type == "em");
4562
4563 if marks.len() == 2 && marks[0].mark_type == "strong" && marks[1].mark_type == "em" {
4567 let escaped = escape_emphasis_markers(text);
4568 let escaped = escape_emoji_shortcodes(&escaped);
4569 let escaped = escape_backticks(&escaped);
4570 let escaped = escape_bare_urls(&escaped);
4571 output.push_str("***");
4572 output.push_str(&escaped);
4573 output.push_str("***");
4574 return;
4575 }
4576
4577 let em_delim = if has_strong && has_em { "_" } else { "*" };
4581
4582 let escaped = if em_delim == "_" {
4585 escape_emphasis_markers_with_underscore(text)
4586 } else {
4587 escape_emphasis_markers(text)
4588 };
4589 let escaped = escape_emoji_shortcodes(&escaped);
4590 let escaped = escape_backticks(&escaped);
4591 let escaped = escape_bare_urls(&escaped);
4599 let escaped = if has_link {
4600 escape_link_brackets(&escaped)
4601 } else {
4602 escaped
4603 };
4604
4605 let mut wrappers: Vec<(String, String)> = Vec::new();
4611 let mut i = 0;
4612 while i < marks.len() {
4613 match marks[i].mark_type.as_str() {
4614 "em" => {
4615 wrappers.push((em_delim.to_string(), em_delim.to_string()));
4616 i += 1;
4617 }
4618 "strong" => {
4619 wrappers.push(("**".to_string(), "**".to_string()));
4620 i += 1;
4621 }
4622 "strike" => {
4623 wrappers.push(("~~".to_string(), "~~".to_string()));
4624 i += 1;
4625 }
4626 "link" => {
4627 let href = link_href(&marks[i]);
4628 wrappers.push(("[".to_string(), format!("]({href})")));
4629 i += 1;
4630 }
4631 "textColor" | "backgroundColor" | "subsup" => {
4632 let start = i;
4633 while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4634 i += 1;
4635 }
4636 emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4637 }
4638 "underline" | "annotation" => {
4639 let start = i;
4640 while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4641 i += 1;
4642 }
4643 emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4644 }
4645 _ => {
4646 i += 1;
4647 }
4648 }
4649 }
4650
4651 let mut result = escaped;
4653 for (open, close) in wrappers.iter().rev() {
4654 result.insert_str(0, open);
4655 result.push_str(close);
4656 }
4657 output.push_str(&result);
4658}
4659
4660fn render_code_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4668 let link_mark = marks.iter().find(|m| m.mark_type == "link");
4669
4670 let mut code_str = String::new();
4671 if let Some(link_mark) = link_mark {
4672 let href = link_href(link_mark);
4673 code_str.push('[');
4674 render_inline_code(text, &mut code_str);
4675 code_str.push_str("](");
4676 code_str.push_str(href);
4677 code_str.push(')');
4678 } else {
4679 render_inline_code(text, &mut code_str);
4680 }
4681
4682 let mut wrappers: Vec<(String, String)> = Vec::new();
4685 let mut i = 0;
4686 while i < marks.len() {
4687 match marks[i].mark_type.as_str() {
4688 "textColor" | "backgroundColor" | "subsup" => {
4689 let start = i;
4690 while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4691 i += 1;
4692 }
4693 emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4694 }
4695 "underline" | "annotation" => {
4696 let start = i;
4697 while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4698 i += 1;
4699 }
4700 emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4701 }
4702 _ => {
4703 i += 1;
4704 }
4705 }
4706 }
4707
4708 let mut result = code_str;
4710 for (open, close) in wrappers.iter().rev() {
4711 result.insert_str(0, open);
4712 result.push_str(close);
4713 }
4714 output.push_str(&result);
4715}
4716
4717fn collect_span_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4719 match mark.mark_type.as_str() {
4720 "textColor" => {
4721 if let Some(c) = mark
4722 .attrs
4723 .as_ref()
4724 .and_then(|a| a.get("color"))
4725 .and_then(serde_json::Value::as_str)
4726 {
4727 attrs.push(format!("color={c}"));
4728 }
4729 }
4730 "backgroundColor" => {
4731 if let Some(c) = mark
4732 .attrs
4733 .as_ref()
4734 .and_then(|a| a.get("color"))
4735 .and_then(serde_json::Value::as_str)
4736 {
4737 attrs.push(format!("bg={c}"));
4738 }
4739 }
4740 "subsup" => {
4741 if let Some(kind) = mark
4742 .attrs
4743 .as_ref()
4744 .and_then(|a| a.get("type"))
4745 .and_then(serde_json::Value::as_str)
4746 {
4747 attrs.push(kind.to_string());
4748 }
4749 }
4750 _ => {}
4751 }
4752}
4753
4754fn collect_bracketed_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4756 match mark.mark_type.as_str() {
4757 "underline" => attrs.push("underline".to_string()),
4758 "annotation" => {
4759 if let Some(ref a) = mark.attrs {
4760 if let Some(id) = a.get("id").and_then(serde_json::Value::as_str) {
4761 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4762 attrs.push(format!("annotation-id=\"{escaped}\""));
4763 }
4764 if let Some(at) = a.get("annotationType").and_then(serde_json::Value::as_str) {
4765 attrs.push(format!("annotation-type={at}"));
4766 }
4767 }
4768 }
4769 _ => {}
4770 }
4771}
4772
4773fn is_span_attr_mark(mark_type: &str) -> bool {
4774 matches!(mark_type, "textColor" | "backgroundColor" | "subsup")
4775}
4776
4777fn is_bracketed_span_mark(mark_type: &str) -> bool {
4778 matches!(mark_type, "underline" | "annotation")
4779}
4780
4781fn span_attr_order(mark_type: &str) -> u8 {
4785 match mark_type {
4786 "textColor" => 0,
4787 "backgroundColor" => 1,
4788 "subsup" => 2,
4789 _ => u8::MAX,
4790 }
4791}
4792
4793fn span_run_is_canonical(run: &[AdfMark]) -> bool {
4798 let mut prev = 0;
4799 for m in run {
4800 let order = span_attr_order(&m.mark_type);
4801 if order == u8::MAX || order < prev {
4802 return false;
4803 }
4804 prev = order;
4805 }
4806 true
4807}
4808
4809fn bracketed_run_is_canonical(run: &[AdfMark]) -> bool {
4814 let mut seen_annotation = false;
4815 for m in run {
4816 match m.mark_type.as_str() {
4817 "underline" => {
4818 if seen_annotation {
4819 return false;
4820 }
4821 }
4822 "annotation" => seen_annotation = true,
4823 _ => return false,
4824 }
4825 }
4826 true
4827}
4828
4829fn emit_span_attr_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4833 if span_run_is_canonical(run) {
4834 let mut attrs = Vec::new();
4835 for m in run {
4836 collect_span_attr(m, &mut attrs);
4837 }
4838 wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4839 return;
4840 }
4841 for m in run {
4842 let mut attrs = Vec::new();
4843 collect_span_attr(m, &mut attrs);
4844 wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4845 }
4846}
4847
4848fn emit_bracketed_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4852 if bracketed_run_is_canonical(run) {
4853 let mut attrs = Vec::new();
4854 for m in run {
4855 collect_bracketed_attr(m, &mut attrs);
4856 }
4857 wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4858 return;
4859 }
4860 for m in run {
4861 let mut attrs = Vec::new();
4862 collect_bracketed_attr(m, &mut attrs);
4863 wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4864 }
4865}
4866
4867fn link_href(mark: &AdfMark) -> &str {
4869 mark.attrs
4870 .as_ref()
4871 .and_then(|a| a.get("href"))
4872 .and_then(serde_json::Value::as_str)
4873 .unwrap_or("")
4874}
4875
4876#[cfg(test)]
4877#[allow(
4878 clippy::unwrap_used,
4879 clippy::expect_used,
4880 clippy::needless_update,
4881 clippy::needless_collect,
4882 duplicate_macro_attributes
4883)]
4884mod tests {
4885 use super::*;
4886
4887 #[test]
4890 fn adf_to_plain_text_single_paragraph() {
4891 let doc = markdown_to_adf("Hello world").unwrap();
4892 assert_eq!(adf_to_plain_text(&doc), "Hello world");
4893 }
4894
4895 #[test]
4896 fn adf_to_plain_text_multiple_paragraphs_space_separated() {
4897 let doc = markdown_to_adf("Alpha\n\nBeta").unwrap();
4898 let plain = adf_to_plain_text(&doc);
4899 assert!(plain.contains("Alpha"));
4901 assert!(plain.contains("Beta"));
4902 assert_eq!(plain, "Alpha Beta");
4903 }
4904
4905 #[test]
4906 fn adf_to_plain_text_drops_marks_but_keeps_text() {
4907 let doc = markdown_to_adf("Hello **bold** world").unwrap();
4908 assert_eq!(adf_to_plain_text(&doc), "Hello bold world");
4909 }
4910
4911 #[test]
4912 fn adf_to_plain_text_empty_doc() {
4913 let doc = AdfDocument::new();
4914 assert_eq!(adf_to_plain_text(&doc), "");
4915 }
4916
4917 #[test]
4918 fn adf_to_plain_text_leading_empty_block_emits_no_extra_space() {
4919 let doc = AdfDocument {
4922 version: 1,
4923 doc_type: "doc".to_string(),
4924 content: vec![
4925 AdfNode {
4926 node_type: "paragraph".to_string(),
4927 attrs: None,
4928 content: Some(vec![]),
4929 text: None,
4930 marks: None,
4931 local_id: None,
4932 parameters: None,
4933 },
4934 AdfNode {
4935 node_type: "paragraph".to_string(),
4936 attrs: None,
4937 content: Some(vec![AdfNode::text("Hello")]),
4938 text: None,
4939 marks: None,
4940 local_id: None,
4941 parameters: None,
4942 },
4943 ],
4944 };
4945 assert_eq!(adf_to_plain_text(&doc), "Hello");
4946 }
4947
4948 #[test]
4951 fn paragraph() {
4952 let doc = markdown_to_adf("Hello world").unwrap();
4953 assert_eq!(doc.content.len(), 1);
4954 assert_eq!(doc.content[0].node_type, "paragraph");
4955 }
4956
4957 #[test]
4958 fn heading_levels() {
4959 for level in 1..=6 {
4960 let hashes = "#".repeat(level);
4961 let md = format!("{hashes} Title");
4962 let doc = markdown_to_adf(&md).unwrap();
4963 assert_eq!(doc.content[0].node_type, "heading");
4964 let attrs = doc.content[0].attrs.as_ref().unwrap();
4965 assert_eq!(attrs["level"], level as u64);
4966 }
4967 }
4968
4969 #[test]
4970 fn code_block() {
4971 let md = "```rust\nfn main() {}\n```";
4972 let doc = markdown_to_adf(md).unwrap();
4973 assert_eq!(doc.content[0].node_type, "codeBlock");
4974 let attrs = doc.content[0].attrs.as_ref().unwrap();
4975 assert_eq!(attrs["language"], "rust");
4976 }
4977
4978 #[test]
4979 fn code_block_no_language() {
4980 let md = "```\nsome code\n```";
4981 let doc = markdown_to_adf(md).unwrap();
4982 assert_eq!(doc.content[0].node_type, "codeBlock");
4983 assert!(doc.content[0].attrs.is_none());
4984 }
4985
4986 #[test]
4987 fn code_block_empty_language() {
4988 let md = "```\"\"\nsome code\n```";
4989 let doc = markdown_to_adf(md).unwrap();
4990 assert_eq!(doc.content[0].node_type, "codeBlock");
4991 let attrs = doc.content[0].attrs.as_ref().unwrap();
4992 assert_eq!(attrs["language"], "");
4993 }
4994
4995 #[test]
4996 fn horizontal_rule() {
4997 let doc = markdown_to_adf("---").unwrap();
4998 assert_eq!(doc.content[0].node_type, "rule");
4999 }
5000
5001 #[test]
5002 fn horizontal_rule_stars() {
5003 let doc = markdown_to_adf("***").unwrap();
5004 assert_eq!(doc.content[0].node_type, "rule");
5005 }
5006
5007 #[test]
5008 fn blockquote() {
5009 let md = "> This is a quote\n> Second line";
5010 let doc = markdown_to_adf(md).unwrap();
5011 assert_eq!(doc.content[0].node_type, "blockquote");
5012 }
5013
5014 #[test]
5015 fn bullet_list() {
5016 let md = "- Item 1\n- Item 2\n- Item 3";
5017 let doc = markdown_to_adf(md).unwrap();
5018 assert_eq!(doc.content[0].node_type, "bulletList");
5019 let items = doc.content[0].content.as_ref().unwrap();
5020 assert_eq!(items.len(), 3);
5021 }
5022
5023 #[test]
5024 fn ordered_list() {
5025 let md = "1. First\n2. Second\n3. Third";
5026 let doc = markdown_to_adf(md).unwrap();
5027 assert_eq!(doc.content[0].node_type, "orderedList");
5028 let items = doc.content[0].content.as_ref().unwrap();
5029 assert_eq!(items.len(), 3);
5030 }
5031
5032 #[test]
5033 fn task_list() {
5034 let md = "- [ ] Todo item\n- [x] Done item";
5035 let doc = markdown_to_adf(md).unwrap();
5036 assert_eq!(doc.content[0].node_type, "taskList");
5037 let items = doc.content[0].content.as_ref().unwrap();
5038 assert_eq!(items.len(), 2);
5039 assert_eq!(items[0].node_type, "taskItem");
5040 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5041 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5042 }
5043
5044 #[test]
5045 fn task_list_uppercase_x() {
5046 let md = "- [X] Done item";
5047 let doc = markdown_to_adf(md).unwrap();
5048 assert_eq!(doc.content[0].node_type, "taskList");
5049 let item = &doc.content[0].content.as_ref().unwrap()[0];
5050 assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
5051 }
5052
5053 #[test]
5056 fn task_list_empty_todo_no_trailing_space() {
5057 let md = "- [ ]";
5058 let doc = markdown_to_adf(md).unwrap();
5059 assert_eq!(doc.content[0].node_type, "taskList");
5060 let items = doc.content[0].content.as_ref().unwrap();
5061 assert_eq!(items.len(), 1);
5062 assert_eq!(items[0].node_type, "taskItem");
5063 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5064 assert!(items[0].content.is_none());
5065 }
5066
5067 #[test]
5069 fn task_list_empty_done_no_trailing_space() {
5070 let md = "- [x]\n- [X]";
5071 let doc = markdown_to_adf(md).unwrap();
5072 assert_eq!(doc.content[0].node_type, "taskList");
5073 let items = doc.content[0].content.as_ref().unwrap();
5074 assert_eq!(items.len(), 2);
5075 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
5076 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5077 }
5078
5079 #[test]
5082 fn task_list_body_has_no_leading_space() {
5083 let md = "- [ ] Buy groceries";
5084 let doc = markdown_to_adf(md).unwrap();
5085 let item = &doc.content[0].content.as_ref().unwrap()[0];
5086 let text = item.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5087 assert_eq!(text, "Buy groceries");
5088 }
5089
5090 #[test]
5094 fn round_trip_empty_task_items_stripped_trailing_spaces() {
5095 let json = r#"{
5096 "version": 1,
5097 "type": "doc",
5098 "content": [{
5099 "type": "taskList",
5100 "attrs": {"localId": "abc"},
5101 "content": [
5102 {"type": "taskItem", "attrs": {"localId": "def", "state": "TODO"}},
5103 {"type": "taskItem", "attrs": {"localId": "ghi", "state": "DONE"}}
5104 ]
5105 }]
5106 }"#;
5107 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5108 let md = adf_to_markdown(&doc).unwrap();
5109 let stripped: String = md.lines().map(str::trim_end).collect::<Vec<_>>().join("\n");
5110 let parsed = markdown_to_adf(&stripped).unwrap();
5111 assert_eq!(parsed.content[0].node_type, "taskList");
5112 let items = parsed.content[0].content.as_ref().unwrap();
5113 assert_eq!(items.len(), 2);
5114 assert_eq!(items[0].node_type, "taskItem");
5115 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5116 assert_eq!(items[1].node_type, "taskItem");
5117 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5118 }
5119
5120 #[test]
5121 fn try_parse_task_marker_accepts_bare_checkbox() {
5122 assert_eq!(try_parse_task_marker("[ ]"), Some(("TODO", "")));
5123 assert_eq!(try_parse_task_marker("[x]"), Some(("DONE", "")));
5124 assert_eq!(try_parse_task_marker("[X]"), Some(("DONE", "")));
5125 assert_eq!(try_parse_task_marker("[ ] foo"), Some(("TODO", "foo")));
5126 assert_eq!(try_parse_task_marker("[x] foo"), Some(("DONE", "foo")));
5127 assert_eq!(try_parse_task_marker("[ ]foo"), None);
5128 assert_eq!(try_parse_task_marker("[x]foo"), None);
5129 assert_eq!(try_parse_task_marker("[y] foo"), None);
5130 }
5131
5132 #[test]
5133 fn starts_with_task_marker_matches_parser() {
5134 assert!(starts_with_task_marker("[ ]"));
5137 assert!(starts_with_task_marker("[x]"));
5138 assert!(starts_with_task_marker("[X]"));
5139 assert!(starts_with_task_marker("[ ] foo"));
5140 assert!(starts_with_task_marker("[x] foo\n"));
5141 assert!(starts_with_task_marker("[ ]\n"));
5142 assert!(!starts_with_task_marker("[ ]foo"));
5144 assert!(!starts_with_task_marker("[y] foo"));
5145 assert!(!starts_with_task_marker("foo [ ] bar"));
5146 assert!(!starts_with_task_marker(""));
5147 }
5148
5149 #[test]
5153 fn round_trip_bullet_list_with_literal_checkbox_text() {
5154 let json = r#"{
5155 "version": 1,
5156 "type": "doc",
5157 "content": [{
5158 "type": "bulletList",
5159 "content": [{
5160 "type": "listItem",
5161 "content": [{
5162 "type": "paragraph",
5163 "content": [
5164 {"type": "text", "text": "[ ] Review the "},
5165 {"type": "text", "text": "config.yaml", "marks": [{"type": "code"}]},
5166 {"type": "text", "text": " file"}
5167 ]
5168 }]
5169 }]
5170 }]
5171 }"#;
5172 let original: AdfDocument = serde_json::from_str(json).unwrap();
5173 let md = adf_to_markdown(&original).unwrap();
5174 assert!(
5176 md.contains(r"- \[ ] Review the "),
5177 "rendered markdown: {md:?}"
5178 );
5179 let parsed = markdown_to_adf(&md).unwrap();
5180 assert_eq!(parsed.content[0].node_type, "bulletList");
5181 let item = &parsed.content[0].content.as_ref().unwrap()[0];
5182 assert_eq!(item.node_type, "listItem");
5183 let para = &item.content.as_ref().unwrap()[0];
5184 assert_eq!(para.node_type, "paragraph");
5185 let text_nodes = para.content.as_ref().unwrap();
5186 assert_eq!(text_nodes[0].text.as_deref().unwrap(), "[ ] Review the ");
5187 assert_eq!(text_nodes[1].text.as_deref().unwrap(), "config.yaml");
5188 assert_eq!(text_nodes[2].text.as_deref().unwrap(), " file");
5189 }
5190
5191 #[test]
5193 fn round_trip_bullet_list_with_literal_done_checkbox_text() {
5194 let json = r#"{
5195 "version": 1,
5196 "type": "doc",
5197 "content": [{
5198 "type": "bulletList",
5199 "content": [{
5200 "type": "listItem",
5201 "content": [{
5202 "type": "paragraph",
5203 "content": [{"type": "text", "text": "[x] not actually done"}]
5204 }]
5205 }]
5206 }]
5207 }"#;
5208 let original: AdfDocument = serde_json::from_str(json).unwrap();
5209 let md = adf_to_markdown(&original).unwrap();
5210 assert!(md.contains(r"- \[x] "), "rendered markdown: {md:?}");
5211 let parsed = markdown_to_adf(&md).unwrap();
5212 assert_eq!(parsed.content[0].node_type, "bulletList");
5213 let item = &parsed.content[0].content.as_ref().unwrap()[0];
5214 let para = &item.content.as_ref().unwrap()[0];
5215 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5216 assert_eq!(text, "[x] not actually done");
5217 }
5218
5219 #[test]
5221 fn round_trip_bullet_list_with_bare_literal_checkbox() {
5222 let json = r#"{
5223 "version": 1,
5224 "type": "doc",
5225 "content": [{
5226 "type": "bulletList",
5227 "content": [{
5228 "type": "listItem",
5229 "content": [{
5230 "type": "paragraph",
5231 "content": [{"type": "text", "text": "[ ]"}]
5232 }]
5233 }]
5234 }]
5235 }"#;
5236 let original: AdfDocument = serde_json::from_str(json).unwrap();
5237 let md = adf_to_markdown(&original).unwrap();
5238 let parsed = markdown_to_adf(&md).unwrap();
5239 assert_eq!(parsed.content[0].node_type, "bulletList");
5240 let item = &parsed.content[0].content.as_ref().unwrap()[0];
5241 let para = &item.content.as_ref().unwrap()[0];
5242 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5243 assert_eq!(text, "[ ]");
5244 }
5245
5246 #[test]
5249 fn bullet_list_non_task_bracket_text_not_escaped() {
5250 let json = r#"{
5251 "version": 1,
5252 "type": "doc",
5253 "content": [{
5254 "type": "bulletList",
5255 "content": [{
5256 "type": "listItem",
5257 "content": [{
5258 "type": "paragraph",
5259 "content": [{"type": "text", "text": "[?] unsure"}]
5260 }]
5261 }]
5262 }]
5263 }"#;
5264 let original: AdfDocument = serde_json::from_str(json).unwrap();
5265 let md = adf_to_markdown(&original).unwrap();
5266 assert!(!md.contains(r"\["), "should not escape: {md:?}");
5267 assert!(md.contains("- [?] unsure"), "rendered: {md:?}");
5268 }
5269
5270 #[test]
5273 fn round_trip_nested_bullet_list_with_literal_checkbox_text() {
5274 let json = r#"{
5275 "version": 1,
5276 "type": "doc",
5277 "content": [{
5278 "type": "bulletList",
5279 "content": [{
5280 "type": "listItem",
5281 "content": [
5282 {"type": "paragraph", "content": [{"type": "text", "text": "outer"}]},
5283 {"type": "bulletList", "content": [{
5284 "type": "listItem",
5285 "content": [{
5286 "type": "paragraph",
5287 "content": [{"type": "text", "text": "[ ] inner literal"}]
5288 }]
5289 }]}
5290 ]
5291 }]
5292 }]
5293 }"#;
5294 let original: AdfDocument = serde_json::from_str(json).unwrap();
5295 let md = adf_to_markdown(&original).unwrap();
5296 let parsed = markdown_to_adf(&md).unwrap();
5297 let outer = &parsed.content[0];
5298 assert_eq!(outer.node_type, "bulletList");
5299 let outer_item = &outer.content.as_ref().unwrap()[0];
5300 let inner_list = &outer_item.content.as_ref().unwrap()[1];
5301 assert_eq!(inner_list.node_type, "bulletList");
5302 let inner_item = &inner_list.content.as_ref().unwrap()[0];
5303 assert_eq!(inner_item.node_type, "listItem");
5304 let para = &inner_item.content.as_ref().unwrap()[0];
5305 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5306 assert_eq!(text, "[ ] inner literal");
5307 }
5308
5309 #[test]
5310 fn adf_task_list_to_markdown() {
5311 let doc = AdfDocument {
5312 version: 1,
5313 doc_type: "doc".to_string(),
5314 content: vec![AdfNode::task_list(vec![
5315 AdfNode::task_item(
5316 "TODO",
5317 vec![AdfNode::paragraph(vec![AdfNode::text("Todo")])],
5318 ),
5319 AdfNode::task_item(
5320 "DONE",
5321 vec![AdfNode::paragraph(vec![AdfNode::text("Done")])],
5322 ),
5323 ])],
5324 };
5325 let md = adf_to_markdown(&doc).unwrap();
5326 assert!(md.contains("- [ ] Todo"));
5327 assert!(md.contains("- [x] Done"));
5328 }
5329
5330 #[test]
5331 fn round_trip_task_list() {
5332 let md = "- [ ] Todo item\n- [x] Done item\n";
5333 let doc = markdown_to_adf(md).unwrap();
5334 let result = adf_to_markdown(&doc).unwrap();
5335 assert!(result.contains("- [ ] Todo item"));
5336 assert!(result.contains("- [x] Done item"));
5337 }
5338
5339 #[test]
5341 fn adf_task_item_unwrapped_inline_content() {
5342 let json = r#"{
5344 "version": 1,
5345 "type": "doc",
5346 "content": [{
5347 "type": "taskList",
5348 "attrs": {"localId": "list-001"},
5349 "content": [{
5350 "type": "taskItem",
5351 "attrs": {"localId": "task-001", "state": "TODO"},
5352 "content": [{"type": "text", "text": "Do something"}]
5353 }]
5354 }]
5355 }"#;
5356 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5357 let md = adf_to_markdown(&doc).unwrap();
5358 assert!(md.contains("- [ ] Do something"), "got: {md}");
5359 assert!(!md.contains("adf-unsupported"), "got: {md}");
5360 }
5361
5362 #[test]
5364 fn adf_task_list_multiple_unwrapped_items() {
5365 let json = r#"{
5366 "version": 1,
5367 "type": "doc",
5368 "content": [{
5369 "type": "taskList",
5370 "attrs": {"localId": "list-001"},
5371 "content": [
5372 {
5373 "type": "taskItem",
5374 "attrs": {"localId": "task-001", "state": "TODO"},
5375 "content": [{"type": "text", "text": "First task"}]
5376 },
5377 {
5378 "type": "taskItem",
5379 "attrs": {"localId": "task-002", "state": "DONE"},
5380 "content": [{"type": "text", "text": "Second task"}]
5381 }
5382 ]
5383 }]
5384 }"#;
5385 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5386 let md = adf_to_markdown(&doc).unwrap();
5387 assert!(md.contains("- [ ] First task"), "got: {md}");
5388 assert!(md.contains("- [x] Second task"), "got: {md}");
5389 assert!(!md.contains("adf-unsupported"), "got: {md}");
5390 }
5391
5392 #[test]
5394 fn adf_task_item_unwrapped_inline_with_marks() {
5395 let json = r#"{
5396 "version": 1,
5397 "type": "doc",
5398 "content": [{
5399 "type": "taskList",
5400 "attrs": {"localId": "list-001"},
5401 "content": [{
5402 "type": "taskItem",
5403 "attrs": {"localId": "task-001", "state": "TODO"},
5404 "content": [
5405 {"type": "text", "text": "Buy "},
5406 {"type": "text", "text": "groceries", "marks": [{"type": "strong"}]},
5407 {"type": "text", "text": " today"}
5408 ]
5409 }]
5410 }]
5411 }"#;
5412 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5413 let md = adf_to_markdown(&doc).unwrap();
5414 assert!(md.contains("- [ ] Buy **groceries** today"), "got: {md}");
5415 }
5416
5417 #[test]
5419 fn adf_task_item_unwrapped_preserves_local_id() {
5420 let json = r#"{
5421 "version": 1,
5422 "type": "doc",
5423 "content": [{
5424 "type": "taskList",
5425 "attrs": {"localId": "list-001"},
5426 "content": [{
5427 "type": "taskItem",
5428 "attrs": {"localId": "task-001", "state": "TODO"},
5429 "content": [{"type": "text", "text": "Do something"}]
5430 }]
5431 }]
5432 }"#;
5433 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5434 let md = adf_to_markdown(&doc).unwrap();
5435 assert!(md.contains("{localId=task-001}"), "got: {md}");
5436 assert!(md.contains("{localId=list-001}"), "got: {md}");
5437 }
5438
5439 #[test]
5441 fn round_trip_task_list_unwrapped_inline() {
5442 let json = r#"{
5443 "version": 1,
5444 "type": "doc",
5445 "content": [{
5446 "type": "taskList",
5447 "attrs": {"localId": "list-001"},
5448 "content": [
5449 {
5450 "type": "taskItem",
5451 "attrs": {"localId": "task-001", "state": "TODO"},
5452 "content": [{"type": "text", "text": "Do something"}]
5453 },
5454 {
5455 "type": "taskItem",
5456 "attrs": {"localId": "task-002", "state": "DONE"},
5457 "content": [{"type": "text", "text": "Already done"}]
5458 }
5459 ]
5460 }]
5461 }"#;
5462 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5463 let md = adf_to_markdown(&doc).unwrap();
5464
5465 let doc2 = markdown_to_adf(&md).unwrap();
5467 assert_eq!(doc2.content[0].node_type, "taskList");
5468
5469 let items = doc2.content[0].content.as_ref().unwrap();
5470 assert_eq!(items.len(), 2);
5471 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5472 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5473
5474 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5476 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "task-002");
5477 assert_eq!(
5478 doc2.content[0].attrs.as_ref().unwrap()["localId"],
5479 "list-001"
5480 );
5481 }
5482
5483 #[test]
5485 fn adf_task_item_unwrapped_inline_then_block() {
5486 let json = r#"{
5487 "version": 1,
5488 "type": "doc",
5489 "content": [{
5490 "type": "taskList",
5491 "attrs": {"localId": "list-001"},
5492 "content": [{
5493 "type": "taskItem",
5494 "attrs": {"localId": "task-001", "state": "TODO"},
5495 "content": [
5496 {"type": "text", "text": "Parent task"},
5497 {
5498 "type": "bulletList",
5499 "content": [{
5500 "type": "listItem",
5501 "content": [{
5502 "type": "paragraph",
5503 "content": [{"type": "text", "text": "sub-item"}]
5504 }]
5505 }]
5506 }
5507 ]
5508 }]
5509 }]
5510 }"#;
5511 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5512 let md = adf_to_markdown(&doc).unwrap();
5513 assert!(md.contains("- [ ] Parent task"), "got: {md}");
5514 assert!(md.contains(" - sub-item"), "got: {md}");
5515 assert!(!md.contains("adf-unsupported"), "got: {md}");
5516 }
5517
5518 #[test]
5520 fn adf_task_item_empty_content() {
5521 let json = r#"{
5522 "version": 1,
5523 "type": "doc",
5524 "content": [{
5525 "type": "taskList",
5526 "attrs": {"localId": "list-001"},
5527 "content": [{
5528 "type": "taskItem",
5529 "attrs": {"localId": "task-001", "state": "TODO"},
5530 "content": []
5531 }]
5532 }]
5533 }"#;
5534 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5535 let md = adf_to_markdown(&doc).unwrap();
5536 assert!(md.contains("- [ ] "), "got: {md}");
5537 assert!(!md.contains("adf-unsupported"), "got: {md}");
5538 }
5539
5540 #[test]
5543 fn adf_nested_task_item_renders_without_corruption() {
5544 let json = r#"{
5545 "type": "doc",
5546 "version": 1,
5547 "content": [{
5548 "type": "taskList",
5549 "attrs": {"localId": ""},
5550 "content": [
5551 {
5552 "type": "taskItem",
5553 "attrs": {"localId": "aabbccdd-1234-5678-abcd-aabbccdd1234", "state": "TODO"},
5554 "content": [{"type": "text", "text": "Normal task"}]
5555 },
5556 {
5557 "type": "taskItem",
5558 "attrs": {"localId": ""},
5559 "content": [
5560 {
5561 "type": "taskItem",
5562 "attrs": {"localId": "bbccddee-2345-6789-bcde-bbccddee2345", "state": "TODO"},
5563 "content": [{"type": "text", "text": "Nested task one"}]
5564 },
5565 {
5566 "type": "taskItem",
5567 "attrs": {"localId": "ccddee11-3456-7890-cdef-ccddee113456", "state": "DONE"},
5568 "content": [{"type": "text", "text": "Nested task two"}]
5569 }
5570 ]
5571 }
5572 ]
5573 }]
5574 }"#;
5575 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5576 let md = adf_to_markdown(&doc).unwrap();
5577 assert!(md.contains("- [ ] Normal task"), "got: {md}");
5579 assert!(!md.contains("adf-unsupported"), "got: {md}");
5581 assert!(md.contains(" - [ ] Nested task one"), "got: {md}");
5582 assert!(md.contains(" - [x] Nested task two"), "got: {md}");
5583 }
5584
5585 #[test]
5587 fn round_trip_nested_task_item() {
5588 let json = r#"{
5589 "type": "doc",
5590 "version": 1,
5591 "content": [{
5592 "type": "taskList",
5593 "attrs": {"localId": ""},
5594 "content": [
5595 {
5596 "type": "taskItem",
5597 "attrs": {"localId": "task-001", "state": "TODO"},
5598 "content": [{"type": "text", "text": "Normal task"}]
5599 },
5600 {
5601 "type": "taskItem",
5602 "attrs": {"localId": ""},
5603 "content": [
5604 {
5605 "type": "taskItem",
5606 "attrs": {"localId": "task-002", "state": "TODO"},
5607 "content": [{"type": "text", "text": "Nested one"}]
5608 },
5609 {
5610 "type": "taskItem",
5611 "attrs": {"localId": "task-003", "state": "DONE"},
5612 "content": [{"type": "text", "text": "Nested two"}]
5613 }
5614 ]
5615 }
5616 ]
5617 }]
5618 }"#;
5619 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5620 let md = adf_to_markdown(&doc).unwrap();
5621 let doc2 = markdown_to_adf(&md).unwrap();
5622
5623 assert_eq!(doc2.content[0].node_type, "taskList");
5625 let items = doc2.content[0].content.as_ref().unwrap();
5626 assert_eq!(items.len(), 2, "expected 2 top-level items, got: {items:?}");
5627
5628 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5630 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5631 let first_content = items[0].content.as_ref().unwrap();
5632 assert_eq!(first_content[0].text.as_deref(), Some("Normal task"));
5633
5634 let container = &items[1];
5636 assert_eq!(container.node_type, "taskItem");
5637 let c_attrs = container.attrs.as_ref().unwrap();
5638 assert!(
5639 c_attrs.get("state").is_none(),
5640 "container should have no state attr, got: {c_attrs:?}"
5641 );
5642
5643 let container_content = container.content.as_ref().unwrap();
5645 assert_eq!(
5646 container_content.len(),
5647 2,
5648 "expected 2 bare taskItem children"
5649 );
5650 assert_eq!(container_content[0].node_type, "taskItem");
5651 assert_eq!(
5652 container_content[0].attrs.as_ref().unwrap()["state"],
5653 "TODO"
5654 );
5655 assert_eq!(
5656 container_content[0].attrs.as_ref().unwrap()["localId"],
5657 "task-002"
5658 );
5659 assert_eq!(container_content[1].node_type, "taskItem");
5660 assert_eq!(
5661 container_content[1].attrs.as_ref().unwrap()["state"],
5662 "DONE"
5663 );
5664 assert_eq!(
5665 container_content[1].attrs.as_ref().unwrap()["localId"],
5666 "task-003"
5667 );
5668 }
5669
5670 #[test]
5672 fn adf_nested_task_item_preserves_local_ids() {
5673 let json = r#"{
5674 "type": "doc",
5675 "version": 1,
5676 "content": [{
5677 "type": "taskList",
5678 "attrs": {"localId": "list-001"},
5679 "content": [{
5680 "type": "taskItem",
5681 "attrs": {"localId": "container-001", "state": "TODO"},
5682 "content": [{
5683 "type": "taskItem",
5684 "attrs": {"localId": "child-001", "state": "DONE"},
5685 "content": [{"type": "text", "text": "Nested child"}]
5686 }]
5687 }]
5688 }]
5689 }"#;
5690 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5691 let md = adf_to_markdown(&doc).unwrap();
5692 assert!(
5694 md.contains("localId=container-001"),
5695 "container localId missing: {md}"
5696 );
5697 assert!(
5699 md.contains("localId=child-001"),
5700 "child localId missing: {md}"
5701 );
5702 assert!(!md.contains("adf-unsupported"), "got: {md}");
5703 }
5704
5705 #[test]
5708 fn adf_nested_task_item_mixed_with_block_node() {
5709 let json = r#"{
5710 "type": "doc",
5711 "version": 1,
5712 "content": [{
5713 "type": "taskList",
5714 "attrs": {"localId": ""},
5715 "content": [{
5716 "type": "taskItem",
5717 "attrs": {"localId": "", "state": "TODO"},
5718 "content": [
5719 {
5720 "type": "taskItem",
5721 "attrs": {"localId": "", "state": "TODO"},
5722 "content": [{"type": "text", "text": "A nested task"}]
5723 },
5724 {
5725 "type": "paragraph",
5726 "content": [{"type": "text", "text": "Stray paragraph"}]
5727 }
5728 ]
5729 }]
5730 }]
5731 }"#;
5732 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5733 let md = adf_to_markdown(&doc).unwrap();
5734 assert!(md.contains(" - [ ] A nested task"), "got: {md}");
5735 assert!(md.contains(" Stray paragraph"), "got: {md}");
5736 assert!(!md.contains("adf-unsupported"), "got: {md}");
5737 }
5738
5739 #[test]
5743 fn task_item_with_text_and_nested_sub_content() {
5744 let md = "- [ ] Parent task\n - [ ] Sub task\n";
5745 let doc = markdown_to_adf(md).unwrap();
5746 assert_eq!(doc.content[0].node_type, "taskList");
5747 let items = doc.content[0].content.as_ref().unwrap();
5748 assert_eq!(items.len(), 2, "got: {items:?}");
5751 let parent = &items[0];
5752 assert_eq!(parent.attrs.as_ref().unwrap()["state"], "TODO");
5753 let parent_content = parent.content.as_ref().unwrap();
5754 assert_eq!(parent_content[0].text.as_deref(), Some("Parent task"));
5755 assert_eq!(items[1].node_type, "taskList");
5757 let nested = items[1].content.as_ref().unwrap();
5758 assert_eq!(nested.len(), 1);
5759 assert_eq!(nested[0].attrs.as_ref().unwrap()["state"], "TODO");
5760 }
5761
5762 #[test]
5766 fn task_item_empty_with_non_tasklist_sub_content() {
5767 let md = "- [ ] \n Some paragraph text\n";
5768 let doc = markdown_to_adf(md).unwrap();
5769 assert_eq!(doc.content[0].node_type, "taskList");
5770 let items = doc.content[0].content.as_ref().unwrap();
5771 assert_eq!(items.len(), 1);
5772 let item = &items[0];
5773 assert_eq!(item.attrs.as_ref().unwrap()["state"], "TODO");
5774 let content = item.content.as_ref().unwrap();
5775 assert_eq!(content[0].node_type, "paragraph");
5777 }
5778
5779 #[test]
5781 fn adf_nested_task_item_single_child() {
5782 let json = r#"{
5783 "type": "doc",
5784 "version": 1,
5785 "content": [{
5786 "type": "taskList",
5787 "attrs": {"localId": ""},
5788 "content": [{
5789 "type": "taskItem",
5790 "attrs": {"localId": "", "state": "TODO"},
5791 "content": [{
5792 "type": "taskItem",
5793 "attrs": {"localId": "", "state": "DONE"},
5794 "content": [{"type": "text", "text": "Only child"}]
5795 }]
5796 }]
5797 }]
5798 }"#;
5799 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5800 let md = adf_to_markdown(&doc).unwrap();
5801 assert!(md.contains(" - [x] Only child"), "got: {md}");
5802 assert!(!md.contains("adf-unsupported"), "got: {md}");
5803 }
5804
5805 #[test]
5808 fn adf_nested_tasklist_sibling_renders_indented() {
5809 let json = r#"{
5810 "version": 1,
5811 "type": "doc",
5812 "content": [{
5813 "type": "taskList",
5814 "attrs": {"localId": ""},
5815 "content": [
5816 {
5817 "type": "taskItem",
5818 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000001", "state": "TODO"},
5819 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
5820 },
5821 {
5822 "type": "taskList",
5823 "attrs": {"localId": ""},
5824 "content": [{
5825 "type": "taskItem",
5826 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000002", "state": "TODO"},
5827 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
5828 }]
5829 },
5830 {
5831 "type": "taskItem",
5832 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000003", "state": "TODO"},
5833 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
5834 }
5835 ]
5836 }]
5837 }"#;
5838 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5839 let md = adf_to_markdown(&doc).unwrap();
5840 assert!(md.contains("- [ ] parent task one"), "got: {md}");
5842 assert!(md.contains(" - [ ] nested sub-task"), "got: {md}");
5843 assert!(md.contains("- [ ] parent task two"), "got: {md}");
5844 }
5845
5846 #[test]
5848 fn round_trip_nested_tasklist_preserves_type() {
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": "parent task one"}]}]
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": "nested sub-task"}]}]
5868 }]
5869 },
5870 {
5871 "type": "taskItem",
5872 "attrs": {"localId": "", "state": "TODO"},
5873 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
5874 }
5875 ]
5876 }]
5877 }"#;
5878 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5879 let md = adf_to_markdown(&doc).unwrap();
5880 let rt_doc = markdown_to_adf(&md).unwrap();
5881 assert_eq!(rt_doc.content[0].node_type, "taskList");
5883 let items = rt_doc.content[0].content.as_ref().unwrap();
5884 assert_eq!(items.len(), 3, "got: {items:?}");
5887 assert_eq!(items[0].node_type, "taskItem");
5888 assert_eq!(
5889 items[1].node_type, "taskList",
5890 "nested taskList should survive round-trip"
5891 );
5892 assert_eq!(items[2].node_type, "taskItem");
5893 let nested_items = items[1].content.as_ref().unwrap();
5894 assert_eq!(nested_items[0].attrs.as_ref().unwrap()["state"], "TODO");
5895 }
5896
5897 #[test]
5899 fn adf_nested_tasklist_done_state() {
5900 let json = r#"{
5901 "version": 1,
5902 "type": "doc",
5903 "content": [{
5904 "type": "taskList",
5905 "attrs": {"localId": ""},
5906 "content": [
5907 {
5908 "type": "taskItem",
5909 "attrs": {"localId": "", "state": "TODO"},
5910 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
5911 },
5912 {
5913 "type": "taskList",
5914 "attrs": {"localId": ""},
5915 "content": [{
5916 "type": "taskItem",
5917 "attrs": {"localId": "", "state": "DONE"},
5918 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "done child"}]}]
5919 }]
5920 }
5921 ]
5922 }]
5923 }"#;
5924 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5925 let md = adf_to_markdown(&doc).unwrap();
5926 assert!(md.contains(" - [x] done child"), "got: {md}");
5927 let rt_doc = markdown_to_adf(&md).unwrap();
5929 let items = rt_doc.content[0].content.as_ref().unwrap();
5930 assert_eq!(
5931 items[1].node_type, "taskList",
5932 "nested taskList should survive round-trip"
5933 );
5934 let nested_item = &items[1].content.as_ref().unwrap()[0];
5935 assert_eq!(nested_item.attrs.as_ref().unwrap()["state"], "DONE");
5936 }
5937
5938 #[test]
5940 fn adf_multiple_nested_tasklists() {
5941 let json = r#"{
5942 "version": 1,
5943 "type": "doc",
5944 "content": [{
5945 "type": "taskList",
5946 "attrs": {"localId": ""},
5947 "content": [
5948 {
5949 "type": "taskItem",
5950 "attrs": {"localId": "", "state": "TODO"},
5951 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "first parent"}]}]
5952 },
5953 {
5954 "type": "taskList",
5955 "attrs": {"localId": ""},
5956 "content": [{
5957 "type": "taskItem",
5958 "attrs": {"localId": "", "state": "TODO"},
5959 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child A"}]}]
5960 }]
5961 },
5962 {
5963 "type": "taskItem",
5964 "attrs": {"localId": "", "state": "TODO"},
5965 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "second parent"}]}]
5966 },
5967 {
5968 "type": "taskList",
5969 "attrs": {"localId": ""},
5970 "content": [{
5971 "type": "taskItem",
5972 "attrs": {"localId": "", "state": "DONE"},
5973 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child B"}]}]
5974 }]
5975 }
5976 ]
5977 }]
5978 }"#;
5979 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5980 let md = adf_to_markdown(&doc).unwrap();
5981 assert!(md.contains("- [ ] first parent"), "got: {md}");
5982 assert!(md.contains(" - [ ] child A"), "got: {md}");
5983 assert!(md.contains("- [ ] second parent"), "got: {md}");
5984 assert!(md.contains(" - [x] child B"), "got: {md}");
5985 }
5986
5987 #[test]
5990 fn round_trip_nested_tasklist_stable() {
5991 let json = r#"{
5992 "version": 1,
5993 "type": "doc",
5994 "content": [{
5995 "type": "taskList",
5996 "attrs": {"localId": ""},
5997 "content": [
5998 {
5999 "type": "taskItem",
6000 "attrs": {"localId": "", "state": "TODO"},
6001 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
6002 },
6003 {
6004 "type": "taskList",
6005 "attrs": {"localId": ""},
6006 "content": [{
6007 "type": "taskItem",
6008 "attrs": {"localId": "", "state": "TODO"},
6009 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child"}]}]
6010 }]
6011 }
6012 ]
6013 }]
6014 }"#;
6015 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6016 let md1 = adf_to_markdown(&doc).unwrap();
6018 let rt1 = markdown_to_adf(&md1).unwrap();
6019 let md2 = adf_to_markdown(&rt1).unwrap();
6021 let rt2 = markdown_to_adf(&md2).unwrap();
6022 assert_eq!(md1, md2, "markdown should be stable across round-trips");
6024 let rt1_json = serde_json::to_string(&rt1).unwrap();
6026 let rt2_json = serde_json::to_string(&rt2).unwrap();
6027 assert_eq!(
6028 rt1_json, rt2_json,
6029 "ADF should be stable across round-trips"
6030 );
6031 }
6032
6033 #[test]
6038 fn task_item_mixed_sub_content_splits_siblings() {
6039 let md = "- [ ] Parent task\n - [ ] Sub task\n Some paragraph\n";
6040 let doc = markdown_to_adf(md).unwrap();
6041 let items = doc.content[0].content.as_ref().unwrap();
6042 assert_eq!(items.len(), 2, "got: {items:?}");
6044 assert_eq!(items[0].node_type, "taskItem");
6045 let parent_content = items[0].content.as_ref().unwrap();
6046 assert!(
6048 parent_content.iter().any(|n| n.node_type == "paragraph"),
6049 "non-taskList sub-content should stay as child: {parent_content:?}"
6050 );
6051 assert_eq!(items[1].node_type, "taskList");
6053 }
6054
6055 #[test]
6059 fn empty_task_item_mixed_sub_content_none_arm() {
6060 let md = "- [ ] \n Some paragraph\n - [ ] Sub task\n";
6061 let doc = markdown_to_adf(md).unwrap();
6062 let items = doc.content[0].content.as_ref().unwrap();
6063 assert_eq!(items.len(), 2, "got: {items:?}");
6065 assert_eq!(items[0].node_type, "taskItem");
6066 let parent_content = items[0].content.as_ref().unwrap();
6067 assert!(
6068 parent_content.iter().any(|n| n.node_type == "paragraph"),
6069 "paragraph should be assigned to taskItem: {parent_content:?}"
6070 );
6071 assert_eq!(items[1].node_type, "taskList");
6072 }
6073
6074 #[test]
6079 fn task_item_text_with_non_tasklist_sub_content_only() {
6080 let md = "- [ ] My task\n Extra paragraph content\n";
6081 let doc = markdown_to_adf(md).unwrap();
6082 let items = doc.content[0].content.as_ref().unwrap();
6083 assert_eq!(items.len(), 1, "got: {items:?}");
6085 assert_eq!(items[0].node_type, "taskItem");
6086 let content = items[0].content.as_ref().unwrap();
6087 assert!(
6089 content.iter().any(|n| n.node_type == "paragraph"),
6090 "paragraph sub-content should be a child of taskItem: {content:?}"
6091 );
6092 }
6093
6094 #[test]
6097 fn adf_list_item_leading_block_node() {
6098 let json = r#"{
6099 "version": 1,
6100 "type": "doc",
6101 "content": [{
6102 "type": "bulletList",
6103 "content": [{
6104 "type": "listItem",
6105 "content": [{
6106 "type": "codeBlock",
6107 "attrs": {"language": "rust"},
6108 "content": [{"type": "text", "text": "let x = 1;"}]
6109 }]
6110 }]
6111 }]
6112 }"#;
6113 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6114 let md = adf_to_markdown(&doc).unwrap();
6115 assert!(md.contains("```rust"), "got: {md}");
6116 assert!(md.contains("let x = 1;"), "got: {md}");
6117 for line in md.lines() {
6120 if line.starts_with("- ") {
6121 continue; }
6123 if line.trim().is_empty() {
6124 continue;
6125 }
6126 assert!(
6127 line.starts_with(" "),
6128 "continuation line not indented: {line:?}"
6129 );
6130 }
6131 }
6132
6133 #[test]
6136 fn code_block_in_list_item_backtick_roundtrip() {
6137 let json = r#"{
6138 "version": 1,
6139 "type": "doc",
6140 "content": [{
6141 "type": "bulletList",
6142 "content": [{
6143 "type": "listItem",
6144 "content": [{
6145 "type": "codeBlock",
6146 "attrs": {"language": ""},
6147 "content": [{"type": "text", "text": "error: some value with a backtick ` at end"}]
6148 }]
6149 }]
6150 }]
6151 }"#;
6152 let original: AdfDocument = serde_json::from_str(json).unwrap();
6153 let md = adf_to_markdown(&original).unwrap();
6154 let roundtripped = markdown_to_adf(&md).unwrap();
6155 let list = &roundtripped.content[0];
6156 assert_eq!(list.node_type, "bulletList", "top node: {}", list.node_type);
6157 let item = &list.content.as_ref().unwrap()[0];
6158 let first_child = &item.content.as_ref().unwrap()[0];
6159 assert_eq!(
6160 first_child.node_type, "codeBlock",
6161 "expected codeBlock, got: {}",
6162 first_child.node_type
6163 );
6164 let text = first_child.content.as_ref().unwrap()[0]
6165 .text
6166 .as_deref()
6167 .unwrap();
6168 assert_eq!(text, "error: some value with a backtick ` at end");
6169 }
6170
6171 #[test]
6173 fn code_block_with_language_in_list_item_roundtrip() {
6174 let json = r#"{
6175 "version": 1,
6176 "type": "doc",
6177 "content": [{
6178 "type": "bulletList",
6179 "content": [{
6180 "type": "listItem",
6181 "content": [{
6182 "type": "codeBlock",
6183 "attrs": {"language": "rust"},
6184 "content": [{"type": "text", "text": "fn main() {\n println!(\"hello\");\n}"}]
6185 }]
6186 }]
6187 }]
6188 }"#;
6189 let original: AdfDocument = serde_json::from_str(json).unwrap();
6190 let md = adf_to_markdown(&original).unwrap();
6191 let roundtripped = markdown_to_adf(&md).unwrap();
6192 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6193 let code = &item.content.as_ref().unwrap()[0];
6194 assert_eq!(code.node_type, "codeBlock");
6195 let lang = code
6196 .attrs
6197 .as_ref()
6198 .and_then(|a| a.get("language"))
6199 .and_then(serde_json::Value::as_str)
6200 .unwrap_or("");
6201 assert_eq!(lang, "rust");
6202 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6203 assert!(text.contains("println!"), "code content: {text}");
6204 }
6205
6206 #[test]
6208 fn code_block_in_ordered_list_item_roundtrip() {
6209 let json = r#"{
6210 "version": 1,
6211 "type": "doc",
6212 "content": [{
6213 "type": "orderedList",
6214 "attrs": {"order": 1},
6215 "content": [{
6216 "type": "listItem",
6217 "content": [{
6218 "type": "codeBlock",
6219 "attrs": {"language": ""},
6220 "content": [{"type": "text", "text": "backtick ` here"}]
6221 }]
6222 }]
6223 }]
6224 }"#;
6225 let original: AdfDocument = serde_json::from_str(json).unwrap();
6226 let md = adf_to_markdown(&original).unwrap();
6227 let roundtripped = markdown_to_adf(&md).unwrap();
6228 let list = &roundtripped.content[0];
6229 assert_eq!(list.node_type, "orderedList");
6230 let item = &list.content.as_ref().unwrap()[0];
6231 let code = &item.content.as_ref().unwrap()[0];
6232 assert_eq!(code.node_type, "codeBlock");
6233 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6234 assert_eq!(text, "backtick ` here");
6235 }
6236
6237 #[test]
6239 fn code_block_then_paragraph_in_list_item() {
6240 let json = r#"{
6241 "version": 1,
6242 "type": "doc",
6243 "content": [{
6244 "type": "bulletList",
6245 "content": [{
6246 "type": "listItem",
6247 "content": [
6248 {
6249 "type": "codeBlock",
6250 "attrs": {"language": ""},
6251 "content": [{"type": "text", "text": "code with ` backtick"}]
6252 },
6253 {
6254 "type": "paragraph",
6255 "content": [{"type": "text", "text": "description"}]
6256 }
6257 ]
6258 }]
6259 }]
6260 }"#;
6261 let original: AdfDocument = serde_json::from_str(json).unwrap();
6262 let md = adf_to_markdown(&original).unwrap();
6263 let roundtripped = markdown_to_adf(&md).unwrap();
6264 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6265 let children = item.content.as_ref().unwrap();
6266 assert_eq!(children[0].node_type, "codeBlock");
6267 assert_eq!(children[1].node_type, "paragraph");
6268 }
6269
6270 #[test]
6272 fn code_block_multiple_backticks_in_list_item() {
6273 let json = r#"{
6274 "version": 1,
6275 "type": "doc",
6276 "content": [{
6277 "type": "bulletList",
6278 "content": [{
6279 "type": "listItem",
6280 "content": [{
6281 "type": "codeBlock",
6282 "attrs": {"language": ""},
6283 "content": [{"type": "text", "text": "a ` b `` c ``` d"}]
6284 }]
6285 }]
6286 }]
6287 }"#;
6288 let original: AdfDocument = serde_json::from_str(json).unwrap();
6289 let md = adf_to_markdown(&original).unwrap();
6290 let roundtripped = markdown_to_adf(&md).unwrap();
6291 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6292 let code = &item.content.as_ref().unwrap()[0];
6293 assert_eq!(code.node_type, "codeBlock");
6294 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6295 assert_eq!(text, "a ` b `` c ``` d");
6296 }
6297
6298 #[test]
6301 fn media_first_child_with_sub_content_in_list_item() {
6302 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
6303 {"type":"listItem","content":[
6304 {"type":"mediaSingle","attrs":{"layout":"center"},
6305 "content":[{"type":"media","attrs":{"type":"file","id":"img-99","collection":"col-x","height":50,"width":100}}]},
6306 {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
6307 ]}
6308 ]}]}"#;
6309 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6310 let md = adf_to_markdown(&doc).unwrap();
6311 let rt = markdown_to_adf(&md).unwrap();
6312 let item = &rt.content[0].content.as_ref().unwrap()[0];
6313 let children = item.content.as_ref().unwrap();
6314 assert_eq!(
6315 children.len(),
6316 2,
6317 "expected 2 children, got {}",
6318 children.len()
6319 );
6320 assert_eq!(children[0].node_type, "mediaSingle");
6321 let media = &children[0].content.as_ref().unwrap()[0];
6322 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-99");
6323 assert_eq!(children[1].node_type, "paragraph");
6324 }
6325
6326 #[test]
6327 fn inline_bold() {
6328 let doc = markdown_to_adf("Some **bold** text").unwrap();
6329 let content = doc.content[0].content.as_ref().unwrap();
6330 assert!(content.len() >= 3);
6331 let bold_node = &content[1];
6332 assert_eq!(bold_node.text.as_deref(), Some("bold"));
6333 let marks = bold_node.marks.as_ref().unwrap();
6334 assert_eq!(marks[0].mark_type, "strong");
6335 }
6336
6337 #[test]
6338 fn inline_italic() {
6339 let doc = markdown_to_adf("Some *italic* text").unwrap();
6340 let content = doc.content[0].content.as_ref().unwrap();
6341 let italic_node = &content[1];
6342 assert_eq!(italic_node.text.as_deref(), Some("italic"));
6343 let marks = italic_node.marks.as_ref().unwrap();
6344 assert_eq!(marks[0].mark_type, "em");
6345 }
6346
6347 #[test]
6348 fn inline_code() {
6349 let doc = markdown_to_adf("Use `code` here").unwrap();
6350 let content = doc.content[0].content.as_ref().unwrap();
6351 let code_node = &content[1];
6352 assert_eq!(code_node.text.as_deref(), Some("code"));
6353 let marks = code_node.marks.as_ref().unwrap();
6354 assert_eq!(marks[0].mark_type, "code");
6355 }
6356
6357 #[test]
6361 fn inline_code_with_backtick_emitted_with_double_delimiters() {
6362 let doc = AdfDocument {
6363 version: 1,
6364 doc_type: "doc".to_string(),
6365 content: vec![AdfNode::paragraph(vec![
6366 AdfNode::text("Run "),
6367 AdfNode::text_with_marks(
6368 "ADD `custom_threshold` TEXT NOT NULL",
6369 vec![AdfMark::code()],
6370 ),
6371 AdfNode::text(" to update the schema."),
6372 ])],
6373 };
6374 let md = adf_to_markdown(&doc).unwrap();
6375 assert!(
6376 md.contains("``ADD `custom_threshold` TEXT NOT NULL``"),
6377 "expected double-backtick delimiters, got: {md}"
6378 );
6379 }
6380
6381 #[test]
6384 fn inline_code_double_backtick_delimiters_parse() {
6385 let doc = markdown_to_adf("Run ``ADD `custom_threshold` TEXT NOT NULL`` now").unwrap();
6386 let content = doc.content[0].content.as_ref().unwrap();
6387 assert_eq!(content.len(), 3, "content: {content:?}");
6388 let code_node = &content[1];
6389 assert_eq!(
6390 code_node.text.as_deref(),
6391 Some("ADD `custom_threshold` TEXT NOT NULL")
6392 );
6393 let marks = code_node.marks.as_ref().unwrap();
6394 assert_eq!(marks[0].mark_type, "code");
6395 }
6396
6397 #[test]
6400 fn inline_code_with_backtick_roundtrip() {
6401 let json = r#"{
6402 "version": 1,
6403 "type": "doc",
6404 "content": [{
6405 "type": "paragraph",
6406 "content": [
6407 {"type": "text", "text": "Run "},
6408 {
6409 "type": "text",
6410 "text": "ADD `custom_threshold` TEXT NOT NULL",
6411 "marks": [{"type": "code"}]
6412 },
6413 {"type": "text", "text": " to update the schema."}
6414 ]
6415 }]
6416 }"#;
6417 let original: AdfDocument = serde_json::from_str(json).unwrap();
6418 let md = adf_to_markdown(&original).unwrap();
6419 let roundtripped = markdown_to_adf(&md).unwrap();
6420 let para = &roundtripped.content[0];
6421 let children = para.content.as_ref().unwrap();
6422 assert_eq!(children.len(), 3, "expected 3 children, got: {children:?}");
6423 assert_eq!(children[0].text.as_deref(), Some("Run "));
6424 assert_eq!(
6425 children[1].text.as_deref(),
6426 Some("ADD `custom_threshold` TEXT NOT NULL")
6427 );
6428 let marks = children[1].marks.as_ref().unwrap();
6429 assert_eq!(marks.len(), 1);
6430 assert_eq!(marks[0].mark_type, "code");
6431 assert_eq!(children[2].text.as_deref(), Some(" to update the schema."));
6432 }
6433
6434 #[test]
6439 fn inline_code_with_double_backtick_roundtrip() {
6440 let doc = AdfDocument {
6441 version: 1,
6442 doc_type: "doc".to_string(),
6443 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6444 "x `` y",
6445 vec![AdfMark::code()],
6446 )])],
6447 };
6448 let md = adf_to_markdown(&doc).unwrap();
6449 let roundtripped = markdown_to_adf(&md).unwrap();
6450 let content = roundtripped.content[0].content.as_ref().unwrap();
6451 assert_eq!(content.len(), 1);
6452 assert_eq!(content[0].text.as_deref(), Some("x `` y"));
6453 let marks = content[0].marks.as_ref().unwrap();
6454 assert_eq!(marks[0].mark_type, "code");
6455 }
6456
6457 #[test]
6460 fn inline_code_leading_backtick_roundtrip() {
6461 let doc = AdfDocument {
6462 version: 1,
6463 doc_type: "doc".to_string(),
6464 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6465 "`start",
6466 vec![AdfMark::code()],
6467 )])],
6468 };
6469 let md = adf_to_markdown(&doc).unwrap();
6470 let roundtripped = markdown_to_adf(&md).unwrap();
6471 let content = roundtripped.content[0].content.as_ref().unwrap();
6472 assert_eq!(content[0].text.as_deref(), Some("`start"));
6473 assert_eq!(content[0].marks.as_ref().unwrap()[0].mark_type, "code");
6474 }
6475
6476 #[test]
6478 fn inline_code_trailing_backtick_roundtrip() {
6479 let doc = AdfDocument {
6480 version: 1,
6481 doc_type: "doc".to_string(),
6482 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6483 "end`",
6484 vec![AdfMark::code()],
6485 )])],
6486 };
6487 let md = adf_to_markdown(&doc).unwrap();
6488 let roundtripped = markdown_to_adf(&md).unwrap();
6489 let content = roundtripped.content[0].content.as_ref().unwrap();
6490 assert_eq!(content[0].text.as_deref(), Some("end`"));
6491 }
6492
6493 #[test]
6496 fn inline_code_space_padded_content_roundtrip() {
6497 let doc = AdfDocument {
6498 version: 1,
6499 doc_type: "doc".to_string(),
6500 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6501 " foo ",
6502 vec![AdfMark::code()],
6503 )])],
6504 };
6505 let md = adf_to_markdown(&doc).unwrap();
6506 let roundtripped = markdown_to_adf(&md).unwrap();
6507 let content = roundtripped.content[0].content.as_ref().unwrap();
6508 assert_eq!(content[0].text.as_deref(), Some(" foo "));
6509 }
6510
6511 #[test]
6514 fn inline_code_all_spaces_roundtrip() {
6515 let doc = AdfDocument {
6516 version: 1,
6517 doc_type: "doc".to_string(),
6518 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6519 " ",
6520 vec![AdfMark::code()],
6521 )])],
6522 };
6523 let md = adf_to_markdown(&doc).unwrap();
6524 let roundtripped = markdown_to_adf(&md).unwrap();
6525 let content = roundtripped.content[0].content.as_ref().unwrap();
6526 assert_eq!(content[0].text.as_deref(), Some(" "));
6527 }
6528
6529 #[test]
6532 fn inline_code_with_link_and_backtick_roundtrip() {
6533 let doc = AdfDocument {
6534 version: 1,
6535 doc_type: "doc".to_string(),
6536 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6537 "fn `inner`",
6538 vec![AdfMark::code(), AdfMark::link("https://example.com")],
6539 )])],
6540 };
6541 let md = adf_to_markdown(&doc).unwrap();
6542 assert!(
6543 md.contains("`` fn `inner` ``"),
6544 "expected padded double-backtick delimiters inside link, got: {md}"
6545 );
6546 let roundtripped = markdown_to_adf(&md).unwrap();
6547 let content = roundtripped.content[0].content.as_ref().unwrap();
6548 assert_eq!(content[0].text.as_deref(), Some("fn `inner`"));
6549 let mark_types: Vec<&str> = content[0]
6550 .marks
6551 .as_ref()
6552 .unwrap()
6553 .iter()
6554 .map(|m| m.mark_type.as_str())
6555 .collect();
6556 assert!(mark_types.contains(&"code"));
6557 assert!(mark_types.contains(&"link"));
6558 }
6559
6560 #[test]
6562 fn inline_code_unmatched_run_is_plain_text() {
6563 let doc = markdown_to_adf("foo ``bar baz").unwrap();
6564 let content = doc.content[0].content.as_ref().unwrap();
6565 assert_eq!(content.len(), 1);
6566 assert_eq!(content[0].text.as_deref(), Some("foo ``bar baz"));
6567 assert!(content[0].marks.is_none());
6568 }
6569
6570 #[test]
6574 fn inline_code_mismatched_delimiters_is_plain_text() {
6575 let doc = markdown_to_adf("``foo` bar").unwrap();
6576 let content = doc.content[0].content.as_ref().unwrap();
6577 assert_eq!(content.len(), 1);
6578 assert_eq!(content[0].text.as_deref(), Some("``foo` bar"));
6579 assert!(content[0].marks.is_none());
6580 }
6581
6582 #[test]
6583 fn inline_code_delimiter_chooses_correct_length() {
6584 assert_eq!(inline_code_delimiter("no ticks"), (1, false));
6585 assert_eq!(inline_code_delimiter("one ` here"), (2, false));
6586 assert_eq!(inline_code_delimiter("two `` here"), (3, false));
6587 assert_eq!(inline_code_delimiter("three ``` here"), (4, false));
6588 assert_eq!(inline_code_delimiter("`leading"), (2, true));
6589 assert_eq!(inline_code_delimiter("trailing`"), (2, true));
6590 assert_eq!(inline_code_delimiter(" foo "), (1, true));
6591 assert_eq!(inline_code_delimiter(" "), (1, false));
6592 assert_eq!(inline_code_delimiter(" "), (1, false));
6593 assert_eq!(inline_code_delimiter(" foo"), (1, false));
6594 }
6595
6596 #[test]
6597 fn try_parse_inline_code_strips_paired_spaces() {
6598 let (end, content) = try_parse_inline_code("`` `foo` ``", 0).unwrap();
6599 assert_eq!(end, 11);
6600 assert_eq!(content, "`foo`");
6601 }
6602
6603 #[test]
6604 fn try_parse_inline_code_all_space_content_is_preserved() {
6605 let (_end, content) = try_parse_inline_code("` `", 0).unwrap();
6606 assert_eq!(content, " ");
6607 }
6608
6609 #[test]
6610 fn try_parse_inline_code_single_run_matches_first_close() {
6611 let (end, content) = try_parse_inline_code("`foo` tail", 0).unwrap();
6612 assert_eq!(end, 5);
6613 assert_eq!(content, "foo");
6614 }
6615
6616 #[test]
6617 fn try_parse_inline_code_no_match_returns_none() {
6618 assert!(try_parse_inline_code("``unmatched", 0).is_none());
6619 assert!(try_parse_inline_code("plain text", 0).is_none());
6620 }
6621
6622 #[test]
6623 fn is_code_fence_opener_rejects_info_with_backtick() {
6624 assert!(is_code_fence_opener("```"));
6625 assert!(is_code_fence_opener("```rust"));
6626 assert!(is_code_fence_opener("```\"\""));
6627 assert!(!is_code_fence_opener("```x `` y```"));
6628 assert!(!is_code_fence_opener("``not-enough"));
6629 assert!(!is_code_fence_opener("no fence"));
6630 }
6631
6632 #[test]
6633 fn inline_strikethrough() {
6634 let doc = markdown_to_adf("Some ~~deleted~~ text").unwrap();
6635 let content = doc.content[0].content.as_ref().unwrap();
6636 let strike_node = &content[1];
6637 assert_eq!(strike_node.text.as_deref(), Some("deleted"));
6638 let marks = strike_node.marks.as_ref().unwrap();
6639 assert_eq!(marks[0].mark_type, "strike");
6640 }
6641
6642 #[test]
6643 fn inline_link() {
6644 let doc = markdown_to_adf("Click [here](https://example.com) now").unwrap();
6645 let content = doc.content[0].content.as_ref().unwrap();
6646 let link_node = &content[1];
6647 assert_eq!(link_node.text.as_deref(), Some("here"));
6648 let marks = link_node.marks.as_ref().unwrap();
6649 assert_eq!(marks[0].mark_type, "link");
6650 }
6651
6652 #[test]
6653 fn block_image() {
6654 let md = "";
6655 let doc = markdown_to_adf(md).unwrap();
6656 assert_eq!(doc.content[0].node_type, "mediaSingle");
6657 }
6658
6659 #[test]
6660 fn table() {
6661 let md = "| A | B |\n| --- | --- |\n| 1 | 2 |";
6662 let doc = markdown_to_adf(md).unwrap();
6663 assert_eq!(doc.content[0].node_type, "table");
6664 let rows = doc.content[0].content.as_ref().unwrap();
6665 assert_eq!(rows.len(), 2); }
6667
6668 #[test]
6671 fn adf_paragraph_to_markdown() {
6672 let doc = AdfDocument {
6673 version: 1,
6674 doc_type: "doc".to_string(),
6675 content: vec![AdfNode::paragraph(vec![AdfNode::text("Hello world")])],
6676 };
6677 let md = adf_to_markdown(&doc).unwrap();
6678 assert_eq!(md.trim(), "Hello world");
6679 }
6680
6681 #[test]
6682 fn adf_heading_to_markdown() {
6683 let doc = AdfDocument {
6684 version: 1,
6685 doc_type: "doc".to_string(),
6686 content: vec![AdfNode::heading(2, vec![AdfNode::text("Title")])],
6687 };
6688 let md = adf_to_markdown(&doc).unwrap();
6689 assert_eq!(md.trim(), "## Title");
6690 }
6691
6692 #[test]
6693 fn adf_bold_to_markdown() {
6694 let doc = AdfDocument {
6695 version: 1,
6696 doc_type: "doc".to_string(),
6697 content: vec![AdfNode::paragraph(vec![
6698 AdfNode::text("Normal "),
6699 AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
6700 AdfNode::text(" text"),
6701 ])],
6702 };
6703 let md = adf_to_markdown(&doc).unwrap();
6704 assert_eq!(md.trim(), "Normal **bold** text");
6705 }
6706
6707 #[test]
6708 fn adf_code_block_to_markdown() {
6709 let doc = AdfDocument {
6710 version: 1,
6711 doc_type: "doc".to_string(),
6712 content: vec![AdfNode::code_block(Some("rust"), "let x = 1;")],
6713 };
6714 let md = adf_to_markdown(&doc).unwrap();
6715 assert!(md.contains("```rust"));
6716 assert!(md.contains("let x = 1;"));
6717 assert!(md.contains("```"));
6718 }
6719
6720 #[test]
6721 fn adf_rule_to_markdown() {
6722 let doc = AdfDocument {
6723 version: 1,
6724 doc_type: "doc".to_string(),
6725 content: vec![AdfNode::rule()],
6726 };
6727 let md = adf_to_markdown(&doc).unwrap();
6728 assert!(md.contains("---"));
6729 }
6730
6731 #[test]
6732 fn adf_bullet_list_to_markdown() {
6733 let doc = AdfDocument {
6734 version: 1,
6735 doc_type: "doc".to_string(),
6736 content: vec![AdfNode::bullet_list(vec![
6737 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("A")])]),
6738 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("B")])]),
6739 ])],
6740 };
6741 let md = adf_to_markdown(&doc).unwrap();
6742 assert!(md.contains("- A"));
6743 assert!(md.contains("- B"));
6744 }
6745
6746 #[test]
6747 fn adf_link_to_markdown() {
6748 let doc = AdfDocument {
6749 version: 1,
6750 doc_type: "doc".to_string(),
6751 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6752 "click",
6753 vec![AdfMark::link("https://example.com")],
6754 )])],
6755 };
6756 let md = adf_to_markdown(&doc).unwrap();
6757 assert_eq!(md.trim(), "[click](https://example.com)");
6758 }
6759
6760 #[test]
6761 fn unsupported_block_preserved_as_json() {
6762 let doc = AdfDocument {
6763 version: 1,
6764 doc_type: "doc".to_string(),
6765 content: vec![AdfNode {
6766 node_type: "unknownBlock".to_string(),
6767 attrs: Some(serde_json::json!({"key": "value"})),
6768 content: None,
6769 text: None,
6770 marks: None,
6771 local_id: None,
6772 parameters: None,
6773 }],
6774 };
6775 let md = adf_to_markdown(&doc).unwrap();
6776 assert!(md.contains("```adf-unsupported"));
6777 assert!(md.contains("\"unknownBlock\""));
6778 }
6779
6780 #[test]
6781 fn unsupported_block_round_trips() {
6782 let original = AdfDocument {
6783 version: 1,
6784 doc_type: "doc".to_string(),
6785 content: vec![AdfNode {
6786 node_type: "unknownBlock".to_string(),
6787 attrs: Some(serde_json::json!({"key": "value"})),
6788 content: None,
6789 text: None,
6790 marks: None,
6791 local_id: None,
6792 parameters: None,
6793 }],
6794 };
6795 let md = adf_to_markdown(&original).unwrap();
6796 let restored = markdown_to_adf(&md).unwrap();
6797 assert_eq!(restored.content[0].node_type, "unknownBlock");
6798 assert_eq!(restored.content[0].attrs.as_ref().unwrap()["key"], "value");
6799 }
6800
6801 #[test]
6804 fn round_trip_simple_document() {
6805 let md = "# Hello\n\nSome text with **bold** and *italic*.\n\n- Item 1\n- Item 2\n";
6806 let adf = markdown_to_adf(md).unwrap();
6807 let restored = adf_to_markdown(&adf).unwrap();
6808
6809 assert!(restored.contains("# Hello"));
6810 assert!(restored.contains("**bold**"));
6811 assert!(restored.contains("*italic*"));
6812 assert!(restored.contains("- Item 1"));
6813 assert!(restored.contains("- Item 2"));
6814 }
6815
6816 #[test]
6817 fn round_trip_code_block() {
6818 let md = "```python\nprint('hello')\n```\n";
6819 let adf = markdown_to_adf(md).unwrap();
6820 let restored = adf_to_markdown(&adf).unwrap();
6821
6822 assert!(restored.contains("```python"));
6823 assert!(restored.contains("print('hello')"));
6824 }
6825
6826 #[test]
6827 fn round_trip_code_block_no_attrs() {
6828 let adf_json = r#"{"version":1,"type":"doc","content":[
6829 {"type":"codeBlock","content":[{"type":"text","text":"plain code"}]}
6830 ]}"#;
6831 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6832 assert!(doc.content[0].attrs.is_none());
6833 let md = adf_to_markdown(&doc).unwrap();
6834 let round_tripped = markdown_to_adf(&md).unwrap();
6835 assert!(round_tripped.content[0].attrs.is_none());
6836 }
6837
6838 #[test]
6839 fn round_trip_code_block_empty_language() {
6840 let adf_json = r#"{"version":1,"type":"doc","content":[
6841 {"type":"codeBlock","attrs":{"language":""},"content":[{"type":"text","text":"simple code block no backtick"}]}
6842 ]}"#;
6843 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6844 let attrs = doc.content[0].attrs.as_ref().unwrap();
6845 assert_eq!(attrs["language"], "");
6846 let md = adf_to_markdown(&doc).unwrap();
6847 let round_tripped = markdown_to_adf(&md).unwrap();
6848 let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
6849 assert_eq!(rt_attrs["language"], "");
6850 }
6851
6852 #[test]
6853 fn round_trip_code_block_with_language() {
6854 let adf_json = r#"{"version":1,"type":"doc","content":[
6855 {"type":"codeBlock","attrs":{"language":"python"},"content":[{"type":"text","text":"print('hi')"}]}
6856 ]}"#;
6857 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6858 let md = adf_to_markdown(&doc).unwrap();
6859 let round_tripped = markdown_to_adf(&md).unwrap();
6860 let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
6861 assert_eq!(rt_attrs["language"], "python");
6862 }
6863
6864 #[test]
6865 fn multiple_paragraphs() {
6866 let md = "First paragraph.\n\nSecond paragraph.\n";
6867 let adf = markdown_to_adf(md).unwrap();
6868 assert_eq!(adf.content.len(), 2);
6869 assert_eq!(adf.content[0].node_type, "paragraph");
6870 assert_eq!(adf.content[1].node_type, "paragraph");
6871 }
6872
6873 #[test]
6876 fn horizontal_rule_underscores() {
6877 let doc = markdown_to_adf("___").unwrap();
6878 assert_eq!(doc.content[0].node_type, "rule");
6879 }
6880
6881 #[test]
6882 fn not_a_horizontal_rule_too_short() {
6883 let doc = markdown_to_adf("--").unwrap();
6884 assert_eq!(doc.content[0].node_type, "paragraph");
6885 }
6886
6887 #[test]
6888 fn bullet_list_star_marker() {
6889 let md = "* Apple\n* Banana";
6890 let doc = markdown_to_adf(md).unwrap();
6891 assert_eq!(doc.content[0].node_type, "bulletList");
6892 let items = doc.content[0].content.as_ref().unwrap();
6893 assert_eq!(items.len(), 2);
6894 }
6895
6896 #[test]
6897 fn bullet_list_plus_marker() {
6898 let md = "+ One\n+ Two";
6899 let doc = markdown_to_adf(md).unwrap();
6900 assert_eq!(doc.content[0].node_type, "bulletList");
6901 }
6902
6903 #[test]
6904 fn ordered_list_non_one_start() {
6905 let md = "5. Fifth\n6. Sixth";
6906 let doc = markdown_to_adf(md).unwrap();
6907 let node = &doc.content[0];
6908 assert_eq!(node.node_type, "orderedList");
6909 let attrs = node.attrs.as_ref().unwrap();
6910 assert_eq!(attrs["order"], 5);
6911 }
6912
6913 #[test]
6914 fn ordered_list_start_at_one_omits_order_attr() {
6915 let md = "1. First\n2. Second";
6919 let doc = markdown_to_adf(md).unwrap();
6920 let node = &doc.content[0];
6921 assert_eq!(node.node_type, "orderedList");
6922 assert!(
6923 node.attrs.is_none(),
6924 "attrs should be omitted when order=1, got: {:?}",
6925 node.attrs
6926 );
6927 }
6928
6929 #[test]
6930 fn blockquote_bare_marker() {
6931 let md = ">quoted text";
6933 let doc = markdown_to_adf(md).unwrap();
6934 assert_eq!(doc.content[0].node_type, "blockquote");
6935 }
6936
6937 #[test]
6938 fn image_no_alt() {
6939 let md = "";
6940 let doc = markdown_to_adf(md).unwrap();
6941 let node = &doc.content[0];
6942 assert_eq!(node.node_type, "mediaSingle");
6943 let media = &node.content.as_ref().unwrap()[0];
6945 let attrs = media.attrs.as_ref().unwrap();
6946 assert!(attrs.get("alt").is_none());
6947 }
6948
6949 #[test]
6950 fn image_with_alt() {
6951 let md = "";
6952 let doc = markdown_to_adf(md).unwrap();
6953 let media = &doc.content[0].content.as_ref().unwrap()[0];
6954 let attrs = media.attrs.as_ref().unwrap();
6955 assert_eq!(attrs["alt"], "A photo");
6956 }
6957
6958 #[test]
6959 fn table_multi_body_rows() {
6960 let md = "| H1 | H2 |\n| --- | --- |\n| a | b |\n| c | d |";
6961 let doc = markdown_to_adf(md).unwrap();
6962 let rows = doc.content[0].content.as_ref().unwrap();
6963 assert_eq!(rows.len(), 3); let header_cells = rows[0].content.as_ref().unwrap();
6966 assert_eq!(header_cells[0].node_type, "tableHeader");
6967 let body_cells = rows[1].content.as_ref().unwrap();
6969 assert_eq!(body_cells[0].node_type, "tableCell");
6970 }
6971
6972 #[test]
6973 fn table_no_separator_is_not_table() {
6974 let md = "| not | a table |";
6976 let doc = markdown_to_adf(md).unwrap();
6977 assert_eq!(doc.content[0].node_type, "paragraph");
6978 }
6979
6980 #[test]
6981 fn inline_underscore_bold() {
6982 let doc = markdown_to_adf("Some __bold__ text").unwrap();
6983 let content = doc.content[0].content.as_ref().unwrap();
6984 let bold_node = &content[1];
6985 assert_eq!(bold_node.text.as_deref(), Some("bold"));
6986 let marks = bold_node.marks.as_ref().unwrap();
6987 assert_eq!(marks[0].mark_type, "strong");
6988 }
6989
6990 #[test]
6991 fn inline_underscore_italic() {
6992 let doc = markdown_to_adf("Some _italic_ text").unwrap();
6993 let content = doc.content[0].content.as_ref().unwrap();
6994 let italic_node = &content[1];
6995 assert_eq!(italic_node.text.as_deref(), Some("italic"));
6996 let marks = italic_node.marks.as_ref().unwrap();
6997 assert_eq!(marks[0].mark_type, "em");
6998 }
6999
7000 #[test]
7001 fn intraword_underscore_not_emphasis() {
7002 let doc = markdown_to_adf("call do_something_useful now").unwrap();
7004 let content = doc.content[0].content.as_ref().unwrap();
7005 assert_eq!(content.len(), 1, "should be a single text node");
7006 assert_eq!(
7007 content[0].text.as_deref(),
7008 Some("call do_something_useful now")
7009 );
7010 assert!(content[0].marks.is_none());
7011 }
7012
7013 #[test]
7014 fn intraword_underscore_multiple() {
7015 let doc = markdown_to_adf("use a_b_c_d here").unwrap();
7017 let content = doc.content[0].content.as_ref().unwrap();
7018 assert_eq!(content.len(), 1);
7019 assert_eq!(content[0].text.as_deref(), Some("use a_b_c_d here"));
7020 assert!(content[0].marks.is_none());
7021 }
7022
7023 #[test]
7024 fn intraword_double_underscore_not_bold() {
7025 let doc = markdown_to_adf("foo__bar__baz").unwrap();
7027 let content = doc.content[0].content.as_ref().unwrap();
7028 assert_eq!(content.len(), 1);
7029 assert_eq!(content[0].text.as_deref(), Some("foo__bar__baz"));
7030 assert!(content[0].marks.is_none());
7031 }
7032
7033 #[test]
7034 fn intraword_triple_underscore_not_bold_italic() {
7035 let doc = markdown_to_adf("x___y___z").unwrap();
7037 let content = doc.content[0].content.as_ref().unwrap();
7038 assert_eq!(content.len(), 1);
7039 assert_eq!(content[0].text.as_deref(), Some("x___y___z"));
7040 assert!(content[0].marks.is_none());
7041 }
7042
7043 #[test]
7044 fn underscore_emphasis_still_works_with_spaces() {
7045 let doc = markdown_to_adf("some _italic_ here").unwrap();
7047 let content = doc.content[0].content.as_ref().unwrap();
7048 assert_eq!(content.len(), 3);
7049 assert_eq!(content[1].text.as_deref(), Some("italic"));
7050 let marks = content[1].marks.as_ref().unwrap();
7051 assert_eq!(marks[0].mark_type, "em");
7052 }
7053
7054 #[test]
7055 fn underscore_bold_still_works_with_spaces() {
7056 let doc = markdown_to_adf("some __bold__ here").unwrap();
7058 let content = doc.content[0].content.as_ref().unwrap();
7059 assert_eq!(content.len(), 3);
7060 assert_eq!(content[1].text.as_deref(), Some("bold"));
7061 let marks = content[1].marks.as_ref().unwrap();
7062 assert_eq!(marks[0].mark_type, "strong");
7063 }
7064
7065 #[test]
7066 fn intraword_underscore_closing_only() {
7067 let doc = markdown_to_adf("_foo_bar").unwrap();
7069 let content = doc.content[0].content.as_ref().unwrap();
7070 assert_eq!(content.len(), 1);
7071 assert_eq!(content[0].text.as_deref(), Some("_foo_bar"));
7072 }
7073
7074 #[test]
7075 fn intraword_double_underscore_closing_only() {
7076 let doc = markdown_to_adf("__foo__bar").unwrap();
7078 let content = doc.content[0].content.as_ref().unwrap();
7079 assert_eq!(content.len(), 1);
7080 assert_eq!(content[0].text.as_deref(), Some("__foo__bar"));
7081 }
7082
7083 #[test]
7084 fn intraword_triple_underscore_closing_only() {
7085 let doc = markdown_to_adf("___foo___bar").unwrap();
7087 let content = doc.content[0].content.as_ref().unwrap();
7088 assert_eq!(content.len(), 1);
7089 assert_eq!(content[0].text.as_deref(), Some("___foo___bar"));
7090 }
7091
7092 #[test]
7093 fn asterisk_emphasis_unaffected_by_intraword_fix() {
7094 let doc = markdown_to_adf("foo*bar*baz").unwrap();
7096 let content = doc.content[0].content.as_ref().unwrap();
7097 assert!(content.len() > 1 || content[0].marks.is_some());
7099 }
7100
7101 #[test]
7102 fn intraword_underscore_at_start_of_text() {
7103 let doc = markdown_to_adf("_italic_ word").unwrap();
7105 let content = doc.content[0].content.as_ref().unwrap();
7106 assert_eq!(content[0].text.as_deref(), Some("italic"));
7107 let marks = content[0].marks.as_ref().unwrap();
7108 assert_eq!(marks[0].mark_type, "em");
7109 }
7110
7111 #[test]
7112 fn intraword_underscore_at_end_of_text() {
7113 let doc = markdown_to_adf("word _italic_").unwrap();
7115 let content = doc.content[0].content.as_ref().unwrap();
7116 let last = content.last().unwrap();
7117 assert_eq!(last.text.as_deref(), Some("italic"));
7118 let marks = last.marks.as_ref().unwrap();
7119 assert_eq!(marks[0].mark_type, "em");
7120 }
7121
7122 #[test]
7123 fn intraword_underscore_opening_only() {
7124 let doc = markdown_to_adf("a_b c_d").unwrap();
7127 let content = doc.content[0].content.as_ref().unwrap();
7128 assert_eq!(content.len(), 1);
7129 assert_eq!(content[0].text.as_deref(), Some("a_b c_d"));
7130 }
7131
7132 #[test]
7133 fn intraword_underscore_roundtrip() {
7134 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call the do_something_useful function"}]}]}"#;
7136 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7137 let jfm = adf_to_markdown(&adf).unwrap();
7138 let roundtripped = markdown_to_adf(&jfm).unwrap();
7139 let content = roundtripped.content[0].content.as_ref().unwrap();
7140 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7141 assert_eq!(
7142 content[0].text.as_deref(),
7143 Some("call the do_something_useful function")
7144 );
7145 assert!(content[0].marks.is_none());
7146 }
7147
7148 #[test]
7149 fn asterisk_emphasis_roundtrip() {
7150 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status: *confirmed* and active"}]}]}"#;
7152 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7153 let jfm = adf_to_markdown(&adf).unwrap();
7154 let roundtripped = markdown_to_adf(&jfm).unwrap();
7155 let content = roundtripped.content[0].content.as_ref().unwrap();
7156 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7157 assert_eq!(
7158 content[0].text.as_deref(),
7159 Some("Status: *confirmed* and active")
7160 );
7161 assert!(content[0].marks.is_none());
7162 }
7163
7164 #[test]
7165 fn double_asterisk_roundtrip() {
7166 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use **kwargs in Python"}]}]}"#;
7168 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7169 let jfm = adf_to_markdown(&adf).unwrap();
7170 let roundtripped = markdown_to_adf(&jfm).unwrap();
7171 let content = roundtripped.content[0].content.as_ref().unwrap();
7172 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7173 assert_eq!(content[0].text.as_deref(), Some("Use **kwargs in Python"));
7174 assert!(content[0].marks.is_none());
7175 }
7176
7177 #[test]
7178 fn asterisk_with_em_mark_roundtrip() {
7179 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a*b","marks":[{"type":"em"}]}]}]}"#;
7181 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7182 let jfm = adf_to_markdown(&adf).unwrap();
7183 let roundtripped = markdown_to_adf(&jfm).unwrap();
7184 let content = roundtripped.content[0].content.as_ref().unwrap();
7185 let em_node = content.iter().find(|n| {
7187 n.marks
7188 .as_ref()
7189 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7190 });
7191 assert!(em_node.is_some(), "should have an em-marked node");
7192 assert_eq!(em_node.unwrap().text.as_deref(), Some("a*b"));
7193 }
7194
7195 #[test]
7196 fn lone_asterisk_roundtrip() {
7197 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"rating: 5 * stars"}]}]}"#;
7199 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7200 let jfm = adf_to_markdown(&adf).unwrap();
7201 let roundtripped = markdown_to_adf(&jfm).unwrap();
7202 let content = roundtripped.content[0].content.as_ref().unwrap();
7203 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7204 assert_eq!(content[0].text.as_deref(), Some("rating: 5 * stars"));
7205 }
7206
7207 #[test]
7208 fn escape_emphasis_markers_unit() {
7209 assert_eq!(escape_emphasis_markers("hello"), "hello");
7210 assert_eq!(escape_emphasis_markers("*bold*"), r"\*bold\*");
7211 assert_eq!(escape_emphasis_markers("**strong**"), r"\*\*strong\*\*");
7212 assert_eq!(escape_emphasis_markers("no stars"), "no stars");
7213 assert_eq!(escape_emphasis_markers("a * b"), r"a \* b");
7214 assert_eq!(escape_emphasis_markers(""), "");
7215 }
7216
7217 #[test]
7218 fn escape_emphasis_markers_underscore_intraword() {
7219 assert_eq!(escape_emphasis_markers("foo_bar"), "foo_bar");
7222 assert_eq!(escape_emphasis_markers("a_b_c"), "a_b_c");
7223 assert_eq!(escape_emphasis_markers("foo__bar"), "foo__bar");
7224 assert_eq!(
7225 escape_emphasis_markers("call do_something_useful"),
7226 "call do_something_useful"
7227 );
7228 }
7229
7230 #[test]
7231 fn escape_emphasis_markers_underscore_at_boundary() {
7232 assert_eq!(escape_emphasis_markers("_Action"), r"\_Action");
7235 assert_eq!(escape_emphasis_markers("Action_"), r"Action\_");
7236 assert_eq!(escape_emphasis_markers("_ "), r"\_ ");
7237 assert_eq!(escape_emphasis_markers(" _"), r" \_");
7238 assert_eq!(escape_emphasis_markers("_"), r"\_");
7239 }
7240
7241 #[test]
7242 fn escape_emphasis_markers_underscore_with_punctuation() {
7243 assert_eq!(escape_emphasis_markers("foo _bar"), r"foo \_bar");
7245 assert_eq!(escape_emphasis_markers("foo_ bar"), r"foo\_ bar");
7246 assert_eq!(escape_emphasis_markers("(_x_)"), r"(\_x\_)");
7247 }
7248
7249 #[test]
7250 fn find_unescaped_skips_backslash_escaped() {
7251 assert_eq!(find_unescaped(r"a\*\*b**", "**"), Some(6));
7253 assert_eq!(find_unescaped(r"a\*\*b", "**"), None);
7255 assert_eq!(find_unescaped("a**b", "**"), Some(1));
7257 assert_eq!(find_unescaped("", "**"), None);
7259 }
7260
7261 #[test]
7262 fn find_unescaped_char_skips_backslash_escaped() {
7263 assert_eq!(find_unescaped_char(r"a\*b*", b'*'), Some(4));
7265 assert_eq!(find_unescaped_char(r"\*", b'*'), None);
7267 assert_eq!(find_unescaped_char("a*b", b'*'), Some(1));
7269 assert_eq!(find_unescaped_char("", b'*'), None);
7271 }
7272
7273 #[test]
7274 fn double_asterisk_in_strong_mark_roundtrip() {
7275 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call **kwargs","marks":[{"type":"strong"}]}]}]}"#;
7277 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7278 let jfm = adf_to_markdown(&adf).unwrap();
7279 let roundtripped = markdown_to_adf(&jfm).unwrap();
7280 let content = roundtripped.content[0].content.as_ref().unwrap();
7281 let strong_node = content.iter().find(|n| {
7282 n.marks
7283 .as_ref()
7284 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7285 });
7286 assert!(strong_node.is_some(), "should have a strong-marked node");
7287 assert_eq!(strong_node.unwrap().text.as_deref(), Some("call **kwargs"));
7288 }
7289
7290 #[test]
7291 fn backtick_code_roundtrip() {
7292 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Set `max_retries` to 3 in the config"}]}]}"#;
7294 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7295 let jfm = adf_to_markdown(&adf).unwrap();
7296 let roundtripped = markdown_to_adf(&jfm).unwrap();
7297 let content = roundtripped.content[0].content.as_ref().unwrap();
7298 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7299 assert_eq!(
7300 content[0].text.as_deref(),
7301 Some("Set `max_retries` to 3 in the config")
7302 );
7303 assert!(content[0].marks.is_none());
7304 }
7305
7306 #[test]
7307 fn multiple_backtick_spans_roundtrip() {
7308 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use `foo` and `bar` together"}]}]}"#;
7310 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7311 let jfm = adf_to_markdown(&adf).unwrap();
7312 let roundtripped = markdown_to_adf(&jfm).unwrap();
7313 let content = roundtripped.content[0].content.as_ref().unwrap();
7314 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7315 assert_eq!(
7316 content[0].text.as_deref(),
7317 Some("Use `foo` and `bar` together")
7318 );
7319 assert!(content[0].marks.is_none());
7320 }
7321
7322 #[test]
7323 fn lone_backtick_roundtrip() {
7324 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use a ` character"}]}]}"#;
7326 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7327 let jfm = adf_to_markdown(&adf).unwrap();
7328 let roundtripped = markdown_to_adf(&jfm).unwrap();
7329 let content = roundtripped.content[0].content.as_ref().unwrap();
7330 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7331 assert_eq!(content[0].text.as_deref(), Some("Use a ` character"));
7332 assert!(content[0].marks.is_none());
7333 }
7334
7335 #[test]
7336 fn backtick_with_code_mark_roundtrip() {
7337 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"max_retries","marks":[{"type":"code"}]}]}]}"#;
7339 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7340 let jfm = adf_to_markdown(&adf).unwrap();
7341 assert_eq!(jfm.trim(), "`max_retries`");
7342 let roundtripped = markdown_to_adf(&jfm).unwrap();
7343 let content = roundtripped.content[0].content.as_ref().unwrap();
7344 let code_node = content.iter().find(|n| {
7345 n.marks
7346 .as_ref()
7347 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7348 });
7349 assert!(code_node.is_some(), "should have a code-marked node");
7350 assert_eq!(code_node.unwrap().text.as_deref(), Some("max_retries"));
7351 }
7352
7353 #[test]
7354 fn backtick_with_em_mark_roundtrip() {
7355 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"use `cfg`","marks":[{"type":"em"}]}]}]}"#;
7357 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7358 let jfm = adf_to_markdown(&adf).unwrap();
7359 let roundtripped = markdown_to_adf(&jfm).unwrap();
7360 let content = roundtripped.content[0].content.as_ref().unwrap();
7361 let em_node = content.iter().find(|n| {
7362 n.marks
7363 .as_ref()
7364 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7365 });
7366 assert!(em_node.is_some(), "should have an em-marked node");
7367 assert_eq!(em_node.unwrap().text.as_deref(), Some("use `cfg`"));
7368 }
7369
7370 #[test]
7371 fn escape_pipes_in_cell_unit() {
7372 assert_eq!(escape_pipes_in_cell("hello"), "hello");
7373 assert_eq!(escape_pipes_in_cell("a|b"), r"a\|b");
7374 assert_eq!(escape_pipes_in_cell("|"), r"\|");
7375 assert_eq!(escape_pipes_in_cell("|a|b|"), r"\|a\|b\|");
7376 assert_eq!(escape_pipes_in_cell(""), "");
7377 assert_eq!(
7378 escape_pipes_in_cell("`parser.decode[T|json]`"),
7379 r"`parser.decode[T\|json]`"
7380 );
7381 }
7382
7383 #[test]
7384 fn escape_backticks_unit() {
7385 assert_eq!(escape_backticks("hello"), "hello");
7386 assert_eq!(escape_backticks("`code`"), r"\`code\`");
7387 assert_eq!(escape_backticks("no ticks"), "no ticks");
7388 assert_eq!(escape_backticks("a ` b"), r"a \` b");
7389 assert_eq!(escape_backticks(""), "");
7390 assert_eq!(escape_backticks("`a` and `b`"), r"\`a\` and \`b\`");
7391 }
7392
7393 #[test]
7396 fn backslash_in_text_roundtrip() {
7397 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"The path is C:\\Users\\admin\\file.txt"}]}]}"#;
7399 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7400 let jfm = adf_to_markdown(&adf).unwrap();
7401 let roundtripped = markdown_to_adf(&jfm).unwrap();
7402 let content = roundtripped.content[0].content.as_ref().unwrap();
7403 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7404 assert_eq!(
7405 content[0].text.as_deref(),
7406 Some(r"The path is C:\Users\admin\file.txt")
7407 );
7408 }
7409
7410 #[test]
7411 fn backslash_emitted_as_double_backslash() {
7412 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\b"}]}]}"#;
7413 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7414 let jfm = adf_to_markdown(&adf).unwrap();
7415 assert!(
7416 jfm.contains(r"a\\b"),
7417 "JFM should contain escaped backslash: {jfm}"
7418 );
7419 }
7420
7421 #[test]
7422 fn consecutive_backslashes_roundtrip() {
7423 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\\\b"}]}]}"#;
7424 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7425 let jfm = adf_to_markdown(&adf).unwrap();
7426 let roundtripped = markdown_to_adf(&jfm).unwrap();
7427 let content = roundtripped.content[0].content.as_ref().unwrap();
7428 assert_eq!(
7429 content[0].text.as_deref(),
7430 Some(r"a\\b"),
7431 "consecutive backslashes should survive round-trip"
7432 );
7433 }
7434
7435 #[test]
7436 fn backslash_with_strong_mark_roundtrip() {
7437 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"strong"}]}]}]}"#;
7438 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7439 let jfm = adf_to_markdown(&adf).unwrap();
7440 let roundtripped = markdown_to_adf(&jfm).unwrap();
7441 let content = roundtripped.content[0].content.as_ref().unwrap();
7442 let strong_node = content.iter().find(|n| {
7443 n.marks
7444 .as_ref()
7445 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7446 });
7447 assert!(strong_node.is_some(), "should have a strong-marked node");
7448 assert_eq!(strong_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7449 }
7450
7451 #[test]
7452 fn backslash_with_code_mark_not_escaped() {
7453 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"code"}]}]}]}"#;
7455 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7456 let jfm = adf_to_markdown(&adf).unwrap();
7457 assert_eq!(jfm.trim(), r"`C:\Users`");
7458 let roundtripped = markdown_to_adf(&jfm).unwrap();
7459 let content = roundtripped.content[0].content.as_ref().unwrap();
7460 let code_node = content.iter().find(|n| {
7461 n.marks
7462 .as_ref()
7463 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7464 });
7465 assert!(code_node.is_some(), "should have a code-marked node");
7466 assert_eq!(code_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7467 }
7468
7469 #[test]
7470 fn backslash_before_special_chars_roundtrip() {
7471 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"\\*not bold\\*"}]}]}"#;
7473 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7474 let jfm = adf_to_markdown(&adf).unwrap();
7475 let roundtripped = markdown_to_adf(&jfm).unwrap();
7476 let content = roundtripped.content[0].content.as_ref().unwrap();
7477 assert_eq!(
7478 content[0].text.as_deref(),
7479 Some(r"\*not bold\*"),
7480 "backslash before special char should survive round-trip"
7481 );
7482 }
7483
7484 #[test]
7485 fn backslash_and_newline_in_text_roundtrip() {
7486 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\path\nline2"}]}]}"#;
7488 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7489 let jfm = adf_to_markdown(&adf).unwrap();
7490 let roundtripped = markdown_to_adf(&jfm).unwrap();
7491 let content = roundtripped.content[0].content.as_ref().unwrap();
7492 assert_eq!(
7493 content[0].text.as_deref(),
7494 Some("C:\\path\nline2"),
7495 "backslash and newline should both survive round-trip"
7496 );
7497 }
7498
7499 #[test]
7500 fn lone_backslash_roundtrip() {
7501 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a \\ b"}]}]}"#;
7502 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7503 let jfm = adf_to_markdown(&adf).unwrap();
7504 let roundtripped = markdown_to_adf(&jfm).unwrap();
7505 let content = roundtripped.content[0].content.as_ref().unwrap();
7506 assert_eq!(content[0].text.as_deref(), Some(r"a \ b"));
7507 }
7508
7509 #[test]
7510 fn trailing_backslash_in_text_roundtrip() {
7511 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\"}]}]}"#;
7513 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7514 let jfm = adf_to_markdown(&adf).unwrap();
7515 let roundtripped = markdown_to_adf(&jfm).unwrap();
7516 let content = roundtripped.content[0].content.as_ref().unwrap();
7517 assert_eq!(
7518 content[0].text.as_deref(),
7519 Some(r"end\"),
7520 "trailing backslash should survive round-trip"
7521 );
7522 }
7523
7524 #[test]
7525 fn escape_bare_urls_unit() {
7526 assert_eq!(escape_bare_urls("hello"), "hello");
7527 assert_eq!(escape_bare_urls(""), "");
7528 assert_eq!(
7529 escape_bare_urls("https://example.com"),
7530 r"\https://example.com"
7531 );
7532 assert_eq!(
7533 escape_bare_urls("http://example.com"),
7534 r"\http://example.com"
7535 );
7536 assert_eq!(
7537 escape_bare_urls("see https://a.com and https://b.com"),
7538 r"see \https://a.com and \https://b.com"
7539 );
7540 assert_eq!(escape_bare_urls("http header"), "http header");
7542 assert_eq!(escape_bare_urls("https is secure"), "https is secure");
7543 }
7544
7545 #[test]
7546 fn heading_not_valid_without_space() {
7547 let doc = markdown_to_adf("#Title").unwrap();
7549 assert_eq!(doc.content[0].node_type, "paragraph");
7550 }
7551
7552 #[test]
7553 fn heading_level_too_high() {
7554 let doc = markdown_to_adf("####### Not a heading").unwrap();
7556 assert_eq!(doc.content[0].node_type, "paragraph");
7557 }
7558
7559 #[test]
7560 fn empty_document() {
7561 let doc = markdown_to_adf("").unwrap();
7562 assert!(doc.content.is_empty());
7563 }
7564
7565 #[test]
7566 fn only_blank_lines() {
7567 let doc = markdown_to_adf("\n\n\n").unwrap();
7568 assert!(doc.content.is_empty());
7569 }
7570
7571 #[test]
7572 fn code_block_unterminated() {
7573 let md = "```rust\nfn main() {}";
7575 let doc = markdown_to_adf(md).unwrap();
7576 assert_eq!(doc.content[0].node_type, "codeBlock");
7577 }
7578
7579 #[test]
7580 fn mixed_document() {
7581 let md = "# Title\n\nA paragraph.\n\n- Item\n\n```\ncode\n```\n\n> quote\n\n---\n\n1. numbered\n";
7582 let doc = markdown_to_adf(md).unwrap();
7583 let types: Vec<&str> = doc.content.iter().map(|n| n.node_type.as_str()).collect();
7584 assert_eq!(
7585 types,
7586 vec![
7587 "heading",
7588 "paragraph",
7589 "bulletList",
7590 "codeBlock",
7591 "blockquote",
7592 "rule",
7593 "orderedList",
7594 ]
7595 );
7596 }
7597
7598 #[test]
7601 fn adf_ordered_list_to_markdown() {
7602 let doc = AdfDocument {
7603 version: 1,
7604 doc_type: "doc".to_string(),
7605 content: vec![AdfNode::ordered_list(
7606 vec![
7607 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("First")])]),
7608 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("Second")])]),
7609 ],
7610 None,
7611 )],
7612 };
7613 let md = adf_to_markdown(&doc).unwrap();
7614 assert!(md.contains("1. First"));
7615 assert!(md.contains("2. Second"));
7616 }
7617
7618 #[test]
7619 fn adf_ordered_list_custom_start() {
7620 let doc = AdfDocument {
7621 version: 1,
7622 doc_type: "doc".to_string(),
7623 content: vec![AdfNode::ordered_list(
7624 vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
7625 AdfNode::text("Third"),
7626 ])])],
7627 Some(3),
7628 )],
7629 };
7630 let md = adf_to_markdown(&doc).unwrap();
7631 assert!(md.contains("3. Third"));
7632 }
7633
7634 #[test]
7635 fn adf_blockquote_to_markdown() {
7636 let doc = AdfDocument {
7637 version: 1,
7638 doc_type: "doc".to_string(),
7639 content: vec![AdfNode::blockquote(vec![AdfNode::paragraph(vec![
7640 AdfNode::text("A quote"),
7641 ])])],
7642 };
7643 let md = adf_to_markdown(&doc).unwrap();
7644 assert!(md.contains("> A quote"));
7645 }
7646
7647 #[test]
7648 fn adf_table_to_markdown() {
7649 let doc = AdfDocument {
7650 version: 1,
7651 doc_type: "doc".to_string(),
7652 content: vec![AdfNode::table(vec![
7653 AdfNode::table_row(vec![
7654 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Name")])]),
7655 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Value")])]),
7656 ]),
7657 AdfNode::table_row(vec![
7658 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a")])]),
7659 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("1")])]),
7660 ]),
7661 ])],
7662 };
7663 let md = adf_to_markdown(&doc).unwrap();
7664 assert!(md.contains("| Name | Value |"));
7665 assert!(md.contains("| --- | --- |"));
7666 assert!(md.contains("| a | 1 |"));
7667 }
7668
7669 #[test]
7670 fn adf_media_to_markdown() {
7671 let doc = AdfDocument {
7672 version: 1,
7673 doc_type: "doc".to_string(),
7674 content: vec![AdfNode::media_single(
7675 "https://example.com/img.png",
7676 Some("Alt"),
7677 )],
7678 };
7679 let md = adf_to_markdown(&doc).unwrap();
7680 assert!(md.contains(""));
7681 }
7682
7683 #[test]
7684 fn adf_media_no_alt_to_markdown() {
7685 let doc = AdfDocument {
7686 version: 1,
7687 doc_type: "doc".to_string(),
7688 content: vec![AdfNode::media_single("https://example.com/img.png", None)],
7689 };
7690 let md = adf_to_markdown(&doc).unwrap();
7691 assert!(md.contains(""));
7692 }
7693
7694 #[test]
7695 fn adf_italic_to_markdown() {
7696 let doc = AdfDocument {
7697 version: 1,
7698 doc_type: "doc".to_string(),
7699 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7700 "emphasis",
7701 vec![AdfMark::em()],
7702 )])],
7703 };
7704 let md = adf_to_markdown(&doc).unwrap();
7705 assert_eq!(md.trim(), "*emphasis*");
7706 }
7707
7708 #[test]
7709 fn adf_strikethrough_to_markdown() {
7710 let doc = AdfDocument {
7711 version: 1,
7712 doc_type: "doc".to_string(),
7713 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7714 "deleted",
7715 vec![AdfMark::strike()],
7716 )])],
7717 };
7718 let md = adf_to_markdown(&doc).unwrap();
7719 assert_eq!(md.trim(), "~~deleted~~");
7720 }
7721
7722 #[test]
7723 fn adf_inline_code_to_markdown() {
7724 let doc = AdfDocument {
7725 version: 1,
7726 doc_type: "doc".to_string(),
7727 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7728 "code",
7729 vec![AdfMark::code()],
7730 )])],
7731 };
7732 let md = adf_to_markdown(&doc).unwrap();
7733 assert_eq!(md.trim(), "`code`");
7734 }
7735
7736 #[test]
7737 fn adf_code_with_link_to_markdown() {
7738 let doc = AdfDocument {
7739 version: 1,
7740 doc_type: "doc".to_string(),
7741 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7742 "func",
7743 vec![AdfMark::code(), AdfMark::link("https://example.com")],
7744 )])],
7745 };
7746 let md = adf_to_markdown(&doc).unwrap();
7747 assert_eq!(md.trim(), "[`func`](https://example.com)");
7748 }
7749
7750 #[test]
7751 fn adf_bold_italic_to_markdown() {
7752 let doc = AdfDocument {
7753 version: 1,
7754 doc_type: "doc".to_string(),
7755 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7756 "both",
7757 vec![AdfMark::strong(), AdfMark::em()],
7758 )])],
7759 };
7760 let md = adf_to_markdown(&doc).unwrap();
7761 assert_eq!(md.trim(), "***both***");
7762 }
7763
7764 #[test]
7765 fn adf_bold_link_to_markdown() {
7766 let doc = AdfDocument {
7767 version: 1,
7768 doc_type: "doc".to_string(),
7769 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7770 "bold link",
7771 vec![AdfMark::strong(), AdfMark::link("https://example.com")],
7772 )])],
7773 };
7774 let md = adf_to_markdown(&doc).unwrap();
7775 assert_eq!(md.trim(), "**[bold link](https://example.com)**");
7776 }
7777
7778 #[test]
7779 fn adf_strikethrough_bold_to_markdown() {
7780 let doc = AdfDocument {
7781 version: 1,
7782 doc_type: "doc".to_string(),
7783 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7784 "struck",
7785 vec![AdfMark::strike(), AdfMark::strong()],
7786 )])],
7787 };
7788 let md = adf_to_markdown(&doc).unwrap();
7789 assert_eq!(md.trim(), "~~**struck**~~");
7790 }
7791
7792 #[test]
7793 fn adf_hard_break_to_markdown() {
7794 let doc = AdfDocument {
7795 version: 1,
7796 doc_type: "doc".to_string(),
7797 content: vec![AdfNode::paragraph(vec![
7798 AdfNode::text("Line 1"),
7799 AdfNode::hard_break(),
7800 AdfNode::text("Line 2"),
7801 ])],
7802 };
7803 let md = adf_to_markdown(&doc).unwrap();
7804 assert!(md.contains("Line 1\\\n Line 2"));
7805 }
7806
7807 #[test]
7808 #[test]
7809 fn adf_unsupported_inline_to_markdown() {
7810 let doc = AdfDocument {
7811 version: 1,
7812 doc_type: "doc".to_string(),
7813 content: vec![AdfNode::paragraph(vec![AdfNode {
7814 node_type: "unknownInline".to_string(),
7815 attrs: None,
7816 content: None,
7817 text: None,
7818 marks: None,
7819 local_id: None,
7820 parameters: None,
7821 }])],
7822 };
7823 let md = adf_to_markdown(&doc).unwrap();
7824 assert!(md.contains("<!-- unsupported inline: unknownInline -->"));
7825 }
7826
7827 #[test]
7830 fn adf_media_inline_to_markdown() {
7831 let doc = AdfDocument {
7832 version: 1,
7833 doc_type: "doc".to_string(),
7834 content: vec![AdfNode::paragraph(vec![
7835 AdfNode::text("see "),
7836 AdfNode::media_inline(serde_json::json!({
7837 "type": "image",
7838 "id": "abcdef01-2345-6789-abcd-abcdef012345",
7839 "collection": "contentId-111111",
7840 "width": 200,
7841 "height": 100
7842 })),
7843 AdfNode::text(" for details"),
7844 ])],
7845 };
7846 let md = adf_to_markdown(&doc).unwrap();
7847 assert!(md.contains(":media-inline[]{"), "got: {md}");
7848 assert!(md.contains("type=image"));
7849 assert!(md.contains("id=abcdef01-2345-6789-abcd-abcdef012345"));
7850 assert!(md.contains("collection=contentId-111111"));
7851 assert!(md.contains("width=200"));
7852 assert!(md.contains("height=100"));
7853 assert!(!md.contains("<!-- unsupported inline"));
7854 }
7855
7856 #[test]
7857 fn media_inline_round_trip() {
7858 let doc = AdfDocument {
7859 version: 1,
7860 doc_type: "doc".to_string(),
7861 content: vec![AdfNode::paragraph(vec![
7862 AdfNode::text("see "),
7863 AdfNode::media_inline(serde_json::json!({
7864 "type": "image",
7865 "id": "abcdef01-2345-6789-abcd-abcdef012345",
7866 "collection": "contentId-111111",
7867 "width": 200,
7868 "height": 100
7869 })),
7870 AdfNode::text(" for details"),
7871 ])],
7872 };
7873 let md = adf_to_markdown(&doc).unwrap();
7874 let rt = markdown_to_adf(&md).unwrap();
7875
7876 let content = rt.content[0].content.as_ref().unwrap();
7877 assert_eq!(content[0].text.as_deref(), Some("see "));
7878 assert_eq!(content[1].node_type, "mediaInline");
7879 let attrs = content[1].attrs.as_ref().unwrap();
7880 assert_eq!(attrs["type"], "image");
7881 assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
7882 assert_eq!(attrs["collection"], "contentId-111111");
7883 assert_eq!(attrs["width"], 200);
7884 assert_eq!(attrs["height"], 100);
7885 assert_eq!(content[2].text.as_deref(), Some(" for details"));
7886 }
7887
7888 #[test]
7889 fn media_inline_external_url_round_trip() {
7890 let doc = AdfDocument {
7891 version: 1,
7892 doc_type: "doc".to_string(),
7893 content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
7894 serde_json::json!({
7895 "type": "external",
7896 "url": "https://example.com/image.png",
7897 "alt": "example",
7898 "width": 400,
7899 "height": 300
7900 }),
7901 )])],
7902 };
7903 let md = adf_to_markdown(&doc).unwrap();
7904 let rt = markdown_to_adf(&md).unwrap();
7905
7906 let content = rt.content[0].content.as_ref().unwrap();
7907 assert_eq!(content[0].node_type, "mediaInline");
7908 let attrs = content[0].attrs.as_ref().unwrap();
7909 assert_eq!(attrs["type"], "external");
7910 assert_eq!(attrs["url"], "https://example.com/image.png");
7911 assert_eq!(attrs["alt"], "example");
7912 assert_eq!(attrs["width"], 400);
7913 assert_eq!(attrs["height"], 300);
7914 }
7915
7916 #[test]
7917 fn media_inline_minimal_attrs() {
7918 let doc = AdfDocument {
7919 version: 1,
7920 doc_type: "doc".to_string(),
7921 content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
7922 serde_json::json!({"type": "file", "id": "abc-123"}),
7923 )])],
7924 };
7925 let md = adf_to_markdown(&doc).unwrap();
7926 let rt = markdown_to_adf(&md).unwrap();
7927
7928 let content = rt.content[0].content.as_ref().unwrap();
7929 assert_eq!(content[0].node_type, "mediaInline");
7930 let attrs = content[0].attrs.as_ref().unwrap();
7931 assert_eq!(attrs["type"], "file");
7932 assert_eq!(attrs["id"], "abc-123");
7933 }
7934
7935 #[test]
7936 fn media_inline_from_issue_476_reproducer() {
7937 let adf_json: serde_json::Value = serde_json::json!({
7939 "type": "doc",
7940 "version": 1,
7941 "content": [
7942 {
7943 "type": "paragraph",
7944 "content": [
7945 {"type": "text", "text": "see "},
7946 {
7947 "type": "mediaInline",
7948 "attrs": {
7949 "collection": "contentId-111111",
7950 "height": 100,
7951 "id": "abcdef01-2345-6789-abcd-abcdef012345",
7952 "localId": "aabbccdd-1234-5678-abcd-aabbccdd1234",
7953 "type": "image",
7954 "width": 200
7955 }
7956 },
7957 {"type": "text", "text": " for details"}
7958 ]
7959 }
7960 ]
7961 });
7962 let doc: AdfDocument = serde_json::from_value(adf_json).unwrap();
7963 let md = adf_to_markdown(&doc).unwrap();
7964 assert!(
7965 !md.contains("<!-- unsupported inline"),
7966 "mediaInline should not be unsupported; got: {md}"
7967 );
7968
7969 let rt = markdown_to_adf(&md).unwrap();
7970 let content = rt.content[0].content.as_ref().unwrap();
7971 assert_eq!(content[1].node_type, "mediaInline");
7972 let attrs = content[1].attrs.as_ref().unwrap();
7973 assert_eq!(attrs["type"], "image");
7974 assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
7975 assert_eq!(attrs["collection"], "contentId-111111");
7976 assert_eq!(attrs["width"], 200);
7977 assert_eq!(attrs["height"], 100);
7978 assert_eq!(attrs["localId"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
7979 }
7980
7981 #[test]
7982 fn emoji_shortcode() {
7983 let doc = markdown_to_adf("Hello :wave: world").unwrap();
7984 let content = doc.content[0].content.as_ref().unwrap();
7985 assert_eq!(content[0].text.as_deref(), Some("Hello "));
7986 assert_eq!(content[1].node_type, "emoji");
7987 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":wave:");
7988 assert_eq!(content[2].text.as_deref(), Some(" world"));
7989 }
7990
7991 #[test]
7992 fn adf_emoji_to_markdown() {
7993 let doc = AdfDocument {
7994 version: 1,
7995 doc_type: "doc".to_string(),
7996 content: vec![AdfNode::paragraph(vec![AdfNode::emoji("thumbsup")])],
7997 };
7998 let md = adf_to_markdown(&doc).unwrap();
7999 assert!(md.contains(":thumbsup:"));
8000 }
8001
8002 #[test]
8003 fn adf_emoji_with_colon_prefix_to_markdown() {
8004 let doc = AdfDocument {
8006 version: 1,
8007 doc_type: "doc".to_string(),
8008 content: vec![AdfNode::paragraph(vec![AdfNode {
8009 node_type: "emoji".to_string(),
8010 attrs: Some(serde_json::json!({"shortName": ":thumbsup:"})),
8011 content: None,
8012 text: None,
8013 marks: None,
8014 local_id: None,
8015 parameters: None,
8016 }])],
8017 };
8018 let md = adf_to_markdown(&doc).unwrap();
8019 assert!(md.contains(":thumbsup:"));
8020 assert!(!md.contains("::thumbsup::"));
8022 }
8023
8024 #[test]
8025 fn round_trip_emoji() {
8026 let md = "Hello :wave: world\n";
8027 let doc = markdown_to_adf(md).unwrap();
8028 let result = adf_to_markdown(&doc).unwrap();
8029 assert!(result.contains(":wave:"));
8030 }
8031
8032 #[test]
8033 fn emoji_with_id_and_text_round_trips() {
8034 let doc = AdfDocument {
8035 version: 1,
8036 doc_type: "doc".to_string(),
8037 content: vec![AdfNode::paragraph(vec![AdfNode {
8038 node_type: "emoji".to_string(),
8039 attrs: Some(
8040 serde_json::json!({"shortName": ":check_mark:", "id": "2705", "text": "✅"}),
8041 ),
8042 content: None,
8043 text: None,
8044 marks: None,
8045 local_id: None,
8046 parameters: None,
8047 }])],
8048 };
8049 let md = adf_to_markdown(&doc).unwrap();
8050 assert!(md.contains(":check_mark:"), "shortcode present: {md}");
8051 assert!(md.contains("id="), "id attr present: {md}");
8052 assert!(md.contains("text="), "text attr present: {md}");
8053
8054 let round_tripped = markdown_to_adf(&md).unwrap();
8056 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8057 let attrs = emoji.attrs.as_ref().unwrap();
8058 assert_eq!(attrs["shortName"], ":check_mark:");
8059 assert_eq!(attrs["id"], "2705");
8060 assert_eq!(attrs["text"], "✅");
8061 }
8062
8063 #[test]
8064 fn emoji_without_extra_attrs_still_works() {
8065 let md = "Hello :wave: world\n";
8066 let doc = markdown_to_adf(md).unwrap();
8067 let emoji = &doc.content[0].content.as_ref().unwrap()[1];
8068 assert_eq!(emoji.attrs.as_ref().unwrap()["shortName"], ":wave:");
8069 assert!(emoji.attrs.as_ref().unwrap().get("id").is_none());
8071 }
8072
8073 #[test]
8074 fn emoji_shortname_preserves_colons_round_trip() {
8075 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8077 {"type":"emoji","attrs":{"shortName":":cross_mark:","id":"atlassian-cross_mark","text":"❌"}}
8078 ]}]}"#;
8079 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8080
8081 let md = adf_to_markdown(&doc).unwrap();
8083 let round_tripped = markdown_to_adf(&md).unwrap();
8084 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8085 let attrs = emoji.attrs.as_ref().unwrap();
8086 assert_eq!(
8087 attrs["shortName"], ":cross_mark:",
8088 "shortName should preserve colons, got: {}",
8089 attrs["shortName"]
8090 );
8091 assert_eq!(attrs["id"], "atlassian-cross_mark");
8092 assert_eq!(attrs["text"], "❌");
8093 }
8094
8095 #[test]
8096 fn emoji_shortname_without_colons_preserved() {
8097 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8099 {"type":"emoji","attrs":{"shortName":"white_check_mark","id":"2705","text":"✅"}}
8100 ]}]}"#;
8101 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8102 let md = adf_to_markdown(&doc).unwrap();
8103 let round_tripped = markdown_to_adf(&md).unwrap();
8104 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8105 let attrs = emoji.attrs.as_ref().unwrap();
8106 assert_eq!(
8107 attrs["shortName"], "white_check_mark",
8108 "shortName without colons should stay without colons, got: {}",
8109 attrs["shortName"]
8110 );
8111 }
8112
8113 #[test]
8114 fn colon_in_text_not_emoji() {
8115 let doc = markdown_to_adf("Time is 10:30 today").unwrap();
8117 let content = doc.content[0].content.as_ref().unwrap();
8118 assert_eq!(content.len(), 1);
8119 assert_eq!(content[0].node_type, "text");
8120 }
8121
8122 #[test]
8123 fn text_with_shortcode_pattern_round_trips_as_text() {
8124 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Alert :fire: triggered on pod:pod42"}]}]}"#;
8126 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8127
8128 let md = adf_to_markdown(&doc).unwrap();
8129 let round_tripped = markdown_to_adf(&md).unwrap();
8130 let content = round_tripped.content[0].content.as_ref().unwrap();
8131
8132 assert_eq!(
8133 content.len(),
8134 1,
8135 "should be a single text node, got: {content:?}"
8136 );
8137 assert_eq!(content[0].node_type, "text");
8138 assert_eq!(
8139 content[0].text.as_deref().unwrap(),
8140 "Alert :fire: triggered on pod:pod42"
8141 );
8142 }
8143
8144 #[test]
8145 fn double_colon_pattern_round_trips_as_text() {
8146 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status::Active::Running"}]}]}"#;
8148 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8149
8150 let md = adf_to_markdown(&doc).unwrap();
8151 let round_tripped = markdown_to_adf(&md).unwrap();
8152 let content = round_tripped.content[0].content.as_ref().unwrap();
8153
8154 assert_eq!(
8155 content.len(),
8156 1,
8157 "should be a single text node, got: {content:?}"
8158 );
8159 assert_eq!(content[0].node_type, "text");
8160 assert_eq!(
8161 content[0].text.as_deref().unwrap(),
8162 "Status::Active::Running"
8163 );
8164 }
8165
8166 #[test]
8167 fn real_emoji_node_still_round_trips() {
8168 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8170 {"type":"text","text":"Hello "},
8171 {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8172 {"type":"text","text":" world"}
8173 ]}]}"#;
8174 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8175
8176 let md = adf_to_markdown(&doc).unwrap();
8177 let round_tripped = markdown_to_adf(&md).unwrap();
8178 let content = round_tripped.content[0].content.as_ref().unwrap();
8179
8180 assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8182 assert_eq!(content[0].text.as_deref(), Some("Hello "));
8183 assert_eq!(content[1].node_type, "emoji");
8184 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":fire:");
8185 assert_eq!(content[2].text.as_deref(), Some(" world"));
8186 }
8187
8188 #[test]
8189 fn combined_emoji_shortname_round_trips_as_single_node() {
8190 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8195 {"type":"text","text":"Thanks for the help "},
8196 {"type":"emoji","attrs":{"shortName":":slightly_smiling_face::bow:","id":"","text":""}}
8197 ]}]}"#;
8198 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8199
8200 let md = adf_to_markdown(&doc).unwrap();
8201 let round_tripped = markdown_to_adf(&md).unwrap();
8202 let content = round_tripped.content[0].content.as_ref().unwrap();
8203
8204 assert_eq!(
8205 content.len(),
8206 2,
8207 "should have text + single combined emoji: {content:?}"
8208 );
8209 assert_eq!(content[0].text.as_deref(), Some("Thanks for the help "));
8210 assert_eq!(content[1].node_type, "emoji");
8211 let attrs = content[1].attrs.as_ref().unwrap();
8212 assert_eq!(attrs["shortName"], ":slightly_smiling_face::bow:");
8213 assert_eq!(attrs["id"], "");
8214 assert_eq!(attrs["text"], "");
8215 }
8216
8217 #[test]
8218 fn triple_combined_emoji_shortname_round_trips_as_single_node() {
8219 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8221 {"type":"emoji","attrs":{"shortName":":a::b::c:","id":"x","text":""}}
8222 ]}]}"#;
8223 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8224
8225 let md = adf_to_markdown(&doc).unwrap();
8226 let round_tripped = markdown_to_adf(&md).unwrap();
8227 let content = round_tripped.content[0].content.as_ref().unwrap();
8228
8229 assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8230 assert_eq!(content[0].node_type, "emoji");
8231 let attrs = content[0].attrs.as_ref().unwrap();
8232 assert_eq!(attrs["shortName"], ":a::b::c:");
8233 assert_eq!(attrs["id"], "x");
8234 }
8235
8236 #[test]
8237 fn consecutive_emojis_remain_separate_nodes() {
8238 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8242 {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8243 {"type":"emoji","attrs":{"shortName":":water:","id":"1f4a7","text":"💧"}}
8244 ]}]}"#;
8245 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8246
8247 let md = adf_to_markdown(&doc).unwrap();
8248 let round_tripped = markdown_to_adf(&md).unwrap();
8249 let content = round_tripped.content[0].content.as_ref().unwrap();
8250
8251 assert_eq!(content.len(), 2, "should be two emoji nodes: {content:?}");
8252 assert_eq!(content[0].node_type, "emoji");
8253 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8254 assert_eq!(content[1].node_type, "emoji");
8255 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8256 }
8257
8258 #[test]
8259 fn adjacent_shortcodes_without_directive_parse_as_two_emojis() {
8260 let md = ":fire::water:";
8263 let doc = markdown_to_adf(md).unwrap();
8264 let content = doc.content[0].content.as_ref().unwrap();
8265
8266 assert_eq!(content.len(), 2, "should be two emojis: {content:?}");
8267 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8268 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8269 }
8270
8271 #[test]
8272 fn combined_emoji_shortname_preserves_local_id() {
8273 let md = r#":a::b:{shortName=":a::b:" id="x" text="y" localId="abc"}"#;
8276 let doc = markdown_to_adf(md).unwrap();
8277 let content = doc.content[0].content.as_ref().unwrap();
8278
8279 assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8280 let attrs = content[0].attrs.as_ref().unwrap();
8281 assert_eq!(attrs["shortName"], ":a::b:");
8282 assert_eq!(attrs["id"], "x");
8283 assert_eq!(attrs["text"], "y");
8284 assert_eq!(attrs["localId"], "abc");
8285 }
8286
8287 #[test]
8288 fn text_shortcode_with_marks_round_trips() {
8289 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8291 {"type":"text","text":"Alert :fire: triggered","marks":[{"type":"strong"}]}
8292 ]}]}"#;
8293 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8294
8295 let md = adf_to_markdown(&doc).unwrap();
8296 let round_tripped = markdown_to_adf(&md).unwrap();
8297 let content = round_tripped.content[0].content.as_ref().unwrap();
8298
8299 assert_eq!(
8300 content.len(),
8301 1,
8302 "should be single bold text node: {content:?}"
8303 );
8304 assert_eq!(content[0].node_type, "text");
8305 assert_eq!(
8306 content[0].text.as_deref().unwrap(),
8307 "Alert :fire: triggered"
8308 );
8309 assert!(content[0]
8310 .marks
8311 .as_ref()
8312 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong")));
8313 }
8314
8315 #[test]
8316 fn mixed_emoji_node_and_text_shortcode_round_trips() {
8317 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8319 {"type":"emoji","attrs":{"shortName":":wave:","id":"1f44b","text":"👋"}},
8320 {"type":"text","text":" says :hello: to you"}
8321 ]}]}"#;
8322 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8323
8324 let md = adf_to_markdown(&doc).unwrap();
8325 let round_tripped = markdown_to_adf(&md).unwrap();
8326 let content = round_tripped.content[0].content.as_ref().unwrap();
8327
8328 assert_eq!(content.len(), 2, "should have 2 nodes: {content:?}");
8330 assert_eq!(content[0].node_type, "emoji");
8331 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":wave:");
8332 assert_eq!(content[1].node_type, "text");
8333 assert_eq!(content[1].text.as_deref().unwrap(), " says :hello: to you");
8334 }
8335
8336 #[test]
8337 fn code_block_with_shortcode_pattern_round_trips() {
8338 let adf_json = r#"{"version":1,"type":"doc","content":[
8341 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8342 {"type":"text","text":"module Foo::Bar::Baz\n def hello\n puts 'world'\n end\nend"}
8343 ]}
8344 ]}"#;
8345 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8346
8347 let md = adf_to_markdown(&doc).unwrap();
8348 let round_tripped = markdown_to_adf(&md).unwrap();
8349
8350 assert_eq!(
8351 round_tripped.content.len(),
8352 1,
8353 "should be a single codeBlock"
8354 );
8355 let cb = &round_tripped.content[0];
8356 assert_eq!(cb.node_type, "codeBlock");
8357 let content = cb.content.as_ref().expect("codeBlock content");
8358 assert_eq!(
8359 content.len(),
8360 1,
8361 "should be a single text node: {content:?}"
8362 );
8363 assert_eq!(content[0].node_type, "text");
8364 assert_eq!(
8365 content[0].text.as_deref().unwrap(),
8366 "module Foo::Bar::Baz\n def hello\n puts 'world'\n end\nend"
8367 );
8368 assert!(
8369 content.iter().all(|n| n.node_type != "emoji"),
8370 "no emoji nodes should be present, got: {content:?}"
8371 );
8372 }
8373
8374 #[test]
8375 fn code_block_with_exact_zendesk_shortcode_pattern_round_trips() {
8376 let adf_json = r#"{"version":1,"type":"doc","content":[
8378 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8379 {"type":"text","text":"class ZBC::Zendesk::PlanType::Professional < Base"}
8380 ]}
8381 ]}"#;
8382 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8383
8384 let md = adf_to_markdown(&doc).unwrap();
8385 let round_tripped = markdown_to_adf(&md).unwrap();
8386
8387 let cb = &round_tripped.content[0];
8388 assert_eq!(cb.node_type, "codeBlock");
8389 let content = cb.content.as_ref().expect("codeBlock content");
8390 assert_eq!(content.len(), 1, "should be a single text node");
8391 assert_eq!(
8392 content[0].text.as_deref().unwrap(),
8393 "class ZBC::Zendesk::PlanType::Professional < Base"
8394 );
8395 }
8396
8397 #[test]
8398 fn code_block_with_literal_shortcode_round_trips() {
8399 let adf_json = r#"{"version":1,"type":"doc","content":[
8402 {"type":"codeBlock","attrs":{"language":"text"},"content":[
8403 {"type":"text","text":":fire: :wave: :thumbsup:"}
8404 ]}
8405 ]}"#;
8406 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8407
8408 let md = adf_to_markdown(&doc).unwrap();
8409 let round_tripped = markdown_to_adf(&md).unwrap();
8410
8411 let cb = &round_tripped.content[0];
8412 assert_eq!(cb.node_type, "codeBlock");
8413 let content = cb.content.as_ref().expect("codeBlock content");
8414 assert_eq!(
8415 content.len(),
8416 1,
8417 "should be a single text node: {content:?}"
8418 );
8419 assert_eq!(content[0].node_type, "text");
8420 assert_eq!(
8421 content[0].text.as_deref().unwrap(),
8422 ":fire: :wave: :thumbsup:"
8423 );
8424 }
8425
8426 #[test]
8427 fn inline_code_with_shortcode_pattern_round_trips() {
8428 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8431 {"type":"text","text":"See "},
8432 {"type":"text","text":"Foo::Bar::Baz","marks":[{"type":"code"}]},
8433 {"type":"text","text":" for details"}
8434 ]}]}"#;
8435 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8436
8437 let md = adf_to_markdown(&doc).unwrap();
8438 let round_tripped = markdown_to_adf(&md).unwrap();
8439 let content = round_tripped.content[0].content.as_ref().unwrap();
8440
8441 assert_eq!(content.len(), 3, "should have 3 text nodes: {content:?}");
8442 assert_eq!(content[0].text.as_deref(), Some("See "));
8443 assert_eq!(content[1].text.as_deref(), Some("Foo::Bar::Baz"));
8444 assert!(content[1]
8445 .marks
8446 .as_ref()
8447 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8448 assert_eq!(content[2].text.as_deref(), Some(" for details"));
8449 assert!(
8450 content.iter().all(|n| n.node_type != "emoji"),
8451 "no emoji nodes should be present"
8452 );
8453 }
8454
8455 #[test]
8456 fn inline_code_with_literal_shortcode_round_trips() {
8457 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8460 {"type":"text","text":":fire:","marks":[{"type":"code"}]}
8461 ]}]}"#;
8462 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8463
8464 let md = adf_to_markdown(&doc).unwrap();
8465 let round_tripped = markdown_to_adf(&md).unwrap();
8466 let content = round_tripped.content[0].content.as_ref().unwrap();
8467
8468 assert_eq!(
8469 content.len(),
8470 1,
8471 "should be a single code node: {content:?}"
8472 );
8473 assert_eq!(content[0].node_type, "text");
8474 assert_eq!(content[0].text.as_deref(), Some(":fire:"));
8475 assert!(content[0]
8476 .marks
8477 .as_ref()
8478 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8479 }
8480
8481 #[test]
8482 fn code_block_in_list_with_shortcode_pattern_round_trips() {
8483 let adf_json = r#"{"version":1,"type":"doc","content":[
8486 {"type":"bulletList","content":[
8487 {"type":"listItem","content":[
8488 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8489 {"type":"text","text":"Foo::Bar::Baz"}
8490 ]}
8491 ]}
8492 ]}
8493 ]}"#;
8494 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8495
8496 let md = adf_to_markdown(&doc).unwrap();
8497 let round_tripped = markdown_to_adf(&md).unwrap();
8498
8499 let list = &round_tripped.content[0];
8500 assert_eq!(list.node_type, "bulletList");
8501 let item = &list.content.as_ref().unwrap()[0];
8502 assert_eq!(item.node_type, "listItem");
8503 let cb = &item.content.as_ref().unwrap()[0];
8504 assert_eq!(cb.node_type, "codeBlock");
8505 let cb_content = cb.content.as_ref().unwrap();
8506 assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8507 assert_eq!(cb_content[0].node_type, "text");
8508 }
8509
8510 #[test]
8511 fn code_block_with_unicode_shortcode_pattern_round_trips() {
8512 let adf_json = r#"{"version":1,"type":"doc","content":[
8516 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8517 {"type":"text","text":"class ZBC::配置::Production < Base"}
8518 ]}
8519 ]}"#;
8520 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8521
8522 let md = adf_to_markdown(&doc).unwrap();
8523 let round_tripped = markdown_to_adf(&md).unwrap();
8524
8525 let cb = &round_tripped.content[0];
8526 assert_eq!(cb.node_type, "codeBlock");
8527 let content = cb.content.as_ref().expect("codeBlock content");
8528 assert_eq!(content.len(), 1);
8529 assert_eq!(
8530 content[0].text.as_deref().unwrap(),
8531 "class ZBC::配置::Production < Base"
8532 );
8533 }
8534
8535 #[test]
8536 fn list_item_hardbreak_then_code_block_round_trips() {
8537 let adf_json = r#"{"version":1,"type":"doc","content":[
8543 {"type":"bulletList","content":[
8544 {"type":"listItem","content":[
8545 {"type":"paragraph","content":[
8546 {"type":"text","text":"Consider removing this check."},
8547 {"type":"hardBreak"}
8548 ]},
8549 {"type":"codeBlock","content":[
8550 {"type":"text","text":"x = Foo::Bar::Baz.new"}
8551 ]}
8552 ]}
8553 ]}
8554 ]}"#;
8555 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8556
8557 let md = adf_to_markdown(&doc).unwrap();
8558 let round_tripped = markdown_to_adf(&md).unwrap();
8559
8560 let list = &round_tripped.content[0];
8561 assert_eq!(list.node_type, "bulletList");
8562 let item = &list.content.as_ref().unwrap()[0];
8563 assert_eq!(item.node_type, "listItem");
8564 let item_content = item.content.as_ref().unwrap();
8565 assert_eq!(
8566 item_content.len(),
8567 2,
8568 "listItem should have paragraph + codeBlock, got: {item_content:?}"
8569 );
8570 assert_eq!(item_content[0].node_type, "paragraph");
8571 assert_eq!(item_content[1].node_type, "codeBlock");
8572
8573 let cb_content = item_content[1].content.as_ref().unwrap();
8575 assert_eq!(cb_content[0].node_type, "text");
8576 assert_eq!(
8577 cb_content[0].text.as_deref().unwrap(),
8578 "x = Foo::Bar::Baz.new"
8579 );
8580
8581 assert!(
8583 item_content
8584 .iter()
8585 .flat_map(|n| n.content.iter().flat_map(|c| c.iter()))
8586 .all(|n| n.node_type != "emoji"),
8587 "no emoji nodes should be present, got: {item_content:?}"
8588 );
8589 }
8590
8591 #[test]
8592 fn list_item_hardbreak_then_nested_list_still_works() {
8593 let md = "- first\\\n continuation text\\\n - nested item\n";
8597 let doc = markdown_to_adf(md).unwrap();
8598 let list = &doc.content[0];
8599 assert_eq!(list.node_type, "bulletList");
8600 let item = &list.content.as_ref().unwrap()[0];
8601 let item_content = item.content.as_ref().unwrap();
8603 let para = &item_content[0];
8604 assert_eq!(para.node_type, "paragraph");
8605 let para_nodes = para.content.as_ref().unwrap();
8606 assert!(
8607 para_nodes
8608 .iter()
8609 .any(|n| n.text.as_deref() == Some("continuation text")),
8610 "continuation text should survive: {para_nodes:?}"
8611 );
8612 }
8613
8614 #[test]
8615 fn list_item_hardbreak_then_image_still_works() {
8616 let md = "- first\\\n {type=file id=x}\n";
8621 let doc = markdown_to_adf(md).unwrap();
8622 let list = &doc.content[0];
8623 let item = &list.content.as_ref().unwrap()[0];
8624 let item_content = item.content.as_ref().unwrap();
8625 assert!(
8627 item_content.iter().any(|n| n.node_type == "mediaSingle"),
8628 "mediaSingle should still be a block-level sibling, got: {item_content:?}"
8629 );
8630 }
8631
8632 #[test]
8633 fn list_item_hardbreak_then_container_directive_round_trips() {
8634 let adf_json = r#"{"version":1,"type":"doc","content":[
8638 {"type":"bulletList","content":[
8639 {"type":"listItem","content":[
8640 {"type":"paragraph","content":[
8641 {"type":"text","text":"intro"},
8642 {"type":"hardBreak"}
8643 ]},
8644 {"type":"panel","attrs":{"panelType":"info"},"content":[
8645 {"type":"paragraph","content":[
8646 {"type":"text","text":"panel body"}
8647 ]}
8648 ]}
8649 ]}
8650 ]}
8651 ]}"#;
8652 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8653 let md = adf_to_markdown(&doc).unwrap();
8654 let round_tripped = markdown_to_adf(&md).unwrap();
8655
8656 let item = &round_tripped.content[0].content.as_ref().unwrap()[0];
8657 let item_content = item.content.as_ref().unwrap();
8658 assert!(
8659 item_content.iter().any(|n| n.node_type == "panel"),
8660 "panel should survive as block-level sibling, got: {item_content:?}"
8661 );
8662 }
8663
8664 #[test]
8665 fn inline_code_with_unicode_shortcode_pattern_round_trips() {
8666 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8669 {"type":"text","text":"See "},
8670 {"type":"text","text":"ZBC::配置::Production","marks":[{"type":"code"}]},
8671 {"type":"text","text":" for prod"}
8672 ]}]}"#;
8673 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8674
8675 let md = adf_to_markdown(&doc).unwrap();
8676 let round_tripped = markdown_to_adf(&md).unwrap();
8677 let content = round_tripped.content[0].content.as_ref().unwrap();
8678
8679 assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8680 assert_eq!(content[1].text.as_deref(), Some("ZBC::配置::Production"));
8681 assert!(content[1]
8682 .marks
8683 .as_ref()
8684 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8685 }
8686
8687 #[test]
8688 fn code_block_followed_by_shortcode_text_round_trips() {
8689 let adf_json = r#"{"version":1,"type":"doc","content":[
8692 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8693 {"type":"text","text":"Foo::Bar::Baz"}
8694 ]},
8695 {"type":"paragraph","content":[
8696 {"type":"text","text":"Status::Active::Running"}
8697 ]}
8698 ]}"#;
8699 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8700
8701 let md = adf_to_markdown(&doc).unwrap();
8702 let round_tripped = markdown_to_adf(&md).unwrap();
8703
8704 assert_eq!(round_tripped.content.len(), 2);
8705 let cb = &round_tripped.content[0];
8706 assert_eq!(cb.node_type, "codeBlock");
8707 let cb_content = cb.content.as_ref().unwrap();
8708 assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8709
8710 let para = &round_tripped.content[1];
8711 assert_eq!(para.node_type, "paragraph");
8712 let para_content = para.content.as_ref().unwrap();
8713 assert_eq!(para_content.len(), 1);
8714 assert_eq!(para_content[0].node_type, "text");
8715 assert_eq!(
8716 para_content[0].text.as_deref(),
8717 Some("Status::Active::Running")
8718 );
8719 }
8720
8721 #[test]
8722 fn adf_inline_card_to_markdown() {
8723 let doc = AdfDocument {
8724 version: 1,
8725 doc_type: "doc".to_string(),
8726 content: vec![AdfNode::paragraph(vec![AdfNode {
8727 node_type: "inlineCard".to_string(),
8728 attrs: Some(
8729 serde_json::json!({"url": "https://org.atlassian.net/browse/ACCS-4382"}),
8730 ),
8731 content: None,
8732 text: None,
8733 marks: None,
8734 local_id: None,
8735 parameters: None,
8736 }])],
8737 };
8738 let md = adf_to_markdown(&doc).unwrap();
8739 assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
8740 assert!(!md.contains("<!-- unsupported inline"));
8741 }
8742
8743 #[test]
8744 fn inline_card_directive_round_trips() {
8745 let original = AdfDocument {
8747 version: 1,
8748 doc_type: "doc".to_string(),
8749 content: vec![AdfNode::paragraph(vec![AdfNode::inline_card(
8750 "https://org.atlassian.net/browse/ACCS-4382",
8751 )])],
8752 };
8753 let md = adf_to_markdown(&original).unwrap();
8754 assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
8755 let restored = markdown_to_adf(&md).unwrap();
8756 let node = &restored.content[0].content.as_ref().unwrap()[0];
8757 assert_eq!(node.node_type, "inlineCard");
8758 assert_eq!(
8759 node.attrs.as_ref().unwrap()["url"],
8760 "https://org.atlassian.net/browse/ACCS-4382"
8761 );
8762 }
8763
8764 #[test]
8765 fn inline_card_directive_parsed_from_jfm() {
8766 let doc = markdown_to_adf("See :card[https://example.com/issue/123] for details.").unwrap();
8768 let nodes = doc.content[0].content.as_ref().unwrap();
8769 assert_eq!(nodes[0].node_type, "text");
8770 assert_eq!(nodes[0].text.as_deref(), Some("See "));
8771 assert_eq!(nodes[1].node_type, "inlineCard");
8772 assert_eq!(
8773 nodes[1].attrs.as_ref().unwrap()["url"],
8774 "https://example.com/issue/123"
8775 );
8776 assert_eq!(nodes[2].node_type, "text");
8777 assert_eq!(nodes[2].text.as_deref(), Some(" for details."));
8778 }
8779
8780 #[test]
8781 fn self_link_becomes_link_mark_not_inline_card() {
8782 let doc = markdown_to_adf("[https://example.com](https://example.com)").unwrap();
8785 let node = &doc.content[0].content.as_ref().unwrap()[0];
8786 assert_eq!(node.node_type, "text");
8787 assert_eq!(node.text.as_deref(), Some("https://example.com"));
8788 let mark = &node.marks.as_ref().unwrap()[0];
8789 assert_eq!(mark.mark_type, "link");
8790 assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
8791 }
8792
8793 #[test]
8794 fn url_link_text_with_trailing_slash_mismatch_becomes_link_mark() {
8795 let doc =
8798 markdown_to_adf("[https://octopz.example.com](https://octopz.example.com/)").unwrap();
8799 let node = &doc.content[0].content.as_ref().unwrap()[0];
8800 assert_eq!(node.node_type, "text");
8801 assert_eq!(node.text.as_deref(), Some("https://octopz.example.com"));
8802 let mark = &node.marks.as_ref().unwrap()[0];
8803 assert_eq!(mark.mark_type, "link");
8804 assert_eq!(
8805 mark.attrs.as_ref().unwrap()["href"],
8806 "https://octopz.example.com/"
8807 );
8808 }
8809
8810 #[test]
8811 fn named_link_does_not_become_inline_card() {
8812 let doc = markdown_to_adf("[#4668](https://github.com/org/repo/pull/4668)").unwrap();
8814 let node = &doc.content[0].content.as_ref().unwrap()[0];
8815 assert_eq!(node.node_type, "text");
8816 assert_eq!(node.text.as_deref(), Some("#4668"));
8817 let mark = &node.marks.as_ref().unwrap()[0];
8818 assert_eq!(mark.mark_type, "link");
8819 }
8820
8821 #[test]
8822 fn adf_inline_card_no_url_to_markdown() {
8823 let doc = AdfDocument {
8824 version: 1,
8825 doc_type: "doc".to_string(),
8826 content: vec![AdfNode::paragraph(vec![AdfNode {
8827 node_type: "inlineCard".to_string(),
8828 attrs: Some(serde_json::json!({})),
8829 content: None,
8830 text: None,
8831 marks: None,
8832 local_id: None,
8833 parameters: None,
8834 }])],
8835 };
8836 let md = adf_to_markdown(&doc).unwrap();
8837 assert!(!md.contains("<!-- unsupported inline"));
8839 }
8840
8841 #[test]
8842 fn adf_code_block_no_language_to_markdown() {
8843 let doc = AdfDocument {
8844 version: 1,
8845 doc_type: "doc".to_string(),
8846 content: vec![AdfNode::code_block(None, "plain code")],
8847 };
8848 let md = adf_to_markdown(&doc).unwrap();
8849 assert!(md.contains("```\n"));
8850 assert!(md.contains("plain code"));
8851 }
8852
8853 #[test]
8854 fn adf_code_block_empty_language_to_markdown() {
8855 let doc = AdfDocument {
8856 version: 1,
8857 doc_type: "doc".to_string(),
8858 content: vec![AdfNode::code_block(Some(""), "plain code")],
8859 };
8860 let md = adf_to_markdown(&doc).unwrap();
8861 assert!(md.contains("```\"\"\n"));
8862 assert!(md.contains("plain code"));
8863 }
8864
8865 #[test]
8868 fn round_trip_table() {
8869 let md = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
8870 let adf = markdown_to_adf(md).unwrap();
8871 let restored = adf_to_markdown(&adf).unwrap();
8872 assert!(restored.contains("| A | B |"));
8873 assert!(restored.contains("| 1 | 2 |"));
8874 }
8875
8876 #[test]
8877 fn round_trip_blockquote() {
8878 let md = "> This is quoted\n";
8879 let adf = markdown_to_adf(md).unwrap();
8880 let restored = adf_to_markdown(&adf).unwrap();
8881 assert!(restored.contains("> This is quoted"));
8882 }
8883
8884 #[test]
8885 fn round_trip_image() {
8886 let md = "\n";
8887 let adf = markdown_to_adf(md).unwrap();
8888 let restored = adf_to_markdown(&adf).unwrap();
8889 assert!(restored.contains(""));
8890 }
8891
8892 #[test]
8893 fn round_trip_ordered_list() {
8894 let md = "1. A\n2. B\n3. C\n";
8895 let adf = markdown_to_adf(md).unwrap();
8896 let restored = adf_to_markdown(&adf).unwrap();
8897 assert!(restored.contains("1. A"));
8898 assert!(restored.contains("2. B"));
8899 assert!(restored.contains("3. C"));
8900 }
8901
8902 #[test]
8903 fn round_trip_inline_marks() {
8904 let md = "Text with `code` and ~~strike~~ and [link](https://x.com).\n";
8905 let adf = markdown_to_adf(md).unwrap();
8906 let restored = adf_to_markdown(&adf).unwrap();
8907 assert!(restored.contains("`code`"));
8908 assert!(restored.contains("~~strike~~"));
8909 assert!(restored.contains("[link](https://x.com)"));
8910 }
8911
8912 #[test]
8915 fn panel_info() {
8916 let md = ":::panel{type=info}\nThis is informational.\n:::";
8917 let doc = markdown_to_adf(md).unwrap();
8918 assert_eq!(doc.content[0].node_type, "panel");
8919 assert_eq!(doc.content[0].attrs.as_ref().unwrap()["panelType"], "info");
8920 let inner = doc.content[0].content.as_ref().unwrap();
8921 assert_eq!(inner[0].node_type, "paragraph");
8922 }
8923
8924 #[test]
8925 fn adf_panel_to_markdown() {
8926 let doc = AdfDocument {
8927 version: 1,
8928 doc_type: "doc".to_string(),
8929 content: vec![AdfNode::panel(
8930 "warning",
8931 vec![AdfNode::paragraph(vec![AdfNode::text("Be careful.")])],
8932 )],
8933 };
8934 let md = adf_to_markdown(&doc).unwrap();
8935 assert!(md.contains(":::panel{type=warning}"));
8936 assert!(md.contains("Be careful."));
8937 assert!(md.contains(":::"));
8938 }
8939
8940 #[test]
8941 fn round_trip_panel() {
8942 let md = ":::panel{type=info}\nThis is informational.\n:::\n";
8943 let doc = markdown_to_adf(md).unwrap();
8944 let result = adf_to_markdown(&doc).unwrap();
8945 assert!(result.contains(":::panel{type=info}"));
8946 assert!(result.contains("This is informational."));
8947 }
8948
8949 #[test]
8950 fn expand_with_title() {
8951 let md = ":::expand{title=\"Click me\"}\nHidden content.\n:::";
8952 let doc = markdown_to_adf(md).unwrap();
8953 assert_eq!(doc.content[0].node_type, "expand");
8954 assert_eq!(doc.content[0].attrs.as_ref().unwrap()["title"], "Click me");
8955 }
8956
8957 #[test]
8958 fn adf_expand_to_markdown() {
8959 let doc = AdfDocument {
8960 version: 1,
8961 doc_type: "doc".to_string(),
8962 content: vec![AdfNode::expand(
8963 Some("Details"),
8964 vec![AdfNode::paragraph(vec![AdfNode::text("Inner.")])],
8965 )],
8966 };
8967 let md = adf_to_markdown(&doc).unwrap();
8968 assert!(md.contains(":::expand{title=\"Details\"}"));
8969 assert!(md.contains("Inner."));
8970 }
8971
8972 #[test]
8973 fn round_trip_expand() {
8974 let md = ":::expand{title=\"Details\"}\nInner content.\n:::\n";
8975 let doc = markdown_to_adf(md).unwrap();
8976 let result = adf_to_markdown(&doc).unwrap();
8977 assert!(result.contains(":::expand{title=\"Details\"}"));
8978 assert!(result.contains("Inner content."));
8979 }
8980
8981 #[test]
8982 fn layout_two_columns() {
8983 let md =
8984 "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
8985 let doc = markdown_to_adf(md).unwrap();
8986 assert_eq!(doc.content[0].node_type, "layoutSection");
8987 let columns = doc.content[0].content.as_ref().unwrap();
8988 assert_eq!(columns.len(), 2);
8989 assert_eq!(columns[0].node_type, "layoutColumn");
8990 assert_eq!(columns[1].node_type, "layoutColumn");
8991 }
8992
8993 #[test]
8994 fn adf_layout_to_markdown() {
8995 let doc = AdfDocument {
8996 version: 1,
8997 doc_type: "doc".to_string(),
8998 content: vec![AdfNode::layout_section(vec![
8999 AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Left.")])]),
9000 AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Right.")])]),
9001 ])],
9002 };
9003 let md = adf_to_markdown(&doc).unwrap();
9004 assert!(md.contains("::::layout"));
9005 assert!(md.contains(":::column{width=50}"));
9006 assert!(md.contains("Left."));
9007 assert!(md.contains("Right."));
9008 }
9009
9010 #[test]
9011 fn layout_column_localid_roundtrip() {
9012 let adf_json = r#"{
9013 "version": 1,
9014 "type": "doc",
9015 "content": [{
9016 "type": "layoutSection",
9017 "content": [
9018 {
9019 "type": "layoutColumn",
9020 "attrs": {"width": 50.0, "localId": "aabb112233cc"},
9021 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Left"}]}]
9022 },
9023 {
9024 "type": "layoutColumn",
9025 "attrs": {"width": 50.0, "localId": "ddeeff445566"},
9026 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Right"}]}]
9027 }
9028 ]
9029 }]
9030 }"#;
9031 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9032 let md = adf_to_markdown(&doc).unwrap();
9033 assert!(
9034 md.contains("localId=aabb112233cc"),
9035 "first column localId should appear in markdown: {md}"
9036 );
9037 assert!(
9038 md.contains("localId=ddeeff445566"),
9039 "second column localId should appear in markdown: {md}"
9040 );
9041 let rt = markdown_to_adf(&md).unwrap();
9042 let cols = rt.content[0].content.as_ref().unwrap();
9043 assert_eq!(
9044 cols[0].attrs.as_ref().unwrap()["localId"],
9045 "aabb112233cc",
9046 "first column localId should round-trip"
9047 );
9048 assert_eq!(
9049 cols[1].attrs.as_ref().unwrap()["localId"],
9050 "ddeeff445566",
9051 "second column localId should round-trip"
9052 );
9053 }
9054
9055 #[test]
9056 fn layout_column_without_localid() {
9057 let md =
9058 "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
9059 let doc = markdown_to_adf(md).unwrap();
9060 let cols = doc.content[0].content.as_ref().unwrap();
9061 assert!(
9062 cols[0].attrs.as_ref().unwrap().get("localId").is_none(),
9063 "column without localId should not gain one"
9064 );
9065 let md2 = adf_to_markdown(&doc).unwrap();
9066 assert!(
9067 !md2.contains("localId"),
9068 "no localId should appear in output: {md2}"
9069 );
9070 }
9071
9072 #[test]
9073 fn layout_column_localid_stripped_when_option_set() {
9074 let adf_json = r#"{
9075 "version": 1,
9076 "type": "doc",
9077 "content": [{
9078 "type": "layoutSection",
9079 "content": [{
9080 "type": "layoutColumn",
9081 "attrs": {"width": 50.0, "localId": "aabb112233cc"},
9082 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Col"}]}]
9083 }]
9084 }]
9085 }"#;
9086 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9087 let opts = RenderOptions {
9088 strip_local_ids: true,
9089 ..Default::default()
9090 };
9091 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
9092 assert!(!md.contains("localId"), "localId should be stripped: {md}");
9093 }
9094
9095 #[test]
9096 fn layout_column_localid_flush_previous() {
9097 let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nLeft.\n:::column{width=50 localId=ddeeff445566}\nRight.\n:::\n::::";
9099 let doc = markdown_to_adf(md).unwrap();
9100 let cols = doc.content[0].content.as_ref().unwrap();
9101 assert_eq!(
9102 cols[0].attrs.as_ref().unwrap()["localId"],
9103 "aabb112233cc",
9104 "flush-previous column should preserve localId"
9105 );
9106 assert_eq!(
9107 cols[1].attrs.as_ref().unwrap()["localId"],
9108 "ddeeff445566",
9109 "second column localId should be preserved"
9110 );
9111 }
9112
9113 #[test]
9114 fn layout_column_localid_flush_last() {
9115 let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nOnly column.";
9117 let doc = markdown_to_adf(md).unwrap();
9118 let cols = doc.content[0].content.as_ref().unwrap();
9119 assert_eq!(
9120 cols[0].attrs.as_ref().unwrap()["localId"],
9121 "aabb112233cc",
9122 "flush-last column should preserve localId"
9123 );
9124 }
9125
9126 #[test]
9128 fn issue_555_layout_column_fractional_width_roundtrip() {
9129 let adf_json = r#"{
9130 "version": 1,
9131 "type": "doc",
9132 "content": [{
9133 "type": "layoutSection",
9134 "content": [
9135 {
9136 "type": "layoutColumn",
9137 "attrs": {"width": 66.66},
9138 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9139 },
9140 {
9141 "type": "layoutColumn",
9142 "attrs": {"width": 33.34},
9143 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9144 }
9145 ]
9146 }]
9147 }"#;
9148 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9149 let md = adf_to_markdown(&doc).unwrap();
9150 assert!(md.contains("width=66.66"), "fractional width in md: {md}");
9151 assert!(md.contains("width=33.34"), "fractional width in md: {md}");
9152 let rt = markdown_to_adf(&md).unwrap();
9153 let cols = rt.content[0].content.as_ref().unwrap();
9154 assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 66.66);
9155 assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 33.34);
9156 }
9157
9158 #[test]
9160 fn issue_555_layout_column_five_sixths_width_roundtrip() {
9161 let adf_json = r#"{
9162 "version": 1,
9163 "type": "doc",
9164 "content": [{
9165 "type": "layoutSection",
9166 "content": [
9167 {
9168 "type": "layoutColumn",
9169 "attrs": {"width": 83.33},
9170 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9171 },
9172 {
9173 "type": "layoutColumn",
9174 "attrs": {"width": 16.67},
9175 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9176 }
9177 ]
9178 }]
9179 }"#;
9180 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9181 let md = adf_to_markdown(&doc).unwrap();
9182 let rt = markdown_to_adf(&md).unwrap();
9183 let cols = rt.content[0].content.as_ref().unwrap();
9184 assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 83.33);
9185 assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 16.67);
9186 }
9187
9188 #[test]
9190 fn issue_555_layout_column_integer_width_preserved() {
9191 let adf_json = r#"{
9192 "version": 1,
9193 "type": "doc",
9194 "content": [{
9195 "type": "layoutSection",
9196 "content": [
9197 {
9198 "type": "layoutColumn",
9199 "attrs": {"width": 50},
9200 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "A"}]}]
9201 },
9202 {
9203 "type": "layoutColumn",
9204 "attrs": {"width": 50},
9205 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "B"}]}]
9206 }
9207 ]
9208 }]
9209 }"#;
9210 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9211 let md = adf_to_markdown(&doc).unwrap();
9212 assert!(
9213 md.contains("width=50") && !md.contains("width=50."),
9214 "integer width should render without decimal: {md}"
9215 );
9216 let rt = markdown_to_adf(&md).unwrap();
9217 let cols = rt.content[0].content.as_ref().unwrap();
9218 let w0 = &cols[0].attrs.as_ref().unwrap()["width"];
9219 assert!(
9220 w0.is_i64() || w0.is_u64(),
9221 "width should remain a JSON integer, got: {w0}"
9222 );
9223 assert_eq!(w0.as_i64(), Some(50));
9224 }
9225
9226 #[test]
9228 fn issue_555_media_single_integer_width_preserved() {
9229 let adf_json = r#"{
9230 "version": 1,
9231 "type": "doc",
9232 "content": [{
9233 "type": "mediaSingle",
9234 "attrs": {"layout": "center", "width": 800},
9235 "content": [
9236 {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9237 ]
9238 }]
9239 }"#;
9240 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9241 let md = adf_to_markdown(&doc).unwrap();
9242 assert!(
9243 md.contains("width=800") && !md.contains("width=800."),
9244 "integer width should render without decimal: {md}"
9245 );
9246 let rt = markdown_to_adf(&md).unwrap();
9247 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9248 let w = &ms_attrs["width"];
9249 assert!(
9250 w.is_i64() || w.is_u64(),
9251 "mediaSingle width should remain JSON integer, got: {w}"
9252 );
9253 assert_eq!(w.as_i64(), Some(800));
9254 }
9255
9256 #[test]
9260 fn issue_555_media_single_fractional_width_preserved() {
9261 let adf_json = r#"{
9262 "version": 1,
9263 "type": "doc",
9264 "content": [{
9265 "type": "mediaSingle",
9266 "attrs": {"layout": "center", "width": 66.5},
9267 "content": [
9268 {"type": "media", "attrs": {"type": "external", "url": "https://example.com/diagram.png"}}
9269 ]
9270 }]
9271 }"#;
9272 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9273 let md = adf_to_markdown(&doc).unwrap();
9274 assert!(
9275 md.contains("width=66.5"),
9276 "fractional width must appear in JFM: {md}"
9277 );
9278 let rt = markdown_to_adf(&md).unwrap();
9279 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9280 assert_eq!(ms_attrs["width"], 66.5);
9281 }
9282
9283 #[test]
9285 fn issue_555_media_single_float_width_preserved() {
9286 let adf_json = r#"{
9287 "version": 1,
9288 "type": "doc",
9289 "content": [{
9290 "type": "mediaSingle",
9291 "attrs": {"layout": "center", "width": 800.0},
9292 "content": [
9293 {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9294 ]
9295 }]
9296 }"#;
9297 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9298 let md = adf_to_markdown(&doc).unwrap();
9299 assert!(
9300 md.contains("width=800.0"),
9301 "float width should render with decimal: {md}"
9302 );
9303 let rt = markdown_to_adf(&md).unwrap();
9304 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9305 let w = &ms_attrs["width"];
9306 assert!(
9307 w.is_f64(),
9308 "mediaSingle float width should stay a JSON float, got: {w}"
9309 );
9310 assert_eq!(w.as_f64(), Some(800.0));
9311 }
9312
9313 #[test]
9315 fn issue_555_media_single_wide_layout_integer_width_roundtrip() {
9316 let adf_json = r#"{
9317 "version": 1,
9318 "type": "doc",
9319 "content": [{
9320 "type": "mediaSingle",
9321 "attrs": {"layout": "wide", "width": 1420},
9322 "content": [
9323 {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9324 ]
9325 }]
9326 }"#;
9327 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9328 let md = adf_to_markdown(&doc).unwrap();
9329 let rt = markdown_to_adf(&md).unwrap();
9330 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9331 assert_eq!(ms_attrs["layout"], "wide");
9332 let w = &ms_attrs["width"];
9333 assert!(
9334 w.is_i64() || w.is_u64(),
9335 "mediaSingle width should remain JSON integer, got: {w}"
9336 );
9337 assert_eq!(w.as_i64(), Some(1420));
9338 }
9339
9340 #[test]
9343 fn issue_555_file_media_single_integer_width_preserved() {
9344 let adf_json = r#"{
9345 "version": 1,
9346 "type": "doc",
9347 "content": [{
9348 "type": "mediaSingle",
9349 "attrs": {"layout": "wide", "width": 1420},
9350 "content": [
9351 {"type": "media", "attrs": {"id": "abc-123", "type": "file", "collection": "col-1", "width": 1200, "height": 800}}
9352 ]
9353 }]
9354 }"#;
9355 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9356 let md = adf_to_markdown(&doc).unwrap();
9357 let rt = markdown_to_adf(&md).unwrap();
9358 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9359 let ms_w = &ms_attrs["width"];
9360 assert!(ms_w.is_i64() || ms_w.is_u64(), "ms width: {ms_w}");
9361 assert_eq!(ms_w.as_i64(), Some(1420));
9362 let media = &rt.content[0].content.as_ref().unwrap()[0];
9363 let media_attrs = media.attrs.as_ref().unwrap();
9364 let mw = &media_attrs["width"];
9365 assert!(mw.is_i64() || mw.is_u64(), "media width: {mw}");
9366 assert_eq!(mw.as_i64(), Some(1200));
9367 let mh = &media_attrs["height"];
9368 assert!(mh.is_i64() || mh.is_u64(), "media height: {mh}");
9369 assert_eq!(mh.as_i64(), Some(800));
9370 }
9371
9372 #[test]
9374 fn issue_555_fmt_numeric_attr_preserves_type() {
9375 assert_eq!(
9376 fmt_numeric_attr(&serde_json::json!(50)).as_deref(),
9377 Some("50")
9378 );
9379 assert_eq!(
9380 fmt_numeric_attr(&serde_json::json!(50.0)).as_deref(),
9381 Some("50.0")
9382 );
9383 assert_eq!(
9384 fmt_numeric_attr(&serde_json::json!(66.66)).as_deref(),
9385 Some("66.66")
9386 );
9387 assert_eq!(
9388 fmt_numeric_attr(&serde_json::json!(-5)).as_deref(),
9389 Some("-5")
9390 );
9391 assert_eq!(fmt_numeric_attr(&serde_json::json!("not a number")), None);
9392 let big = serde_json::Value::Number(serde_json::Number::from(u64::MAX));
9394 assert_eq!(
9395 fmt_numeric_attr(&big).as_deref(),
9396 Some("18446744073709551615")
9397 );
9398 assert_eq!(fmt_numeric_attr(&serde_json::Value::Null), None);
9400 }
9401
9402 #[test]
9404 fn issue_555_parse_numeric_attr_detects_type() {
9405 let v = parse_numeric_attr("800").unwrap();
9406 assert!(v.is_i64() || v.is_u64(), "'800' should parse as integer");
9407 assert_eq!(v.as_i64(), Some(800));
9408
9409 let v = parse_numeric_attr("800.0").unwrap();
9410 assert!(v.is_f64(), "'800.0' should parse as float");
9411 assert_eq!(v.as_f64(), Some(800.0));
9412
9413 let v = parse_numeric_attr("66.66").unwrap();
9414 assert!(v.is_f64());
9415 assert_eq!(v.as_f64(), Some(66.66));
9416
9417 let v = parse_numeric_attr("1e2").unwrap();
9419 assert!(v.is_f64());
9420 assert_eq!(v.as_f64(), Some(100.0));
9421 let v = parse_numeric_attr("1E2").unwrap();
9422 assert!(v.is_f64());
9423 assert_eq!(v.as_f64(), Some(100.0));
9424
9425 let v = parse_numeric_attr("-42").unwrap();
9427 assert!(v.is_i64());
9428 assert_eq!(v.as_i64(), Some(-42));
9429
9430 assert!(parse_numeric_attr("not-a-number").is_none());
9431 assert!(parse_numeric_attr("").is_none());
9432 assert!(parse_numeric_attr("1.2.3").is_none());
9433 }
9434
9435 #[test]
9438 fn issue_555_media_single_wide_layout_fractional_width_roundtrip() {
9439 let adf_json = r#"{
9440 "version": 1,
9441 "type": "doc",
9442 "content": [{
9443 "type": "mediaSingle",
9444 "attrs": {"layout": "wide", "width": 83.33},
9445 "content": [
9446 {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9447 ]
9448 }]
9449 }"#;
9450 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9451 let md = adf_to_markdown(&doc).unwrap();
9452 assert!(md.contains("layout=wide"), "layout must appear in md: {md}");
9453 assert!(md.contains("width=83.33"), "width must appear in md: {md}");
9454 let rt = markdown_to_adf(&md).unwrap();
9455 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9456 assert_eq!(ms_attrs["layout"], "wide");
9457 assert_eq!(ms_attrs["width"], 83.33);
9458 }
9459
9460 #[test]
9464 fn issue_555_file_media_single_fractional_media_width_preserved() {
9465 let adf_json = r#"{
9466 "version": 1,
9467 "type": "doc",
9468 "content": [{
9469 "type": "mediaSingle",
9470 "attrs": {"layout": "wide", "width": 66.5},
9471 "content": [
9472 {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c"}}
9473 ]
9474 }]
9475 }"#;
9476 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9477 let md = adf_to_markdown(&doc).unwrap();
9478 assert!(md.contains("mediaWidth=66.5"), "mediaWidth in md: {md}");
9479 let rt = markdown_to_adf(&md).unwrap();
9480 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9481 assert_eq!(ms_attrs["width"], 66.5);
9482 }
9483
9484 #[test]
9488 fn issue_555_file_media_fractional_inner_dimensions_preserved() {
9489 let adf_json = r#"{
9490 "version": 1,
9491 "type": "doc",
9492 "content": [{
9493 "type": "mediaSingle",
9494 "attrs": {"layout": "center"},
9495 "content": [
9496 {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c", "width": 1200.5, "height": 800.25}}
9497 ]
9498 }]
9499 }"#;
9500 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9501 let md = adf_to_markdown(&doc).unwrap();
9502 assert!(md.contains("width=1200.5"), "width in md: {md}");
9503 assert!(md.contains("height=800.25"), "height in md: {md}");
9504 let rt = markdown_to_adf(&md).unwrap();
9505 let media = &rt.content[0].content.as_ref().unwrap()[0];
9506 let attrs = media.attrs.as_ref().unwrap();
9507 assert_eq!(attrs["width"], 1200.5);
9508 assert_eq!(attrs["height"], 800.25);
9509 }
9510
9511 #[test]
9512 fn decisions_list() {
9513 let md = ":::decisions\n- <> Use PostgreSQL\n- <> REST API\n:::";
9514 let doc = markdown_to_adf(md).unwrap();
9515 assert_eq!(doc.content[0].node_type, "decisionList");
9516 let items = doc.content[0].content.as_ref().unwrap();
9517 assert_eq!(items.len(), 2);
9518 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DECIDED");
9519 }
9520
9521 #[test]
9524 fn decision_item_content_is_inline_not_paragraph() {
9525 let md = ":::decisions\n- <> Use Rust\n:::";
9526 let doc = markdown_to_adf(md).unwrap();
9527 let items = doc.content[0].content.as_ref().unwrap();
9528 let first_child = &items[0].content.as_ref().unwrap()[0];
9529 assert_eq!(
9530 first_child.node_type, "text",
9531 "decisionItem must contain inline nodes directly, not a paragraph wrapper"
9532 );
9533 assert_eq!(first_child.text.as_deref(), Some("Use Rust"));
9534 }
9535
9536 #[test]
9537 fn adf_decisions_to_markdown() {
9538 let doc = AdfDocument {
9539 version: 1,
9540 doc_type: "doc".to_string(),
9541 content: vec![AdfNode::decision_list(vec![AdfNode::decision_item(
9542 "DECIDED",
9543 vec![AdfNode::paragraph(vec![AdfNode::text("Use PostgreSQL")])],
9544 )])],
9545 };
9546 let md = adf_to_markdown(&doc).unwrap();
9547 assert!(md.contains(":::decisions"));
9548 assert!(md.contains("- <> Use PostgreSQL"));
9549 }
9550
9551 #[test]
9552 fn bodied_extension_container() {
9553 let md = ":::extension{type=com.forge key=my-macro}\nContent.\n:::";
9554 let doc = markdown_to_adf(md).unwrap();
9555 assert_eq!(doc.content[0].node_type, "bodiedExtension");
9556 assert_eq!(
9557 doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9558 "com.forge"
9559 );
9560 }
9561
9562 #[test]
9563 fn adf_bodied_extension_to_markdown() {
9564 let doc = AdfDocument {
9565 version: 1,
9566 doc_type: "doc".to_string(),
9567 content: vec![AdfNode::bodied_extension(
9568 "com.forge",
9569 "my-macro",
9570 vec![AdfNode::paragraph(vec![AdfNode::text("Content.")])],
9571 )],
9572 };
9573 let md = adf_to_markdown(&doc).unwrap();
9574 assert!(md.contains(":::extension{type=com.forge key=my-macro}"));
9575 assert!(md.contains("Content."));
9576 }
9577
9578 #[test]
9581 fn leaf_block_card() {
9582 let doc = markdown_to_adf("::card[https://example.com/browse/PROJ-123]").unwrap();
9583 assert_eq!(doc.content[0].node_type, "blockCard");
9584 assert_eq!(
9585 doc.content[0].attrs.as_ref().unwrap()["url"],
9586 "https://example.com/browse/PROJ-123"
9587 );
9588 }
9589
9590 #[test]
9591 fn adf_block_card_to_markdown() {
9592 let doc = AdfDocument {
9593 version: 1,
9594 doc_type: "doc".to_string(),
9595 content: vec![AdfNode::block_card("https://example.com/browse/PROJ-123")],
9596 };
9597 let md = adf_to_markdown(&doc).unwrap();
9598 assert!(md.contains("::card[https://example.com/browse/PROJ-123]"));
9599 }
9600
9601 #[test]
9602 fn round_trip_block_card() {
9603 let md = "::card[https://example.com/browse/PROJ-123]\n";
9604 let doc = markdown_to_adf(md).unwrap();
9605 let result = adf_to_markdown(&doc).unwrap();
9606 assert!(result.contains("::card[https://example.com/browse/PROJ-123]"));
9607 }
9608
9609 #[test]
9610 fn leaf_embed_card() {
9611 let doc =
9612 markdown_to_adf("::embed[https://figma.com/file/abc]{layout=wide width=80}").unwrap();
9613 assert_eq!(doc.content[0].node_type, "embedCard");
9614 let attrs = doc.content[0].attrs.as_ref().unwrap();
9615 assert_eq!(attrs["url"], "https://figma.com/file/abc");
9616 assert_eq!(attrs["layout"], "wide");
9617 assert_eq!(attrs["width"], 80.0);
9618 }
9619
9620 #[test]
9621 fn leaf_embed_card_with_original_height() {
9622 let doc = markdown_to_adf(
9623 "::embed[https://example.com]{layout=center originalHeight=732 width=100}",
9624 )
9625 .unwrap();
9626 assert_eq!(doc.content[0].node_type, "embedCard");
9627 let attrs = doc.content[0].attrs.as_ref().unwrap();
9628 assert_eq!(attrs["url"], "https://example.com");
9629 assert_eq!(attrs["layout"], "center");
9630 assert_eq!(attrs["originalHeight"], 732.0);
9631 assert_eq!(attrs["width"], 100.0);
9632 }
9633
9634 #[test]
9635 fn adf_embed_card_to_markdown() {
9636 let doc = AdfDocument {
9637 version: 1,
9638 doc_type: "doc".to_string(),
9639 content: vec![AdfNode::embed_card(
9640 "https://figma.com/file/abc",
9641 Some("wide"),
9642 None,
9643 Some(80.0),
9644 )],
9645 };
9646 let md = adf_to_markdown(&doc).unwrap();
9647 assert!(md.contains("::embed[https://figma.com/file/abc]{layout=wide width=80}"));
9648 }
9649
9650 #[test]
9651 fn adf_embed_card_original_height_to_markdown() {
9652 let doc = AdfDocument {
9653 version: 1,
9654 doc_type: "doc".to_string(),
9655 content: vec![AdfNode::embed_card(
9656 "https://example.com",
9657 Some("center"),
9658 Some(732.0),
9659 Some(100.0),
9660 )],
9661 };
9662 let md = adf_to_markdown(&doc).unwrap();
9663 assert!(
9664 md.contains("::embed[https://example.com]{layout=center originalHeight=732 width=100}"),
9665 "expected originalHeight and width in md: {md}"
9666 );
9667 }
9668
9669 #[test]
9670 fn embed_card_roundtrip_with_all_attrs() {
9671 let adf_json = r#"{"version":1,"type":"doc","content":[{
9672 "type":"embedCard",
9673 "attrs":{"layout":"center","originalHeight":732.0,"url":"https://example.com","width":100.0}
9674 }]}"#;
9675 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9676 let md = adf_to_markdown(&doc).unwrap();
9677 assert!(
9678 md.contains("originalHeight=732"),
9679 "originalHeight missing from md: {md}"
9680 );
9681 assert!(md.contains("width=100"), "width missing from md: {md}");
9682 let rt = markdown_to_adf(&md).unwrap();
9683 let attrs = rt.content[0].attrs.as_ref().unwrap();
9684 assert_eq!(attrs["originalHeight"], 732.0);
9685 assert_eq!(attrs["width"], 100.0);
9686 assert_eq!(attrs["layout"], "center");
9687 assert_eq!(attrs["url"], "https://example.com");
9688 }
9689
9690 #[test]
9691 fn embed_card_fractional_dimensions() {
9692 let doc = AdfDocument {
9693 version: 1,
9694 doc_type: "doc".to_string(),
9695 content: vec![AdfNode::embed_card(
9696 "https://example.com",
9697 Some("center"),
9698 Some(732.5),
9699 Some(99.9),
9700 )],
9701 };
9702 let md = adf_to_markdown(&doc).unwrap();
9703 assert!(
9704 md.contains("originalHeight=732.5"),
9705 "fractional originalHeight missing: {md}"
9706 );
9707 assert!(md.contains("width=99.9"), "fractional width missing: {md}");
9708 let rt = markdown_to_adf(&md).unwrap();
9709 let attrs = rt.content[0].attrs.as_ref().unwrap();
9710 assert_eq!(attrs["originalHeight"], 732.5);
9711 assert_eq!(attrs["width"], 99.9);
9712 }
9713
9714 #[test]
9715 fn embed_card_integer_width_in_json() {
9716 let adf_json = r#"{"version":1,"type":"doc","content":[{
9718 "type":"embedCard",
9719 "attrs":{"url":"https://example.com","width":100}
9720 }]}"#;
9721 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9722 let md = adf_to_markdown(&doc).unwrap();
9723 assert!(
9724 md.contains("width=100"),
9725 "integer width missing from md: {md}"
9726 );
9727 let rt = markdown_to_adf(&md).unwrap();
9728 assert_eq!(rt.content[0].attrs.as_ref().unwrap()["width"], 100.0);
9729 }
9730
9731 #[test]
9732 fn embed_card_only_original_height() {
9733 let adf_json = r#"{"version":1,"type":"doc","content":[{
9735 "type":"embedCard",
9736 "attrs":{"url":"https://example.com","originalHeight":500.0}
9737 }]}"#;
9738 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9739 let md = adf_to_markdown(&doc).unwrap();
9740 assert!(
9741 md.contains("originalHeight=500"),
9742 "originalHeight missing: {md}"
9743 );
9744 assert!(!md.contains("width="), "width should not appear: {md}");
9745 let rt = markdown_to_adf(&md).unwrap();
9746 let attrs = rt.content[0].attrs.as_ref().unwrap();
9747 assert_eq!(attrs["originalHeight"], 500.0);
9748 assert!(attrs.get("width").is_none());
9749 }
9750
9751 #[test]
9752 fn leaf_void_extension() {
9753 let doc = markdown_to_adf("::extension{type=com.atlassian.macro key=jira-chart}").unwrap();
9754 assert_eq!(doc.content[0].node_type, "extension");
9755 assert_eq!(
9756 doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9757 "com.atlassian.macro"
9758 );
9759 assert_eq!(
9760 doc.content[0].attrs.as_ref().unwrap()["extensionKey"],
9761 "jira-chart"
9762 );
9763 }
9764
9765 #[test]
9766 fn adf_void_extension_to_markdown() {
9767 let doc = AdfDocument {
9768 version: 1,
9769 doc_type: "doc".to_string(),
9770 content: vec![AdfNode::extension(
9771 "com.atlassian.macro",
9772 "jira-chart",
9773 None,
9774 )],
9775 };
9776 let md = adf_to_markdown(&doc).unwrap();
9777 assert!(md.contains("::extension{type=com.atlassian.macro key=jira-chart}"));
9778 }
9779
9780 #[test]
9783 fn bare_url_autolink() {
9784 let doc = markdown_to_adf("Visit https://example.com today").unwrap();
9785 let content = doc.content[0].content.as_ref().unwrap();
9786 assert_eq!(content[0].text.as_deref(), Some("Visit "));
9787 assert_eq!(content[1].node_type, "inlineCard");
9788 assert_eq!(
9789 content[1].attrs.as_ref().unwrap()["url"],
9790 "https://example.com"
9791 );
9792 assert_eq!(content[2].text.as_deref(), Some(" today"));
9793 }
9794
9795 #[test]
9796 fn bare_url_strips_trailing_punctuation() {
9797 let doc = markdown_to_adf("See https://example.com.").unwrap();
9798 let content = doc.content[0].content.as_ref().unwrap();
9799 assert_eq!(
9800 content[1].attrs.as_ref().unwrap()["url"],
9801 "https://example.com"
9802 );
9803 }
9804
9805 #[test]
9806 fn bare_url_round_trip() {
9807 let doc = markdown_to_adf("Visit https://example.com/path today").unwrap();
9808 let md = adf_to_markdown(&doc).unwrap();
9809 assert!(md.contains(":card[https://example.com/path]"));
9810 }
9811
9812 #[test]
9815 fn plain_text_url_round_trips_as_text() {
9816 let adf_json = r#"{
9819 "version": 1,
9820 "type": "doc",
9821 "content": [{
9822 "type": "paragraph",
9823 "content": [
9824 {"type": "text", "text": "https://example.com/some/path/to/resource"}
9825 ]
9826 }]
9827 }"#;
9828 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9829 let jfm = adf_to_markdown(&adf).unwrap();
9830 let roundtripped = markdown_to_adf(&jfm).unwrap();
9831 let content = roundtripped.content[0].content.as_ref().unwrap();
9832 assert_eq!(content.len(), 1, "should be a single node");
9833 assert_eq!(content[0].node_type, "text");
9834 assert_eq!(
9835 content[0].text.as_deref(),
9836 Some("https://example.com/some/path/to/resource")
9837 );
9838 }
9839
9840 #[test]
9841 fn url_text_with_link_mark_round_trips_as_text_node() {
9842 let adf_json = r#"{
9846 "version": 1,
9847 "type": "doc",
9848 "content": [{
9849 "type": "paragraph",
9850 "content": [{
9851 "type": "text",
9852 "text": "https://octopz.example.com",
9853 "marks": [{"type": "link", "attrs": {"href": "https://octopz.example.com/"}}]
9854 }]
9855 }]
9856 }"#;
9857 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9858 let jfm = adf_to_markdown(&adf).unwrap();
9859 let roundtripped = markdown_to_adf(&jfm).unwrap();
9860 let content = roundtripped.content[0].content.as_ref().unwrap();
9861 assert_eq!(content.len(), 1, "should be a single node");
9862 assert_eq!(content[0].node_type, "text", "must be text, not inlineCard");
9863 assert_eq!(
9864 content[0].text.as_deref(),
9865 Some("https://octopz.example.com")
9866 );
9867 let mark = &content[0].marks.as_ref().unwrap()[0];
9868 assert_eq!(mark.mark_type, "link");
9869 assert_eq!(
9870 mark.attrs.as_ref().unwrap()["href"],
9871 "https://octopz.example.com/"
9872 );
9873 }
9874
9875 #[test]
9876 fn url_text_with_exact_link_mark_round_trips() {
9877 let adf_json = r#"{
9879 "version": 1,
9880 "type": "doc",
9881 "content": [{
9882 "type": "paragraph",
9883 "content": [{
9884 "type": "text",
9885 "text": "https://example.com/path",
9886 "marks": [{"type": "link", "attrs": {"href": "https://example.com/path"}}]
9887 }]
9888 }]
9889 }"#;
9890 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9891 let jfm = adf_to_markdown(&adf).unwrap();
9892 let roundtripped = markdown_to_adf(&jfm).unwrap();
9893 let content = roundtripped.content[0].content.as_ref().unwrap();
9894 assert_eq!(content.len(), 1, "should be a single node");
9895 assert_eq!(content[0].node_type, "text");
9896 assert_eq!(content[0].text.as_deref(), Some("https://example.com/path"));
9897 let mark = &content[0].marks.as_ref().unwrap()[0];
9898 assert_eq!(mark.mark_type, "link");
9899 }
9900
9901 #[test]
9902 fn plain_text_url_amid_text_round_trips() {
9903 let adf_json = r#"{
9905 "version": 1,
9906 "type": "doc",
9907 "content": [{
9908 "type": "paragraph",
9909 "content": [
9910 {"type": "text", "text": "see https://example.com for info"}
9911 ]
9912 }]
9913 }"#;
9914 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9915 let jfm = adf_to_markdown(&adf).unwrap();
9916 let roundtripped = markdown_to_adf(&jfm).unwrap();
9917 let content = roundtripped.content[0].content.as_ref().unwrap();
9918 assert_eq!(content.len(), 1);
9919 assert_eq!(content[0].node_type, "text");
9920 assert_eq!(
9921 content[0].text.as_deref(),
9922 Some("see https://example.com for info")
9923 );
9924 }
9925
9926 #[test]
9927 fn plain_text_multiple_urls_round_trips() {
9928 let adf_json = r#"{
9929 "version": 1,
9930 "type": "doc",
9931 "content": [{
9932 "type": "paragraph",
9933 "content": [
9934 {"type": "text", "text": "http://a.com and https://b.com"}
9935 ]
9936 }]
9937 }"#;
9938 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9939 let jfm = adf_to_markdown(&adf).unwrap();
9940 let roundtripped = markdown_to_adf(&jfm).unwrap();
9941 let content = roundtripped.content[0].content.as_ref().unwrap();
9942 assert_eq!(content.len(), 1);
9943 assert_eq!(content[0].node_type, "text");
9944 assert_eq!(
9945 content[0].text.as_deref(),
9946 Some("http://a.com and https://b.com")
9947 );
9948 }
9949
9950 #[test]
9951 fn plain_text_http_prefix_no_url_unchanged() {
9952 let adf_json = r#"{
9954 "version": 1,
9955 "type": "doc",
9956 "content": [{
9957 "type": "paragraph",
9958 "content": [
9959 {"type": "text", "text": "the http header is important"}
9960 ]
9961 }]
9962 }"#;
9963 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9964 let jfm = adf_to_markdown(&adf).unwrap();
9965 let roundtripped = markdown_to_adf(&jfm).unwrap();
9966 let content = roundtripped.content[0].content.as_ref().unwrap();
9967 assert_eq!(
9968 content[0].text.as_deref(),
9969 Some("the http header is important")
9970 );
9971 }
9972
9973 #[test]
9974 fn linked_url_text_round_trips() {
9975 let adf_json = r#"{
9979 "version": 1,
9980 "type": "doc",
9981 "content": [{
9982 "type": "paragraph",
9983 "content": [{
9984 "type": "text",
9985 "text": "https://example.com",
9986 "marks": [{"type": "link", "attrs": {"href": "https://example.com"}}]
9987 }]
9988 }]
9989 }"#;
9990 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9991 let jfm = adf_to_markdown(&adf).unwrap();
9992 let roundtripped = markdown_to_adf(&jfm).unwrap();
9993 let content = roundtripped.content[0].content.as_ref().unwrap();
9994 assert_eq!(content.len(), 1);
9995 assert_eq!(content[0].node_type, "text");
9996 assert_eq!(content[0].text.as_deref(), Some("https://example.com"));
9997 let mark = &content[0].marks.as_ref().unwrap()[0];
9998 assert_eq!(mark.mark_type, "link");
9999 assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
10000 }
10001
10002 #[test]
10005 fn escape_link_brackets_unit() {
10006 assert_eq!(escape_link_brackets("hello"), "hello");
10007 assert_eq!(escape_link_brackets("["), "\\[");
10008 assert_eq!(escape_link_brackets("]"), "\\]");
10009 assert_eq!(escape_link_brackets("[PROJ-456]"), "\\[PROJ-456\\]");
10010 assert_eq!(escape_link_brackets("a[b]c"), "a\\[b\\]c");
10011 }
10012
10013 #[test]
10014 fn bracket_text_with_link_mark_escapes_brackets() {
10015 let doc = AdfDocument {
10018 version: 1,
10019 doc_type: "doc".to_string(),
10020 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10021 "[",
10022 vec![AdfMark::link("https://example.com")],
10023 )])],
10024 };
10025 let md = adf_to_markdown(&doc).unwrap();
10026 assert_eq!(md.trim(), "[\\[](https://example.com)");
10027 }
10028
10029 #[test]
10030 fn bracket_text_with_link_mark_round_trips() {
10031 let adf_json = r#"{
10034 "type": "doc",
10035 "version": 1,
10036 "content": [{
10037 "type": "paragraph",
10038 "content": [
10039 {
10040 "type": "text",
10041 "text": "[",
10042 "marks": [{"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}]
10043 },
10044 {
10045 "type": "text",
10046 "text": "PROJ-456] Fix the auth bug",
10047 "marks": [
10048 {"type": "underline"},
10049 {"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}
10050 ]
10051 }
10052 ]
10053 }]
10054 }"#;
10055 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10056 let jfm = adf_to_markdown(&adf).unwrap();
10057
10058 assert!(jfm.contains("\\["), "opening bracket should be escaped");
10060
10061 let rt = markdown_to_adf(&jfm).unwrap();
10063 let content = rt.content[0].content.as_ref().unwrap();
10064
10065 let link_nodes: Vec<_> = content
10067 .iter()
10068 .filter(|n| {
10069 n.marks
10070 .as_ref()
10071 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
10072 })
10073 .collect();
10074 assert!(
10075 !link_nodes.is_empty(),
10076 "link mark must be preserved on round-trip"
10077 );
10078
10079 let all_text: String = content.iter().filter_map(|n| n.text.as_deref()).collect();
10081 assert!(
10082 all_text.contains('['),
10083 "literal '[' must survive round-trip"
10084 );
10085 assert!(
10086 all_text.contains("PROJ-456]"),
10087 "continuation text must survive round-trip"
10088 );
10089 }
10090
10091 #[test]
10092 fn closing_bracket_in_link_text_round_trips() {
10093 let doc = AdfDocument {
10096 version: 1,
10097 doc_type: "doc".to_string(),
10098 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10099 "item]",
10100 vec![AdfMark::link("https://example.com")],
10101 )])],
10102 };
10103 let md = adf_to_markdown(&doc).unwrap();
10104 assert_eq!(md.trim(), "[item\\]](https://example.com)");
10105
10106 let rt = markdown_to_adf(&md).unwrap();
10107 let content = rt.content[0].content.as_ref().unwrap();
10108 assert_eq!(content[0].text.as_deref(), Some("item]"));
10109 assert!(content[0]
10110 .marks
10111 .as_ref()
10112 .unwrap()
10113 .iter()
10114 .any(|m| m.mark_type == "link"));
10115 }
10116
10117 #[test]
10118 fn brackets_in_link_text_round_trip() {
10119 let doc = AdfDocument {
10121 version: 1,
10122 doc_type: "doc".to_string(),
10123 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10124 "[PROJ-123]",
10125 vec![AdfMark::link("https://example.com")],
10126 )])],
10127 };
10128 let md = adf_to_markdown(&doc).unwrap();
10129 assert_eq!(md.trim(), "[\\[PROJ-123\\]](https://example.com)");
10130
10131 let rt = markdown_to_adf(&md).unwrap();
10132 let content = rt.content[0].content.as_ref().unwrap();
10133 assert_eq!(content[0].text.as_deref(), Some("[PROJ-123]"));
10134 assert!(content[0]
10135 .marks
10136 .as_ref()
10137 .unwrap()
10138 .iter()
10139 .any(|m| m.mark_type == "link"));
10140 }
10141
10142 #[test]
10143 fn plain_text_brackets_not_escaped() {
10144 let doc = AdfDocument {
10146 version: 1,
10147 doc_type: "doc".to_string(),
10148 content: vec![AdfNode::paragraph(vec![AdfNode::text(
10149 "see [PROJ-123] for details",
10150 )])],
10151 };
10152 let md = adf_to_markdown(&doc).unwrap();
10153 assert_eq!(md.trim(), "see [PROJ-123] for details");
10154 }
10155
10156 #[test]
10157 fn link_with_no_brackets_unchanged() {
10158 let doc = AdfDocument {
10160 version: 1,
10161 doc_type: "doc".to_string(),
10162 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10163 "click here",
10164 vec![AdfMark::link("https://example.com")],
10165 )])],
10166 };
10167 let md = adf_to_markdown(&doc).unwrap();
10168 assert_eq!(md.trim(), "[click here](https://example.com)");
10169 }
10170
10171 #[test]
10174 fn url_with_brackets_as_link_text_round_trips() {
10175 let href = "https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10180 let doc = AdfDocument {
10181 version: 1,
10182 doc_type: "doc".to_string(),
10183 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10184 href,
10185 vec![AdfMark::link(href)],
10186 )])],
10187 };
10188 let md = adf_to_markdown(&doc).unwrap();
10189 let rt = markdown_to_adf(&md).unwrap();
10190 let content = rt.content[0].content.as_ref().unwrap();
10191 assert_eq!(content.len(), 1);
10192 assert_eq!(content[0].node_type, "text");
10193 assert_eq!(content[0].text.as_deref(), Some(href));
10194 let mark = &content[0].marks.as_ref().unwrap()[0];
10195 assert_eq!(mark.mark_type, "link");
10196 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10197 }
10198
10199 #[test]
10200 fn url_with_brackets_embedded_in_link_text_round_trips() {
10201 let href = "https://example.com/logs?query=service%20environment%20data&from=100&to=200";
10208 let text =
10209 "See the logs: https://example.com/logs?query=service[\u{2026}]data&from=100&to=200";
10210 let doc = AdfDocument {
10211 version: 1,
10212 doc_type: "doc".to_string(),
10213 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10214 text,
10215 vec![AdfMark::link(href)],
10216 )])],
10217 };
10218 let md = adf_to_markdown(&doc).unwrap();
10219 let rt = markdown_to_adf(&md).unwrap();
10220 let content = rt.content[0].content.as_ref().unwrap();
10221 assert_eq!(content.len(), 1, "content split unexpectedly: {content:?}");
10222 assert_eq!(content[0].node_type, "text");
10223 assert_eq!(content[0].text.as_deref(), Some(text));
10224 let mark = &content[0].marks.as_ref().unwrap()[0];
10225 assert_eq!(mark.mark_type, "link");
10226 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10227 }
10228
10229 #[test]
10230 fn url_with_brackets_plain_text_round_trips() {
10231 let text =
10234 "See the dashboard: https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10235 let doc = AdfDocument {
10236 version: 1,
10237 doc_type: "doc".to_string(),
10238 content: vec![AdfNode::paragraph(vec![AdfNode::text(text)])],
10239 };
10240 let md = adf_to_markdown(&doc).unwrap();
10241 let rt = markdown_to_adf(&md).unwrap();
10242 let content = rt.content[0].content.as_ref().unwrap();
10243 assert_eq!(content.len(), 1);
10244 assert_eq!(content[0].node_type, "text");
10245 assert_eq!(content[0].text.as_deref(), Some(text));
10246 assert!(content[0].marks.is_none());
10247 }
10248
10249 #[test]
10250 fn url_with_link_mark_embedded_no_brackets_round_trips() {
10251 let href = "https://example.com/";
10254 let text = "See https://example.com/ for more";
10255 let doc = AdfDocument {
10256 version: 1,
10257 doc_type: "doc".to_string(),
10258 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10259 text,
10260 vec![AdfMark::link(href)],
10261 )])],
10262 };
10263 let md = adf_to_markdown(&doc).unwrap();
10264 let rt = markdown_to_adf(&md).unwrap();
10265 let content = rt.content[0].content.as_ref().unwrap();
10266 assert_eq!(content.len(), 1);
10267 assert_eq!(content[0].node_type, "text");
10268 assert_eq!(content[0].text.as_deref(), Some(text));
10269 let mark = &content[0].marks.as_ref().unwrap()[0];
10270 assert_eq!(mark.mark_type, "link");
10271 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10272 }
10273
10274 #[test]
10275 fn nested_brackets_in_link_text_round_trip() {
10276 let href = "https://x.com";
10279 let text = "foo [a[b]c] bar";
10280 let doc = AdfDocument {
10281 version: 1,
10282 doc_type: "doc".to_string(),
10283 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10284 text,
10285 vec![AdfMark::link(href)],
10286 )])],
10287 };
10288 let md = adf_to_markdown(&doc).unwrap();
10289 let rt = markdown_to_adf(&md).unwrap();
10290 let content = rt.content[0].content.as_ref().unwrap();
10291 assert_eq!(content.len(), 1);
10292 assert_eq!(content[0].node_type, "text");
10293 assert_eq!(content[0].text.as_deref(), Some(text));
10294 }
10295
10296 #[test]
10297 fn bracket_url_bracket_in_link_text_round_trips() {
10298 let href = "https://y.com";
10304 let text = "[see https://x.com/a[0]=1 here]";
10305 let doc = AdfDocument {
10306 version: 1,
10307 doc_type: "doc".to_string(),
10308 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10309 text,
10310 vec![AdfMark::link(href)],
10311 )])],
10312 };
10313 let md = adf_to_markdown(&doc).unwrap();
10314 let rt = markdown_to_adf(&md).unwrap();
10315 let content = rt.content[0].content.as_ref().unwrap();
10316 assert_eq!(content.len(), 1);
10317 assert_eq!(content[0].node_type, "text");
10318 assert_eq!(content[0].text.as_deref(), Some(text));
10319 let mark = &content[0].marks.as_ref().unwrap()[0];
10320 assert_eq!(mark.mark_type, "link");
10321 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10322 }
10323
10324 #[test]
10325 fn escape_bare_urls_applied_inside_link_text() {
10326 let doc = AdfDocument {
10332 version: 1,
10333 doc_type: "doc".to_string(),
10334 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10335 "See https://example.com/",
10336 vec![AdfMark::link("https://target.example.com/")],
10337 )])],
10338 };
10339 let md = adf_to_markdown(&doc).unwrap();
10340 assert!(
10341 md.contains(r"\https://example.com/"),
10342 "bare URL inside link text must be escaped, got: {md}"
10343 );
10344 }
10345
10346 #[test]
10347 fn inline_card_still_round_trips() {
10348 let adf_json = r#"{
10351 "version": 1,
10352 "type": "doc",
10353 "content": [{
10354 "type": "paragraph",
10355 "content": [
10356 {"type": "inlineCard", "attrs": {"url": "https://example.com/page"}}
10357 ]
10358 }]
10359 }"#;
10360 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10361 let jfm = adf_to_markdown(&adf).unwrap();
10362 assert!(jfm.contains(":card[https://example.com/page]"));
10363 let roundtripped = markdown_to_adf(&jfm).unwrap();
10364 let content = roundtripped.content[0].content.as_ref().unwrap();
10365 assert_eq!(content[0].node_type, "inlineCard");
10366 assert_eq!(
10367 content[0].attrs.as_ref().unwrap()["url"],
10368 "https://example.com/page"
10369 );
10370 }
10371
10372 #[test]
10375 fn url_safe_in_bracket_content_balanced() {
10376 assert!(url_safe_in_bracket_content("https://example.com"));
10378 assert!(url_safe_in_bracket_content("https://example.com/[id]"));
10379 assert!(url_safe_in_bracket_content("a[b[c]d]e"));
10380 assert!(url_safe_in_bracket_content(""));
10381 }
10382
10383 #[test]
10384 fn url_safe_in_bracket_content_unbalanced() {
10385 assert!(!url_safe_in_bracket_content("a]b"));
10387 assert!(!url_safe_in_bracket_content("https://example.com/path]end"));
10388 assert!(!url_safe_in_bracket_content("a\nb"));
10390 }
10391
10392 #[test]
10393 fn inline_card_url_with_closing_bracket_round_trip() {
10394 let adf_json = r#"{
10399 "version": 1,
10400 "type": "doc",
10401 "content": [{
10402 "type": "paragraph",
10403 "content": [
10404 {"type": "text", "text": "See: "},
10405 {"type": "inlineCard", "attrs": {"url": "https://example.com/path]end/?q=1"}}
10406 ]
10407 }]
10408 }"#;
10409 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10410 let jfm = adf_to_markdown(&adf).unwrap();
10411 assert!(
10412 jfm.contains(r#":card[]{url="https://example.com/path]end/?q=1"}"#),
10413 "expected attr-form for URL with `]`, got: {jfm}"
10414 );
10415 let rt = markdown_to_adf(&jfm).unwrap();
10416 let content = rt.content[0].content.as_ref().unwrap();
10417 assert_eq!(content.len(), 2, "expected 2 inline nodes, got {content:?}");
10418 assert_eq!(content[0].node_type, "text");
10419 assert_eq!(content[0].text.as_deref(), Some("See: "));
10420 assert_eq!(content[1].node_type, "inlineCard");
10421 assert_eq!(
10422 content[1].attrs.as_ref().unwrap()["url"],
10423 "https://example.com/path]end/?q=1"
10424 );
10425 }
10426
10427 #[test]
10428 fn inline_card_url_with_closing_bracket_preserves_local_id() {
10429 let adf_json = r#"{
10431 "version": 1,
10432 "type": "doc",
10433 "content": [{
10434 "type": "paragraph",
10435 "content": [
10436 {"type": "inlineCard", "attrs": {
10437 "url": "https://example.com/a]b",
10438 "localId": "c-77"
10439 }}
10440 ]
10441 }]
10442 }"#;
10443 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10444 let jfm = adf_to_markdown(&adf).unwrap();
10445 assert!(
10446 jfm.contains(r#"url="https://example.com/a]b""#),
10447 "jfm: {jfm}"
10448 );
10449 assert!(jfm.contains("localId=c-77"), "jfm: {jfm}");
10450 let rt = markdown_to_adf(&jfm).unwrap();
10451 let card = &rt.content[0].content.as_ref().unwrap()[0];
10452 assert_eq!(card.node_type, "inlineCard");
10453 assert_eq!(
10454 card.attrs.as_ref().unwrap()["url"],
10455 "https://example.com/a]b"
10456 );
10457 assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-77");
10458 }
10459
10460 #[test]
10461 fn block_card_url_with_closing_bracket_round_trip() {
10462 let adf_json = r#"{
10464 "version": 1,
10465 "type": "doc",
10466 "content": [
10467 {"type": "blockCard", "attrs": {"url": "https://example.com/path]end"}}
10468 ]
10469 }"#;
10470 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10471 let jfm = adf_to_markdown(&adf).unwrap();
10472 assert!(
10473 jfm.contains(r#"::card[]{url="https://example.com/path]end"}"#),
10474 "expected attr-form for blockCard with `]`, got: {jfm}"
10475 );
10476 let rt = markdown_to_adf(&jfm).unwrap();
10477 assert_eq!(rt.content[0].node_type, "blockCard");
10478 assert_eq!(
10479 rt.content[0].attrs.as_ref().unwrap()["url"],
10480 "https://example.com/path]end"
10481 );
10482 }
10483
10484 #[test]
10485 fn block_card_attr_form_parses_without_renderer() {
10486 let doc = markdown_to_adf(r#"::card[]{url="https://example.com/a"}"#).unwrap();
10490 assert_eq!(doc.content[0].node_type, "blockCard");
10491 assert_eq!(
10492 doc.content[0].attrs.as_ref().unwrap()["url"],
10493 "https://example.com/a"
10494 );
10495 }
10496
10497 #[test]
10498 fn block_card_attr_form_url_overrides_content() {
10499 let doc =
10503 markdown_to_adf(r#"::card[https://old.example.com]{url="https://new.example.com"}"#)
10504 .unwrap();
10505 assert_eq!(doc.content[0].node_type, "blockCard");
10506 assert_eq!(
10507 doc.content[0].attrs.as_ref().unwrap()["url"],
10508 "https://new.example.com"
10509 );
10510 }
10511
10512 #[test]
10513 fn block_card_attr_form_with_layout_and_width() {
10514 let doc =
10517 markdown_to_adf(r#"::card[]{url="https://example.com/a]b" layout=wide width=80}"#)
10518 .unwrap();
10519 let attrs = doc.content[0].attrs.as_ref().unwrap();
10520 assert_eq!(attrs["url"], "https://example.com/a]b");
10521 assert_eq!(attrs["layout"], "wide");
10522 assert_eq!(attrs["width"], 80);
10523 }
10524
10525 #[test]
10526 fn inline_card_issue_553_reproducer() {
10527 let adf_json = r#"{
10531 "version": 1,
10532 "type": "doc",
10533 "content": [{
10534 "type": "paragraph",
10535 "content": [
10536 {"type": "text", "text": "See the related page: "},
10537 {"type": "inlineCard", "attrs": {
10538 "url": "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10539 }}
10540 ]
10541 }]
10542 }"#;
10543 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10544 let jfm = adf_to_markdown(&adf).unwrap();
10545 let rt = markdown_to_adf(&jfm).unwrap();
10546 let content = rt.content[0].content.as_ref().unwrap();
10547 assert_eq!(content.len(), 2);
10548 assert_eq!(content[0].node_type, "text");
10549 assert_eq!(content[1].node_type, "inlineCard");
10550 assert_eq!(
10551 content[1].attrs.as_ref().unwrap()["url"],
10552 "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10553 );
10554 }
10555
10556 #[test]
10557 fn inline_card_attr_form_parses_even_without_renderer() {
10558 let doc = markdown_to_adf(r#":card[]{url="https://example.com/a"}"#).unwrap();
10560 let node = &doc.content[0].content.as_ref().unwrap()[0];
10561 assert_eq!(node.node_type, "inlineCard");
10562 assert_eq!(node.attrs.as_ref().unwrap()["url"], "https://example.com/a");
10563 }
10564
10565 #[test]
10566 fn inline_card_attr_form_url_overrides_content() {
10567 let doc =
10571 markdown_to_adf(r#":card[https://old.example.com]{url="https://new.example.com"}"#)
10572 .unwrap();
10573 let node = &doc.content[0].content.as_ref().unwrap()[0];
10574 assert_eq!(node.node_type, "inlineCard");
10575 assert_eq!(
10576 node.attrs.as_ref().unwrap()["url"],
10577 "https://new.example.com"
10578 );
10579 }
10580
10581 #[test]
10584 fn url_with_link_and_underline_marks_round_trip() {
10585 let adf_json = r#"{
10589 "version": 1,
10590 "type": "doc",
10591 "content": [{
10592 "type": "paragraph",
10593 "content": [
10594 {"type": "text", "text": "See results at: "},
10595 {"type": "text",
10596 "text": "https://example.com/projects/abc123/analytics",
10597 "marks": [
10598 {"type": "link", "attrs": {"href": "https://example.com/projects/abc123/analytics"}},
10599 {"type": "underline"}
10600 ]},
10601 {"type": "text", "text": " for details."}
10602 ]
10603 }]
10604 }"#;
10605 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10606 let jfm = adf_to_markdown(&adf).unwrap();
10607 let rt = markdown_to_adf(&jfm).unwrap();
10608 let content = rt.content[0].content.as_ref().unwrap();
10609 assert_eq!(
10610 content.len(),
10611 3,
10612 "expected 3 inline nodes, got: {content:?}"
10613 );
10614 assert_eq!(content[0].node_type, "text");
10615 assert_eq!(
10616 content[1].node_type, "text",
10617 "must stay text, not inlineCard"
10618 );
10619 assert_eq!(
10620 content[1].text.as_deref(),
10621 Some("https://example.com/projects/abc123/analytics")
10622 );
10623 let mark_types: Vec<&str> = content[1]
10624 .marks
10625 .as_deref()
10626 .unwrap_or(&[])
10627 .iter()
10628 .map(|m| m.mark_type.as_str())
10629 .collect();
10630 assert_eq!(mark_types, vec!["link", "underline"], "marks lost");
10631 assert_eq!(content[2].node_type, "text");
10632 }
10633
10634 #[test]
10635 fn url_inside_bracketed_span_stays_text() {
10636 let doc = markdown_to_adf("[https://example.com]{underline}").unwrap();
10640 let node = &doc.content[0].content.as_ref().unwrap()[0];
10641 assert_eq!(node.node_type, "text");
10642 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10643 let mark_types: Vec<&str> = node
10644 .marks
10645 .as_deref()
10646 .unwrap_or(&[])
10647 .iter()
10648 .map(|m| m.mark_type.as_str())
10649 .collect();
10650 assert_eq!(mark_types, vec!["underline"]);
10651 }
10652
10653 #[test]
10654 fn url_inside_emphasis_stays_text() {
10655 for (md, mark) in [
10658 ("**https://example.com**", "strong"),
10659 ("*https://example.com*", "em"),
10660 ("~~https://example.com~~", "strike"),
10661 ] {
10662 let doc = markdown_to_adf(md).unwrap();
10663 let node = &doc.content[0].content.as_ref().unwrap()[0];
10664 assert_eq!(node.node_type, "text", "md={md}: must be text");
10665 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10666 let mark_types: Vec<&str> = node
10667 .marks
10668 .as_deref()
10669 .unwrap_or(&[])
10670 .iter()
10671 .map(|m| m.mark_type.as_str())
10672 .collect();
10673 assert_eq!(mark_types, vec![mark], "md={md}: wrong marks");
10674 }
10675 }
10676
10677 #[test]
10678 fn url_inside_span_directive_stays_text() {
10679 let doc = markdown_to_adf(":span[https://example.com]{color=red}").unwrap();
10681 let node = &doc.content[0].content.as_ref().unwrap()[0];
10682 assert_eq!(node.node_type, "text");
10683 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10684 let mark = &node.marks.as_ref().unwrap()[0];
10685 assert_eq!(mark.mark_type, "textColor");
10686 }
10687
10688 #[test]
10689 fn url_as_link_text_with_underline_after_link_mark_order() {
10690 let adf_json = r#"{
10694 "version": 1,
10695 "type": "doc",
10696 "content": [{
10697 "type": "paragraph",
10698 "content": [
10699 {"type": "text",
10700 "text": "https://example.com",
10701 "marks": [
10702 {"type": "underline"},
10703 {"type": "link", "attrs": {"href": "https://example.com"}}
10704 ]}
10705 ]
10706 }]
10707 }"#;
10708 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10709 let jfm = adf_to_markdown(&adf).unwrap();
10710 let rt = markdown_to_adf(&jfm).unwrap();
10711 let node = &rt.content[0].content.as_ref().unwrap()[0];
10712 assert_eq!(node.node_type, "text", "must stay text, got: {node:?}");
10713 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10714 let mark_types: Vec<&str> = node
10715 .marks
10716 .as_deref()
10717 .unwrap_or(&[])
10718 .iter()
10719 .map(|m| m.mark_type.as_str())
10720 .collect();
10721 assert_eq!(mark_types, vec!["underline", "link"]);
10722 }
10723
10724 #[test]
10725 fn bare_url_at_top_level_still_becomes_inline_card() {
10726 let doc = markdown_to_adf("Visit https://example.com today").unwrap();
10730 let content = doc.content[0].content.as_ref().unwrap();
10731 assert_eq!(content.len(), 3);
10732 assert_eq!(content[0].node_type, "text");
10733 assert_eq!(content[1].node_type, "inlineCard");
10734 assert_eq!(
10735 content[1].attrs.as_ref().unwrap()["url"],
10736 "https://example.com"
10737 );
10738 assert_eq!(content[2].node_type, "text");
10739 }
10740
10741 #[test]
10744 fn paragraph_align_center() {
10745 let md = "Centered text.\n{align=center}";
10746 let doc = markdown_to_adf(md).unwrap();
10747 let marks = doc.content[0].marks.as_ref().unwrap();
10748 assert_eq!(marks[0].mark_type, "alignment");
10749 assert_eq!(marks[0].attrs.as_ref().unwrap()["align"], "center");
10750 }
10751
10752 #[test]
10753 fn adf_alignment_to_markdown() {
10754 let mut node = AdfNode::paragraph(vec![AdfNode::text("Centered.")]);
10755 node.marks = Some(vec![AdfMark::alignment("center")]);
10756 let doc = AdfDocument {
10757 version: 1,
10758 doc_type: "doc".to_string(),
10759 content: vec![node],
10760 };
10761 let md = adf_to_markdown(&doc).unwrap();
10762 assert!(md.contains("Centered."));
10763 assert!(md.contains("{align=center}"));
10764 }
10765
10766 #[test]
10767 fn round_trip_alignment() {
10768 let md = "Centered.\n{align=center}\n";
10769 let doc = markdown_to_adf(md).unwrap();
10770 let result = adf_to_markdown(&doc).unwrap();
10771 assert!(result.contains("{align=center}"));
10772 }
10773
10774 #[test]
10775 fn paragraph_indent() {
10776 let md = "Indented.\n{indent=2}";
10777 let doc = markdown_to_adf(md).unwrap();
10778 let marks = doc.content[0].marks.as_ref().unwrap();
10779 assert_eq!(marks[0].mark_type, "indentation");
10780 assert_eq!(marks[0].attrs.as_ref().unwrap()["level"], 2);
10781 }
10782
10783 #[test]
10784 fn code_block_breakout() {
10785 let md = "```python\ndef f(): pass\n```\n{breakout=wide}";
10786 let doc = markdown_to_adf(md).unwrap();
10787 let marks = doc.content[0].marks.as_ref().unwrap();
10788 assert_eq!(marks[0].mark_type, "breakout");
10789 assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
10790 assert!(marks[0].attrs.as_ref().unwrap().get("width").is_none());
10791 }
10792
10793 #[test]
10794 fn code_block_breakout_with_width() {
10795 let md = "```python\ndef f(): pass\n```\n{breakout=wide breakoutWidth=1200}";
10796 let doc = markdown_to_adf(md).unwrap();
10797 let marks = doc.content[0].marks.as_ref().unwrap();
10798 assert_eq!(marks[0].mark_type, "breakout");
10799 assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
10800 assert_eq!(marks[0].attrs.as_ref().unwrap()["width"], 1200);
10801 }
10802
10803 #[test]
10804 fn adf_breakout_to_markdown() {
10805 let mut node = AdfNode::code_block(Some("python"), "pass");
10806 node.marks = Some(vec![AdfMark::breakout("wide", None)]);
10807 let doc = AdfDocument {
10808 version: 1,
10809 doc_type: "doc".to_string(),
10810 content: vec![node],
10811 };
10812 let md = adf_to_markdown(&doc).unwrap();
10813 assert!(md.contains("{breakout=wide}"));
10814 assert!(!md.contains("breakoutWidth"));
10815 }
10816
10817 #[test]
10818 fn adf_breakout_with_width_to_markdown() {
10819 let mut node = AdfNode::code_block(Some("python"), "pass");
10820 node.marks = Some(vec![AdfMark::breakout("wide", Some(1200))]);
10821 let doc = AdfDocument {
10822 version: 1,
10823 doc_type: "doc".to_string(),
10824 content: vec![node],
10825 };
10826 let md = adf_to_markdown(&doc).unwrap();
10827 assert!(md.contains("breakout=wide"));
10828 assert!(md.contains("breakoutWidth=1200"));
10829 }
10830
10831 #[test]
10832 fn breakout_width_round_trip() {
10833 let adf_json = r#"{"version":1,"type":"doc","content":[{
10834 "type":"codeBlock",
10835 "attrs":{"language":"text"},
10836 "marks":[{"type":"breakout","attrs":{"mode":"wide","width":1200}}],
10837 "content":[{"type":"text","text":"some code"}]
10838 }]}"#;
10839 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10840 let md = adf_to_markdown(&doc).unwrap();
10841 assert!(md.contains("breakout=wide"));
10842 assert!(md.contains("breakoutWidth=1200"));
10843 let round_tripped = markdown_to_adf(&md).unwrap();
10844 let marks = round_tripped.content[0].marks.as_ref().unwrap();
10845 let breakout = marks.iter().find(|m| m.mark_type == "breakout").unwrap();
10846 assert_eq!(breakout.attrs.as_ref().unwrap()["mode"], "wide");
10847 assert_eq!(breakout.attrs.as_ref().unwrap()["width"], 1200);
10848 }
10849
10850 #[test]
10853 fn image_with_layout_attrs() {
10854 let doc = markdown_to_adf("{layout=wide width=80}").unwrap();
10855 let node = &doc.content[0];
10856 assert_eq!(node.node_type, "mediaSingle");
10857 let attrs = node.attrs.as_ref().unwrap();
10858 assert_eq!(attrs["layout"], "wide");
10859 assert_eq!(attrs["width"], 80);
10860 }
10861
10862 #[test]
10863 fn adf_image_with_layout_to_markdown() {
10864 let mut node = AdfNode::media_single("url", Some("alt"));
10865 node.attrs.as_mut().unwrap()["layout"] = serde_json::json!("wide");
10866 node.attrs.as_mut().unwrap()["width"] = serde_json::json!(80);
10867 let doc = AdfDocument {
10868 version: 1,
10869 doc_type: "doc".to_string(),
10870 content: vec![node],
10871 };
10872 let md = adf_to_markdown(&doc).unwrap();
10873 assert!(md.contains("{layout=wide width=80}"));
10874 }
10875
10876 #[test]
10877 fn table_with_layout_attrs() {
10878 let md = "| H |\n| --- |\n| C |\n{layout=wide numbered}";
10879 let doc = markdown_to_adf(md).unwrap();
10880 let table = &doc.content[0];
10881 assert_eq!(table.node_type, "table");
10882 let attrs = table.attrs.as_ref().unwrap();
10883 assert_eq!(attrs["layout"], "wide");
10884 assert_eq!(attrs["isNumberColumnEnabled"], true);
10885 }
10886
10887 #[test]
10888 fn adf_table_with_attrs_to_markdown() {
10889 let mut table = AdfNode::table(vec![
10890 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
10891 AdfNode::text("H"),
10892 ])])]),
10893 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
10894 AdfNode::text("C"),
10895 ])])]),
10896 ]);
10897 table.attrs = Some(serde_json::json!({"layout": "wide", "isNumberColumnEnabled": true}));
10898 let doc = AdfDocument {
10899 version: 1,
10900 doc_type: "doc".to_string(),
10901 content: vec![table],
10902 };
10903 let md = adf_to_markdown(&doc).unwrap();
10904 assert!(md.contains("{layout=wide numbered}"));
10905 }
10906
10907 #[test]
10910 fn underline_bracketed_span() {
10911 let doc = markdown_to_adf("This is [underlined text]{underline} here.").unwrap();
10912 let content = doc.content[0].content.as_ref().unwrap();
10913 assert_eq!(content[1].text.as_deref(), Some("underlined text"));
10914 let marks = content[1].marks.as_ref().unwrap();
10915 assert_eq!(marks[0].mark_type, "underline");
10916 }
10917
10918 #[test]
10919 fn adf_underline_to_markdown() {
10920 let doc = AdfDocument {
10921 version: 1,
10922 doc_type: "doc".to_string(),
10923 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10924 "underlined",
10925 vec![AdfMark::underline()],
10926 )])],
10927 };
10928 let md = adf_to_markdown(&doc).unwrap();
10929 assert!(md.contains("[underlined]{underline}"));
10930 }
10931
10932 #[test]
10933 fn round_trip_underline() {
10934 let md = "This is [underlined text]{underline} here.\n";
10935 let doc = markdown_to_adf(md).unwrap();
10936 let result = adf_to_markdown(&doc).unwrap();
10937 assert!(result.contains("[underlined text]{underline}"));
10938 }
10939
10940 #[test]
10941 fn mark_ordering_underline_strong_preserved() {
10942 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10944 {"type":"text","text":"bold and underlined","marks":[{"type":"underline"},{"type":"strong"}]}
10945 ]}]}"#;
10946 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10947 let md = adf_to_markdown(&doc).unwrap();
10948 let round_tripped = markdown_to_adf(&md).unwrap();
10949 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10950 let mark_types: Vec<&str> = node
10951 .marks
10952 .as_ref()
10953 .unwrap()
10954 .iter()
10955 .map(|m| m.mark_type.as_str())
10956 .collect();
10957 assert_eq!(
10958 mark_types,
10959 vec!["underline", "strong"],
10960 "mark order should be preserved, got: {mark_types:?}"
10961 );
10962 }
10963
10964 #[test]
10965 fn mark_ordering_link_strong_preserved() {
10966 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10968 {"type":"text","text":"bold link","marks":[
10969 {"type":"link","attrs":{"href":"https://example.com"}},
10970 {"type":"strong"}
10971 ]}
10972 ]}]}"#;
10973 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10974 let md = adf_to_markdown(&doc).unwrap();
10975 let round_tripped = markdown_to_adf(&md).unwrap();
10976 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10977 let mark_types: Vec<&str> = node
10978 .marks
10979 .as_ref()
10980 .unwrap()
10981 .iter()
10982 .map(|m| m.mark_type.as_str())
10983 .collect();
10984 assert_eq!(
10985 mark_types,
10986 vec!["link", "strong"],
10987 "mark order should be preserved, got: {mark_types:?}"
10988 );
10989 }
10990
10991 #[test]
10992 fn mark_ordering_link_textcolor_preserved() {
10993 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10995 {"type":"text","text":"red link","marks":[
10996 {"type":"link","attrs":{"href":"https://example.com"}},
10997 {"type":"textColor","attrs":{"color":"#ff0000"}}
10998 ]}
10999 ]}]}"##;
11000 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11001 let md = adf_to_markdown(&doc).unwrap();
11002 let round_tripped = markdown_to_adf(&md).unwrap();
11003 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11004 let mark_types: Vec<&str> = node
11005 .marks
11006 .as_ref()
11007 .unwrap()
11008 .iter()
11009 .map(|m| m.mark_type.as_str())
11010 .collect();
11011 assert_eq!(
11012 mark_types,
11013 vec!["link", "textColor"],
11014 "mark order should be preserved, got: {mark_types:?}"
11015 );
11016 }
11017
11018 #[test]
11019 fn mark_ordering_link_em_preserved() {
11020 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11022 {"type":"text","text":"italic link","marks":[
11023 {"type":"link","attrs":{"href":"https://example.com"}},
11024 {"type":"em"}
11025 ]}
11026 ]}]}"#;
11027 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11028 let md = adf_to_markdown(&doc).unwrap();
11029 let round_tripped = markdown_to_adf(&md).unwrap();
11030 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11031 let mark_types: Vec<&str> = node
11032 .marks
11033 .as_ref()
11034 .unwrap()
11035 .iter()
11036 .map(|m| m.mark_type.as_str())
11037 .collect();
11038 assert_eq!(
11039 mark_types,
11040 vec!["link", "em"],
11041 "mark order should be preserved, got: {mark_types:?}"
11042 );
11043 }
11044
11045 #[test]
11046 fn mark_ordering_link_strike_preserved() {
11047 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11049 {"type":"text","text":"struck link","marks":[
11050 {"type":"link","attrs":{"href":"https://example.com"}},
11051 {"type":"strike"}
11052 ]}
11053 ]}]}"#;
11054 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11055 let md = adf_to_markdown(&doc).unwrap();
11056 let round_tripped = markdown_to_adf(&md).unwrap();
11057 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11058 let mark_types: Vec<&str> = node
11059 .marks
11060 .as_ref()
11061 .unwrap()
11062 .iter()
11063 .map(|m| m.mark_type.as_str())
11064 .collect();
11065 assert_eq!(
11066 mark_types,
11067 vec!["link", "strike"],
11068 "mark order should be preserved, got: {mark_types:?}"
11069 );
11070 }
11071
11072 #[test]
11073 fn mark_ordering_strong_link_preserved() {
11074 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11076 {"type":"text","text":"bold link","marks":[
11077 {"type":"strong"},
11078 {"type":"link","attrs":{"href":"https://example.com"}}
11079 ]}
11080 ]}]}"#;
11081 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11082 let md = adf_to_markdown(&doc).unwrap();
11083 let round_tripped = markdown_to_adf(&md).unwrap();
11084 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11085 let mark_types: Vec<&str> = node
11086 .marks
11087 .as_ref()
11088 .unwrap()
11089 .iter()
11090 .map(|m| m.mark_type.as_str())
11091 .collect();
11092 assert_eq!(
11093 mark_types,
11094 vec!["strong", "link"],
11095 "mark order should be preserved, got: {mark_types:?}"
11096 );
11097 }
11098
11099 #[test]
11100 fn mark_ordering_em_link_preserved() {
11101 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11103 {"type":"text","text":"italic link","marks":[
11104 {"type":"em"},
11105 {"type":"link","attrs":{"href":"https://example.com"}}
11106 ]}
11107 ]}]}"#;
11108 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11109 let md = adf_to_markdown(&doc).unwrap();
11110 let round_tripped = markdown_to_adf(&md).unwrap();
11111 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11112 let mark_types: Vec<&str> = node
11113 .marks
11114 .as_ref()
11115 .unwrap()
11116 .iter()
11117 .map(|m| m.mark_type.as_str())
11118 .collect();
11119 assert_eq!(
11120 mark_types,
11121 vec!["em", "link"],
11122 "mark order should be preserved, got: {mark_types:?}"
11123 );
11124 }
11125
11126 #[test]
11127 fn mark_ordering_strike_link_preserved() {
11128 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11130 {"type":"text","text":"struck link","marks":[
11131 {"type":"strike"},
11132 {"type":"link","attrs":{"href":"https://example.com"}}
11133 ]}
11134 ]}]}"#;
11135 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11136 let md = adf_to_markdown(&doc).unwrap();
11137 let round_tripped = markdown_to_adf(&md).unwrap();
11138 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11139 let mark_types: Vec<&str> = node
11140 .marks
11141 .as_ref()
11142 .unwrap()
11143 .iter()
11144 .map(|m| m.mark_type.as_str())
11145 .collect();
11146 assert_eq!(
11147 mark_types,
11148 vec!["strike", "link"],
11149 "mark order should be preserved, got: {mark_types:?}"
11150 );
11151 }
11152
11153 #[test]
11154 fn mark_ordering_underline_link_preserved() {
11155 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11157 {"type":"text","text":"click here","marks":[
11158 {"type":"underline"},
11159 {"type":"link","attrs":{"href":"https://example.com"}}
11160 ]}
11161 ]}]}"#;
11162 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11163 let md = adf_to_markdown(&doc).unwrap();
11164 let round_tripped = markdown_to_adf(&md).unwrap();
11165 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11166 let mark_types: Vec<&str> = node
11167 .marks
11168 .as_ref()
11169 .unwrap()
11170 .iter()
11171 .map(|m| m.mark_type.as_str())
11172 .collect();
11173 assert_eq!(
11174 mark_types,
11175 vec!["underline", "link"],
11176 "mark order should be preserved, got: {mark_types:?}"
11177 );
11178 }
11179
11180 #[test]
11181 fn mark_ordering_textcolor_link_preserved() {
11182 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11184 {"type":"text","text":"red link","marks":[
11185 {"type":"textColor","attrs":{"color":"#ff0000"}},
11186 {"type":"link","attrs":{"href":"https://example.com"}}
11187 ]}
11188 ]}]}"##;
11189 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11190 let md = adf_to_markdown(&doc).unwrap();
11191 let round_tripped = markdown_to_adf(&md).unwrap();
11192 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11193 let mark_types: Vec<&str> = node
11194 .marks
11195 .as_ref()
11196 .unwrap()
11197 .iter()
11198 .map(|m| m.mark_type.as_str())
11199 .collect();
11200 assert_eq!(
11201 mark_types,
11202 vec!["textColor", "link"],
11203 "mark order should be preserved, got: {mark_types:?}"
11204 );
11205 }
11206
11207 #[test]
11208 fn mark_ordering_link_underline_preserved() {
11209 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11211 {"type":"text","text":"click here","marks":[
11212 {"type":"link","attrs":{"href":"https://example.com"}},
11213 {"type":"underline"}
11214 ]}
11215 ]}]}"#;
11216 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11217 let md = adf_to_markdown(&doc).unwrap();
11218 assert!(
11220 md.contains("](https://example.com)"),
11221 "should have link: {md}"
11222 );
11223 assert!(md.contains("underline"), "should have underline: {md}");
11224 let round_tripped = markdown_to_adf(&md).unwrap();
11225 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11226 let mark_types: Vec<&str> = node
11227 .marks
11228 .as_ref()
11229 .unwrap()
11230 .iter()
11231 .map(|m| m.mark_type.as_str())
11232 .collect();
11233 assert_eq!(
11234 mark_types,
11235 vec!["link", "underline"],
11236 "mark order should be preserved, got: {mark_types:?}"
11237 );
11238 }
11239
11240 #[test]
11241 fn mark_ordering_underline_strong_link_preserved() {
11242 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11244 {"type":"text","text":"bold underlined link","marks":[
11245 {"type":"underline"},
11246 {"type":"strong"},
11247 {"type":"link","attrs":{"href":"https://example.com/page"}}
11248 ]}
11249 ]}]}"#;
11250 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11251 let md = adf_to_markdown(&doc).unwrap();
11252 let round_tripped = markdown_to_adf(&md).unwrap();
11253 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11254 let mark_types: Vec<&str> = node
11255 .marks
11256 .as_ref()
11257 .unwrap()
11258 .iter()
11259 .map(|m| m.mark_type.as_str())
11260 .collect();
11261 assert_eq!(
11262 mark_types,
11263 vec!["underline", "strong", "link"],
11264 "mark order should be preserved, got: {mark_types:?}"
11265 );
11266 }
11267
11268 #[test]
11269 fn mark_ordering_strong_underline_link_preserved() {
11270 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11272 {"type":"text","text":"bold underlined link","marks":[
11273 {"type":"strong"},
11274 {"type":"underline"},
11275 {"type":"link","attrs":{"href":"https://example.com/page"}}
11276 ]}
11277 ]}]}"#;
11278 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11279 let md = adf_to_markdown(&doc).unwrap();
11280 let round_tripped = markdown_to_adf(&md).unwrap();
11281 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11282 let mark_types: Vec<&str> = node
11283 .marks
11284 .as_ref()
11285 .unwrap()
11286 .iter()
11287 .map(|m| m.mark_type.as_str())
11288 .collect();
11289 assert_eq!(
11290 mark_types,
11291 vec!["strong", "underline", "link"],
11292 "mark order should be preserved, got: {mark_types:?}"
11293 );
11294 }
11295
11296 #[test]
11297 fn mark_ordering_underline_em_link_preserved() {
11298 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11300 {"type":"text","text":"italic underlined link","marks":[
11301 {"type":"underline"},
11302 {"type":"em"},
11303 {"type":"link","attrs":{"href":"https://example.com/page"}}
11304 ]}
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 let mark_types: Vec<&str> = node
11311 .marks
11312 .as_ref()
11313 .unwrap()
11314 .iter()
11315 .map(|m| m.mark_type.as_str())
11316 .collect();
11317 assert_eq!(
11318 mark_types,
11319 vec!["underline", "em", "link"],
11320 "mark order should be preserved, got: {mark_types:?}"
11321 );
11322 }
11323
11324 #[test]
11325 fn mark_ordering_underline_strike_link_preserved() {
11326 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11328 {"type":"text","text":"struck underlined link","marks":[
11329 {"type":"underline"},
11330 {"type":"strike"},
11331 {"type":"link","attrs":{"href":"https://example.com/page"}}
11332 ]}
11333 ]}]}"#;
11334 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
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 .unwrap()
11342 .iter()
11343 .map(|m| m.mark_type.as_str())
11344 .collect();
11345 assert_eq!(
11346 mark_types,
11347 vec!["underline", "strike", "link"],
11348 "mark order should be preserved, got: {mark_types:?}"
11349 );
11350 }
11351
11352 #[test]
11353 fn mark_ordering_underline_strong_em_link_preserved() {
11354 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11356 {"type":"text","text":"all the marks","marks":[
11357 {"type":"underline"},
11358 {"type":"strong"},
11359 {"type":"em"},
11360 {"type":"link","attrs":{"href":"https://example.com/page"}}
11361 ]}
11362 ]}]}"#;
11363 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11364 let md = adf_to_markdown(&doc).unwrap();
11365 let round_tripped = markdown_to_adf(&md).unwrap();
11366 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11367 let mark_types: Vec<&str> = node
11368 .marks
11369 .as_ref()
11370 .unwrap()
11371 .iter()
11372 .map(|m| m.mark_type.as_str())
11373 .collect();
11374 assert_eq!(
11375 mark_types,
11376 vec!["underline", "strong", "em", "link"],
11377 "mark order should be preserved, got: {mark_types:?}"
11378 );
11379 }
11380
11381 #[test]
11382 fn em_strong_round_trip() {
11383 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11385 {"type":"text","text":"bold and italic","marks":[{"type":"strong"},{"type":"em"}]}
11386 ]}]}"#;
11387 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11388 let md = adf_to_markdown(&doc).unwrap();
11389 assert_eq!(md.trim(), "***bold and italic***");
11390 let round_tripped = markdown_to_adf(&md).unwrap();
11391 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11392 assert_eq!(node.text.as_deref(), Some("bold and italic"));
11393 let mark_types: Vec<&str> = node
11394 .marks
11395 .as_ref()
11396 .unwrap()
11397 .iter()
11398 .map(|m| m.mark_type.as_str())
11399 .collect();
11400 assert_eq!(
11401 mark_types,
11402 vec!["strong", "em"],
11403 "both strong and em marks should be preserved, got: {mark_types:?}"
11404 );
11405 }
11406
11407 #[test]
11408 fn em_strong_round_trip_em_first() {
11409 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11411 {"type":"text","text":"italic and bold","marks":[{"type":"em"},{"type":"strong"}]}
11412 ]}]}"#;
11413 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11414 let md = adf_to_markdown(&doc).unwrap();
11415 let round_tripped = markdown_to_adf(&md).unwrap();
11416 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11417 assert_eq!(node.text.as_deref(), Some("italic and bold"));
11418 let mark_types: Vec<&str> = node
11419 .marks
11420 .as_ref()
11421 .unwrap()
11422 .iter()
11423 .map(|m| m.mark_type.as_str())
11424 .collect();
11425 assert_eq!(
11426 mark_types,
11427 vec!["em", "strong"],
11428 "mark order [em, strong] should be preserved, got: {mark_types:?}"
11429 );
11430 }
11431
11432 fn assert_mark_order_round_trip(marks: Vec<AdfMark>, expected: &[&str]) {
11435 let doc = AdfDocument {
11436 version: 1,
11437 doc_type: "doc".to_string(),
11438 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11439 "text", marks,
11440 )])],
11441 };
11442 let md = adf_to_markdown(&doc).unwrap();
11443 let round_tripped = markdown_to_adf(&md).unwrap();
11444 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11445 let mark_types: Vec<&str> = node
11446 .marks
11447 .as_ref()
11448 .expect("round-tripped node should have marks")
11449 .iter()
11450 .map(|m| m.mark_type.as_str())
11451 .collect();
11452 assert_eq!(
11453 mark_types, expected,
11454 "marks should round-trip in order via {md:?}"
11455 );
11456 }
11457
11458 #[test]
11459 fn round_trip_em_strong_mark_order() {
11460 assert_mark_order_round_trip(vec![AdfMark::em(), AdfMark::strong()], &["em", "strong"]);
11462 assert_mark_order_round_trip(vec![AdfMark::strong(), AdfMark::em()], &["strong", "em"]);
11463 }
11464
11465 #[test]
11466 fn round_trip_strong_underline_mark_order() {
11467 assert_mark_order_round_trip(
11469 vec![AdfMark::strong(), AdfMark::underline()],
11470 &["strong", "underline"],
11471 );
11472 assert_mark_order_round_trip(
11473 vec![AdfMark::underline(), AdfMark::strong()],
11474 &["underline", "strong"],
11475 );
11476 }
11477
11478 #[test]
11479 fn round_trip_em_underline_mark_order() {
11480 assert_mark_order_round_trip(
11481 vec![AdfMark::em(), AdfMark::underline()],
11482 &["em", "underline"],
11483 );
11484 assert_mark_order_round_trip(
11485 vec![AdfMark::underline(), AdfMark::em()],
11486 &["underline", "em"],
11487 );
11488 }
11489
11490 #[test]
11491 fn round_trip_strike_strong_em_permutations() {
11492 assert_mark_order_round_trip(
11496 vec![AdfMark::strike(), AdfMark::strong(), AdfMark::em()],
11497 &["strike", "strong", "em"],
11498 );
11499 assert_mark_order_round_trip(
11500 vec![AdfMark::strike(), AdfMark::em(), AdfMark::strong()],
11501 &["strike", "em", "strong"],
11502 );
11503 assert_mark_order_round_trip(
11504 vec![AdfMark::strong(), AdfMark::strike(), AdfMark::em()],
11505 &["strong", "strike", "em"],
11506 );
11507 assert_mark_order_round_trip(
11508 vec![AdfMark::strong(), AdfMark::em(), AdfMark::strike()],
11509 &["strong", "em", "strike"],
11510 );
11511 assert_mark_order_round_trip(
11512 vec![AdfMark::em(), AdfMark::strike(), AdfMark::strong()],
11513 &["em", "strike", "strong"],
11514 );
11515 assert_mark_order_round_trip(
11516 vec![AdfMark::em(), AdfMark::strong(), AdfMark::strike()],
11517 &["em", "strong", "strike"],
11518 );
11519 }
11520
11521 #[test]
11522 fn round_trip_underline_nested_with_strong_em() {
11523 assert_mark_order_round_trip(
11526 vec![AdfMark::underline(), AdfMark::strong(), AdfMark::em()],
11527 &["underline", "strong", "em"],
11528 );
11529 assert_mark_order_round_trip(
11530 vec![AdfMark::strong(), AdfMark::underline(), AdfMark::em()],
11531 &["strong", "underline", "em"],
11532 );
11533 assert_mark_order_round_trip(
11534 vec![AdfMark::strong(), AdfMark::em(), AdfMark::underline()],
11535 &["strong", "em", "underline"],
11536 );
11537 }
11538
11539 #[test]
11540 fn round_trip_span_attr_order_preserved() {
11541 assert_mark_order_round_trip(
11545 vec![
11546 AdfMark::background_color("#ffff00"),
11547 AdfMark::text_color("#ff0000"),
11548 ],
11549 &["backgroundColor", "textColor"],
11550 );
11551 assert_mark_order_round_trip(
11552 vec![AdfMark::subsup("sub"), AdfMark::text_color("#ff0000")],
11553 &["subsup", "textColor"],
11554 );
11555 assert_mark_order_round_trip(
11556 vec![
11557 AdfMark::text_color("#ff0000"),
11558 AdfMark::background_color("#ffff00"),
11559 ],
11560 &["textColor", "backgroundColor"],
11561 );
11562 }
11563
11564 #[test]
11565 fn round_trip_annotation_before_underline() {
11566 assert_mark_order_round_trip(
11570 vec![
11571 AdfMark::annotation("ann-1", "inlineComment"),
11572 AdfMark::underline(),
11573 ],
11574 &["annotation", "underline"],
11575 );
11576 assert_mark_order_round_trip(
11577 vec![
11578 AdfMark::annotation("ann-1", "inlineComment"),
11579 AdfMark::underline(),
11580 AdfMark::annotation("ann-2", "inlineComment"),
11581 ],
11582 &["annotation", "underline", "annotation"],
11583 );
11584 }
11585
11586 #[test]
11587 fn round_trip_em_content_with_underscores() {
11588 let doc = AdfDocument {
11593 version: 1,
11594 doc_type: "doc".to_string(),
11595 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11596 "foo _bar_ baz",
11597 vec![AdfMark::em(), AdfMark::strong()],
11598 )])],
11599 };
11600 let md = adf_to_markdown(&doc).unwrap();
11601 let round_tripped = markdown_to_adf(&md).unwrap();
11602 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11603 assert_eq!(node.text.as_deref(), Some("foo _bar_ baz"));
11604 let mark_types: Vec<&str> = node
11605 .marks
11606 .as_ref()
11607 .unwrap()
11608 .iter()
11609 .map(|m| m.mark_type.as_str())
11610 .collect();
11611 assert_eq!(mark_types, vec!["em", "strong"]);
11612 }
11613
11614 #[test]
11615 fn round_trip_link_nested_with_formatting_marks() {
11616 assert_mark_order_round_trip(
11619 vec![
11620 AdfMark::link("https://example.com"),
11621 AdfMark::strong(),
11622 AdfMark::em(),
11623 ],
11624 &["link", "strong", "em"],
11625 );
11626 assert_mark_order_round_trip(
11627 vec![
11628 AdfMark::em(),
11629 AdfMark::strong(),
11630 AdfMark::link("https://example.com"),
11631 ],
11632 &["em", "strong", "link"],
11633 );
11634 assert_mark_order_round_trip(
11635 vec![
11636 AdfMark::underline(),
11637 AdfMark::link("https://example.com"),
11638 AdfMark::strong(),
11639 ],
11640 &["underline", "link", "strong"],
11641 );
11642 }
11643
11644 fn bare_mark(mark_type: &str) -> AdfMark {
11648 AdfMark {
11649 mark_type: mark_type.to_string(),
11650 attrs: None,
11651 }
11652 }
11653
11654 #[test]
11655 fn collect_span_attr_handles_missing_attrs() {
11656 let mut attrs = Vec::new();
11661 collect_span_attr(&bare_mark("textColor"), &mut attrs);
11662 collect_span_attr(&bare_mark("backgroundColor"), &mut attrs);
11663 collect_span_attr(&bare_mark("subsup"), &mut attrs);
11664 collect_span_attr(&bare_mark("link"), &mut attrs);
11665 assert!(attrs.is_empty(), "got: {attrs:?}");
11666 }
11667
11668 #[test]
11669 fn collect_bracketed_attr_handles_missing_attrs() {
11670 let mut attrs = Vec::new();
11673 collect_bracketed_attr(&bare_mark("annotation"), &mut attrs);
11674 collect_bracketed_attr(&bare_mark("strong"), &mut attrs);
11675 assert!(attrs.is_empty(), "got: {attrs:?}");
11676 }
11677
11678 #[test]
11679 fn collect_bracketed_attr_handles_annotation_without_id() {
11680 let mark = AdfMark {
11684 mark_type: "annotation".to_string(),
11685 attrs: Some(serde_json::json!({})),
11686 };
11687 let mut attrs = Vec::new();
11688 collect_bracketed_attr(&mark, &mut attrs);
11689 assert!(attrs.is_empty(), "got: {attrs:?}");
11690 }
11691
11692 #[test]
11693 fn span_attr_order_rejects_unknown_types() {
11694 assert_eq!(span_attr_order("textColor"), 0);
11698 assert_eq!(span_attr_order("backgroundColor"), 1);
11699 assert_eq!(span_attr_order("subsup"), 2);
11700 assert_eq!(span_attr_order("strong"), u8::MAX);
11701 assert!(!span_run_is_canonical(&[bare_mark("strong")]));
11702 }
11703
11704 #[test]
11705 fn bracketed_run_rejects_unknown_types() {
11706 assert!(bracketed_run_is_canonical(&[
11710 AdfMark::underline(),
11711 AdfMark::annotation("x", "inlineComment")
11712 ]));
11713 assert!(!bracketed_run_is_canonical(&[
11714 AdfMark::annotation("x", "inlineComment"),
11715 AdfMark::underline()
11716 ]));
11717 assert!(!bracketed_run_is_canonical(&[bare_mark("strong")]));
11718 }
11719
11720 #[test]
11721 fn render_marked_text_ignores_unknown_mark_types() {
11722 let doc = AdfDocument {
11726 version: 1,
11727 doc_type: "doc".to_string(),
11728 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11729 "hello",
11730 vec![bare_mark("futureMark"), AdfMark::strong()],
11731 )])],
11732 };
11733 let md = adf_to_markdown(&doc).unwrap();
11734 assert_eq!(md.trim(), "**hello**");
11735 let rt = markdown_to_adf(&md).unwrap();
11736 let node = &rt.content[0].content.as_ref().unwrap()[0];
11737 assert_eq!(node.text.as_deref(), Some("hello"));
11738 let mark_types: Vec<&str> = node
11739 .marks
11740 .as_ref()
11741 .unwrap()
11742 .iter()
11743 .map(|m| m.mark_type.as_str())
11744 .collect();
11745 assert_eq!(mark_types, vec!["strong"]);
11746 }
11747
11748 #[test]
11749 fn triple_asterisk_parse_to_adf() {
11750 let md = "***bold and italic***\n";
11752 let doc = markdown_to_adf(md).unwrap();
11753 let node = &doc.content[0].content.as_ref().unwrap()[0];
11754 assert_eq!(node.text.as_deref(), Some("bold and italic"));
11755 let mark_types: Vec<&str> = node
11756 .marks
11757 .as_ref()
11758 .unwrap()
11759 .iter()
11760 .map(|m| m.mark_type.as_str())
11761 .collect();
11762 assert!(
11763 mark_types.contains(&"strong") && mark_types.contains(&"em"),
11764 "***text*** should produce both strong and em marks, got: {mark_types:?}"
11765 );
11766 }
11767
11768 #[test]
11769 fn triple_asterisk_with_surrounding_text() {
11770 let md = "before ***bold italic*** after\n";
11772 let doc = markdown_to_adf(md).unwrap();
11773 let nodes = doc.content[0].content.as_ref().unwrap();
11774 assert!(
11776 nodes.len() >= 3,
11777 "expected at least 3 nodes, got {}",
11778 nodes.len()
11779 );
11780 assert_eq!(nodes[0].text.as_deref(), Some("before "));
11781 assert_eq!(nodes[1].text.as_deref(), Some("bold italic"));
11782 let mark_types: Vec<&str> = nodes[1]
11783 .marks
11784 .as_ref()
11785 .unwrap()
11786 .iter()
11787 .map(|m| m.mark_type.as_str())
11788 .collect();
11789 assert!(
11790 mark_types.contains(&"strong") && mark_types.contains(&"em"),
11791 "middle node should have strong+em, got: {mark_types:?}"
11792 );
11793 assert_eq!(nodes[2].text.as_deref(), Some(" after"));
11794 }
11795
11796 #[test]
11797 fn annotation_mark_round_trip() {
11798 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11800 {"type":"text","text":"highlighted text","marks":[
11801 {"type":"annotation","attrs":{"id":"abc123","annotationType":"inlineComment"}}
11802 ]}
11803 ]}]}"#;
11804 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11805
11806 let md = adf_to_markdown(&doc).unwrap();
11807 assert!(
11808 md.contains("annotation-id="),
11809 "JFM should contain annotation-id, got: {md}"
11810 );
11811
11812 let round_tripped = markdown_to_adf(&md).unwrap();
11813 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11814 assert_eq!(text_node.text.as_deref(), Some("highlighted text"));
11815 let marks = text_node.marks.as_ref().expect("should have marks");
11816 let ann = marks
11817 .iter()
11818 .find(|m| m.mark_type == "annotation")
11819 .expect("should have annotation mark");
11820 let attrs = ann.attrs.as_ref().unwrap();
11821 assert_eq!(attrs["id"], "abc123");
11822 assert_eq!(attrs["annotationType"], "inlineComment");
11823 }
11824
11825 #[test]
11826 fn annotation_mark_with_bold() {
11827 let doc = AdfDocument {
11829 version: 1,
11830 doc_type: "doc".to_string(),
11831 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11832 "bold comment",
11833 vec![
11834 AdfMark::strong(),
11835 AdfMark::annotation("def456", "inlineComment"),
11836 ],
11837 )])],
11838 };
11839 let md = adf_to_markdown(&doc).unwrap();
11840 let round_tripped = markdown_to_adf(&md).unwrap();
11841 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11842 let marks = text_node.marks.as_ref().expect("should have marks");
11843 assert!(
11844 marks.iter().any(|m| m.mark_type == "strong"),
11845 "should have strong mark"
11846 );
11847 assert!(
11848 marks.iter().any(|m| m.mark_type == "annotation"),
11849 "should have annotation mark"
11850 );
11851 }
11852
11853 #[test]
11854 fn annotation_and_link_marks_both_preserved() {
11855 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11857 {"type":"text","text":"HANGUL-8","marks":[
11858 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"5ca7425e-34cd-48d3-b4eb-9873ac8b20e0"}},
11859 {"type":"link","attrs":{"href":"https://zd.atlassian.net/browse/HANG-8"}}
11860 ]}
11861 ]}]}"#;
11862 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11863 let md = adf_to_markdown(&doc).unwrap();
11864 assert!(
11866 md.contains("annotation-id="),
11867 "JFM should contain annotation-id, got: {md}"
11868 );
11869 assert!(
11870 md.contains("](https://"),
11871 "JFM should contain link href, got: {md}"
11872 );
11873 let round_tripped = markdown_to_adf(&md).unwrap();
11874 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11875 let marks = text_node.marks.as_ref().expect("should have marks");
11876 assert!(
11877 marks.iter().any(|m| m.mark_type == "annotation"),
11878 "should have annotation mark, got: {:?}",
11879 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11880 );
11881 assert!(
11882 marks.iter().any(|m| m.mark_type == "link"),
11883 "should have link mark, got: {:?}",
11884 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11885 );
11886 }
11887
11888 #[test]
11889 fn annotation_and_code_marks_both_preserved() {
11890 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11892 {"type":"text","text":"some text with "},
11893 {"type":"text","text":"annotated code","marks":[
11894 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"aabbccdd-1234-5678-abcd-000000000001"}},
11895 {"type":"code"}
11896 ]},
11897 {"type":"text","text":" remaining text"}
11898 ]}]}"#;
11899 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11900 let md = adf_to_markdown(&doc).unwrap();
11901 assert!(
11902 md.contains("annotation-id="),
11903 "JFM should contain annotation-id, got: {md}"
11904 );
11905 assert!(
11906 md.contains('`'),
11907 "JFM should contain backticks for code, got: {md}"
11908 );
11909
11910 let round_tripped = markdown_to_adf(&md).unwrap();
11911 let nodes = round_tripped.content[0].content.as_ref().unwrap();
11912 let code_node = nodes
11914 .iter()
11915 .find(|n| n.text.as_deref() == Some("annotated code"))
11916 .expect("should have 'annotated code' text node");
11917 let marks = code_node.marks.as_ref().expect("should have marks");
11918 assert!(
11919 marks.iter().any(|m| m.mark_type == "annotation"),
11920 "should have annotation mark, got: {:?}",
11921 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11922 );
11923 assert!(
11924 marks.iter().any(|m| m.mark_type == "code"),
11925 "should have code mark, got: {:?}",
11926 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11927 );
11928 let ann = marks.iter().find(|m| m.mark_type == "annotation").unwrap();
11929 let attrs = ann.attrs.as_ref().unwrap();
11930 assert_eq!(attrs["id"], "aabbccdd-1234-5678-abcd-000000000001");
11931 assert_eq!(attrs["annotationType"], "inlineComment");
11932 }
11933
11934 #[test]
11935 fn annotation_and_code_and_link_marks_all_preserved() {
11936 let doc = AdfDocument {
11938 version: 1,
11939 doc_type: "doc".to_string(),
11940 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11941 "linked code",
11942 vec![
11943 AdfMark::annotation("ann-001", "inlineComment"),
11944 AdfMark::code(),
11945 AdfMark::link("https://example.com"),
11946 ],
11947 )])],
11948 };
11949 let md = adf_to_markdown(&doc).unwrap();
11950 assert!(
11951 md.contains("annotation-id="),
11952 "JFM should contain annotation-id, got: {md}"
11953 );
11954 assert!(md.contains('`'), "JFM should contain backticks, got: {md}");
11955 assert!(
11956 md.contains("](https://example.com)"),
11957 "JFM should contain link, got: {md}"
11958 );
11959
11960 let round_tripped = markdown_to_adf(&md).unwrap();
11961 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11962 let marks = text_node.marks.as_ref().expect("should have marks");
11963 assert!(
11964 marks.iter().any(|m| m.mark_type == "annotation"),
11965 "should have annotation mark, got: {:?}",
11966 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11967 );
11968 assert!(
11969 marks.iter().any(|m| m.mark_type == "code"),
11970 "should have code mark, got: {:?}",
11971 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11972 );
11973 assert!(
11974 marks.iter().any(|m| m.mark_type == "link"),
11975 "should have link mark, got: {:?}",
11976 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11977 );
11978 }
11979
11980 #[test]
11981 fn multiple_annotations_and_code_mark_preserved() {
11982 let doc = AdfDocument {
11984 version: 1,
11985 doc_type: "doc".to_string(),
11986 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11987 "doubly annotated",
11988 vec![
11989 AdfMark::annotation("ann-aaa", "inlineComment"),
11990 AdfMark::annotation("ann-bbb", "inlineComment"),
11991 AdfMark::code(),
11992 ],
11993 )])],
11994 };
11995 let md = adf_to_markdown(&doc).unwrap();
11996 assert!(
11997 md.contains("ann-aaa"),
11998 "JFM should contain first annotation id, got: {md}"
11999 );
12000 assert!(
12001 md.contains("ann-bbb"),
12002 "JFM should contain second annotation id, got: {md}"
12003 );
12004
12005 let round_tripped = markdown_to_adf(&md).unwrap();
12006 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12007 let marks = text_node.marks.as_ref().expect("should have marks");
12008 let ann_marks: Vec<_> = marks
12009 .iter()
12010 .filter(|m| m.mark_type == "annotation")
12011 .collect();
12012 assert_eq!(
12013 ann_marks.len(),
12014 2,
12015 "should have 2 annotation marks, got: {}",
12016 ann_marks.len()
12017 );
12018 assert!(
12019 marks.iter().any(|m| m.mark_type == "code"),
12020 "should have code mark"
12021 );
12022 }
12023
12024 #[test]
12025 fn underline_and_link_marks_both_preserved() {
12026 let doc = AdfDocument {
12028 version: 1,
12029 doc_type: "doc".to_string(),
12030 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12031 "click here",
12032 vec![AdfMark::underline(), AdfMark::link("https://example.com")],
12033 )])],
12034 };
12035 let md = adf_to_markdown(&doc).unwrap();
12036 assert!(md.contains("underline"), "should have underline attr: {md}");
12037 assert!(
12038 md.contains("](https://example.com)"),
12039 "should have link: {md}"
12040 );
12041 let round_tripped = markdown_to_adf(&md).unwrap();
12042 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12043 let marks = text_node.marks.as_ref().expect("should have marks");
12044 assert!(marks.iter().any(|m| m.mark_type == "underline"));
12045 assert!(marks.iter().any(|m| m.mark_type == "link"));
12046 }
12047
12048 #[test]
12049 fn annotation_link_and_bold_all_preserved() {
12050 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12052 {"type":"text","text":"important","marks":[
12053 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"abc"}},
12054 {"type":"link","attrs":{"href":"https://example.com"}},
12055 {"type":"strong"}
12056 ]}
12057 ]}]}"#;
12058 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12059 let md = adf_to_markdown(&doc).unwrap();
12060 let round_tripped = markdown_to_adf(&md).unwrap();
12061 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12062 let marks = text_node.marks.as_ref().expect("should have marks");
12063 assert!(
12064 marks.iter().any(|m| m.mark_type == "annotation"),
12065 "should have annotation"
12066 );
12067 assert!(
12068 marks.iter().any(|m| m.mark_type == "link"),
12069 "should have link"
12070 );
12071 assert!(
12072 marks.iter().any(|m| m.mark_type == "strong"),
12073 "should have strong"
12074 );
12075 }
12076
12077 #[test]
12078 fn multiple_annotation_marks_round_trip() {
12079 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12081 {"type":"text","text":"some annotated text","marks":[
12082 {"type":"annotation","attrs":{"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","annotationType":"inlineComment"}},
12083 {"type":"annotation","attrs":{"id":"ffffffff-1111-2222-3333-444444444444","annotationType":"inlineComment"}}
12084 ]}
12085 ]}]}"#;
12086 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12087
12088 let md = adf_to_markdown(&doc).unwrap();
12089 assert!(
12090 md.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
12091 "JFM should contain first annotation id, got: {md}"
12092 );
12093 assert!(
12094 md.contains("ffffffff-1111-2222-3333-444444444444"),
12095 "JFM should contain second annotation id, got: {md}"
12096 );
12097
12098 let round_tripped = markdown_to_adf(&md).unwrap();
12099 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12100 assert_eq!(text_node.text.as_deref(), Some("some annotated text"));
12101 let marks = text_node.marks.as_ref().expect("should have marks");
12102 let annotations: Vec<_> = marks
12103 .iter()
12104 .filter(|m| m.mark_type == "annotation")
12105 .collect();
12106 assert_eq!(
12107 annotations.len(),
12108 2,
12109 "should have 2 annotation marks, got: {annotations:?}"
12110 );
12111 let ids: Vec<_> = annotations
12112 .iter()
12113 .map(|a| a.attrs.as_ref().unwrap()["id"].as_str().unwrap())
12114 .collect();
12115 assert!(ids.contains(&"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
12116 assert!(ids.contains(&"ffffffff-1111-2222-3333-444444444444"));
12117 }
12118
12119 #[test]
12120 fn three_annotation_marks_round_trip() {
12121 let doc = AdfDocument {
12123 version: 1,
12124 doc_type: "doc".to_string(),
12125 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12126 "triple annotated",
12127 vec![
12128 AdfMark::annotation("id-1", "inlineComment"),
12129 AdfMark::annotation("id-2", "inlineComment"),
12130 AdfMark::annotation("id-3", "inlineComment"),
12131 ],
12132 )])],
12133 };
12134 let md = adf_to_markdown(&doc).unwrap();
12135 let round_tripped = markdown_to_adf(&md).unwrap();
12136 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12137 let marks = text_node.marks.as_ref().expect("should have marks");
12138 let annotations: Vec<_> = marks
12139 .iter()
12140 .filter(|m| m.mark_type == "annotation")
12141 .collect();
12142 assert_eq!(
12143 annotations.len(),
12144 3,
12145 "should have 3 annotation marks, got: {annotations:?}"
12146 );
12147 }
12148
12149 #[test]
12150 fn multiple_annotations_with_bold_round_trip() {
12151 let doc = AdfDocument {
12153 version: 1,
12154 doc_type: "doc".to_string(),
12155 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12156 "bold double annotated",
12157 vec![
12158 AdfMark::strong(),
12159 AdfMark::annotation("ann-a", "inlineComment"),
12160 AdfMark::annotation("ann-b", "inlineComment"),
12161 ],
12162 )])],
12163 };
12164 let md = adf_to_markdown(&doc).unwrap();
12165 let round_tripped = markdown_to_adf(&md).unwrap();
12166 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12167 let marks = text_node.marks.as_ref().expect("should have marks");
12168 assert!(
12169 marks.iter().any(|m| m.mark_type == "strong"),
12170 "should have strong mark"
12171 );
12172 let annotations: Vec<_> = marks
12173 .iter()
12174 .filter(|m| m.mark_type == "annotation")
12175 .collect();
12176 assert_eq!(
12177 annotations.len(),
12178 2,
12179 "should have 2 annotation marks, got: {annotations:?}"
12180 );
12181 }
12182
12183 #[test]
12184 fn multiple_annotations_with_link_round_trip() {
12185 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12187 {"type":"text","text":"linked text","marks":[
12188 {"type":"annotation","attrs":{"id":"ann-x","annotationType":"inlineComment"}},
12189 {"type":"annotation","attrs":{"id":"ann-y","annotationType":"inlineComment"}},
12190 {"type":"link","attrs":{"href":"https://example.com"}}
12191 ]}
12192 ]}]}"#;
12193 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12194 let md = adf_to_markdown(&doc).unwrap();
12195 let round_tripped = markdown_to_adf(&md).unwrap();
12196 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12197 let marks = text_node.marks.as_ref().expect("should have marks");
12198 assert!(
12199 marks.iter().any(|m| m.mark_type == "link"),
12200 "should have link mark"
12201 );
12202 let annotations: Vec<_> = marks
12203 .iter()
12204 .filter(|m| m.mark_type == "annotation")
12205 .collect();
12206 assert_eq!(
12207 annotations.len(),
12208 2,
12209 "should have 2 annotation marks, got: {annotations:?}"
12210 );
12211 }
12212
12213 #[test]
12216 fn annotation_on_emoji_round_trip() {
12217 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12219 {"type":"emoji","attrs":{"id":"1f4dd","shortName":":memo:","text":"📝"},"marks":[
12220 {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12221 ]},
12222 {"type":"text","text":" annotated text","marks":[
12223 {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12224 ]}
12225 ]}]}"#;
12226 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12227 let md = adf_to_markdown(&doc).unwrap();
12228 assert!(
12229 md.contains("annotation-id="),
12230 "JFM should contain annotation-id for emoji, got: {md}"
12231 );
12232
12233 let round_tripped = markdown_to_adf(&md).unwrap();
12234 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12235
12236 let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12238 let emoji_marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12239 assert!(
12240 emoji_marks.iter().any(|m| m.mark_type == "annotation"),
12241 "emoji should have annotation mark, got: {emoji_marks:?}"
12242 );
12243 let ann = emoji_marks
12244 .iter()
12245 .find(|m| m.mark_type == "annotation")
12246 .unwrap();
12247 assert_eq!(
12248 ann.attrs.as_ref().unwrap()["id"],
12249 "ccddee11-2233-4455-aabb-ccddee112233"
12250 );
12251
12252 let text_node = nodes.iter().find(|n| n.node_type == "text").unwrap();
12254 let text_marks = text_node.marks.as_ref().expect("text should have marks");
12255 assert!(
12256 text_marks.iter().any(|m| m.mark_type == "annotation"),
12257 "text should have annotation mark"
12258 );
12259 }
12260
12261 #[test]
12262 fn annotation_on_status_round_trip() {
12263 let mut status = AdfNode::status("In Progress", "blue");
12264 status.marks = Some(vec![AdfMark::annotation("ann-status-1", "inlineComment")]);
12265
12266 let doc = AdfDocument {
12267 version: 1,
12268 doc_type: "doc".to_string(),
12269 content: vec![AdfNode::paragraph(vec![status])],
12270 };
12271 let md = adf_to_markdown(&doc).unwrap();
12272 assert!(
12273 md.contains("annotation-id="),
12274 "JFM should contain annotation-id for status, got: {md}"
12275 );
12276
12277 let round_tripped = markdown_to_adf(&md).unwrap();
12278 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12279 let status_node = nodes.iter().find(|n| n.node_type == "status").unwrap();
12280 let marks = status_node
12281 .marks
12282 .as_ref()
12283 .expect("status should have marks");
12284 assert!(
12285 marks.iter().any(|m| m.mark_type == "annotation"),
12286 "status should have annotation mark, got: {marks:?}"
12287 );
12288 }
12289
12290 #[test]
12291 fn annotation_on_date_round_trip() {
12292 let mut date = AdfNode::date("1704067200000");
12293 date.marks = Some(vec![AdfMark::annotation("ann-date-1", "inlineComment")]);
12294
12295 let doc = AdfDocument {
12296 version: 1,
12297 doc_type: "doc".to_string(),
12298 content: vec![AdfNode::paragraph(vec![date])],
12299 };
12300 let md = adf_to_markdown(&doc).unwrap();
12301 assert!(
12302 md.contains("annotation-id="),
12303 "JFM should contain annotation-id for date, got: {md}"
12304 );
12305
12306 let round_tripped = markdown_to_adf(&md).unwrap();
12307 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12308 let date_node = nodes.iter().find(|n| n.node_type == "date").unwrap();
12309 let marks = date_node.marks.as_ref().expect("date should have marks");
12310 assert!(
12311 marks.iter().any(|m| m.mark_type == "annotation"),
12312 "date should have annotation mark, got: {marks:?}"
12313 );
12314 }
12315
12316 #[test]
12317 fn annotation_on_mention_round_trip() {
12318 let mut mention = AdfNode::mention("user-123", "@Alice");
12319 mention.marks = Some(vec![AdfMark::annotation("ann-mention-1", "inlineComment")]);
12320
12321 let doc = AdfDocument {
12322 version: 1,
12323 doc_type: "doc".to_string(),
12324 content: vec![AdfNode::paragraph(vec![mention])],
12325 };
12326 let md = adf_to_markdown(&doc).unwrap();
12327 assert!(
12328 md.contains("annotation-id="),
12329 "JFM should contain annotation-id for mention, got: {md}"
12330 );
12331
12332 let round_tripped = markdown_to_adf(&md).unwrap();
12333 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12334 let mention_node = nodes.iter().find(|n| n.node_type == "mention").unwrap();
12335 let marks = mention_node
12336 .marks
12337 .as_ref()
12338 .expect("mention should have marks");
12339 assert!(
12340 marks.iter().any(|m| m.mark_type == "annotation"),
12341 "mention should have annotation mark, got: {marks:?}"
12342 );
12343 }
12344
12345 #[test]
12346 fn annotation_on_inline_card_round_trip() {
12347 let mut card = AdfNode::inline_card("https://example.com");
12348 card.marks = Some(vec![AdfMark::annotation("ann-card-1", "inlineComment")]);
12349
12350 let doc = AdfDocument {
12351 version: 1,
12352 doc_type: "doc".to_string(),
12353 content: vec![AdfNode::paragraph(vec![card])],
12354 };
12355 let md = adf_to_markdown(&doc).unwrap();
12356 assert!(
12357 md.contains("annotation-id="),
12358 "JFM should contain annotation-id for inlineCard, got: {md}"
12359 );
12360
12361 let round_tripped = markdown_to_adf(&md).unwrap();
12362 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12363 let card_node = nodes.iter().find(|n| n.node_type == "inlineCard").unwrap();
12364 let marks = card_node
12365 .marks
12366 .as_ref()
12367 .expect("inlineCard should have marks");
12368 assert!(
12369 marks.iter().any(|m| m.mark_type == "annotation"),
12370 "inlineCard should have annotation mark, got: {marks:?}"
12371 );
12372 }
12373
12374 #[test]
12375 fn annotation_on_placeholder_round_trip() {
12376 let mut placeholder = AdfNode::placeholder("Enter text here");
12377 placeholder.marks = Some(vec![AdfMark::annotation("ann-ph-1", "inlineComment")]);
12378
12379 let doc = AdfDocument {
12380 version: 1,
12381 doc_type: "doc".to_string(),
12382 content: vec![AdfNode::paragraph(vec![placeholder])],
12383 };
12384 let md = adf_to_markdown(&doc).unwrap();
12385 assert!(
12386 md.contains("annotation-id="),
12387 "JFM should contain annotation-id for placeholder, got: {md}"
12388 );
12389
12390 let round_tripped = markdown_to_adf(&md).unwrap();
12391 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12392 let ph_node = nodes.iter().find(|n| n.node_type == "placeholder").unwrap();
12393 let marks = ph_node
12394 .marks
12395 .as_ref()
12396 .expect("placeholder should have marks");
12397 assert!(
12398 marks.iter().any(|m| m.mark_type == "annotation"),
12399 "placeholder should have annotation mark, got: {marks:?}"
12400 );
12401 }
12402
12403 #[test]
12404 fn multiple_annotations_on_emoji_round_trip() {
12405 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12407 {"type":"emoji","attrs":{"shortName":":fire:","text":"🔥"},"marks":[
12408 {"type":"annotation","attrs":{"id":"ann-1","annotationType":"inlineComment"}},
12409 {"type":"annotation","attrs":{"id":"ann-2","annotationType":"inlineComment"}}
12410 ]}
12411 ]}]}"#;
12412 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12413 let md = adf_to_markdown(&doc).unwrap();
12414
12415 let round_tripped = markdown_to_adf(&md).unwrap();
12416 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12417 let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12418 let marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12419 let annotations: Vec<_> = marks
12420 .iter()
12421 .filter(|m| m.mark_type == "annotation")
12422 .collect();
12423 assert_eq!(
12424 annotations.len(),
12425 2,
12426 "emoji should have 2 annotation marks, got: {annotations:?}"
12427 );
12428 }
12429
12430 #[test]
12431 fn emoji_without_annotation_unchanged() {
12432 let doc = AdfDocument {
12434 version: 1,
12435 doc_type: "doc".to_string(),
12436 content: vec![AdfNode::paragraph(vec![AdfNode::emoji(":fire:")])],
12437 };
12438 let md = adf_to_markdown(&doc).unwrap();
12439 assert!(
12441 !md.contains('['),
12442 "emoji without annotation should not be wrapped in brackets, got: {md}"
12443 );
12444 assert!(md.contains(":fire:"));
12445 }
12446
12447 #[test]
12450 fn status_directive() {
12451 let doc = markdown_to_adf("The ticket is :status[In Progress]{color=blue}.").unwrap();
12452 let content = doc.content[0].content.as_ref().unwrap();
12453 assert_eq!(content[1].node_type, "status");
12454 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "In Progress");
12455 assert_eq!(content[1].attrs.as_ref().unwrap()["color"], "blue");
12456 }
12457
12458 #[test]
12459 fn adf_status_to_markdown() {
12460 let doc = AdfDocument {
12461 version: 1,
12462 doc_type: "doc".to_string(),
12463 content: vec![AdfNode::paragraph(vec![AdfNode::status("Done", "green")])],
12464 };
12465 let md = adf_to_markdown(&doc).unwrap();
12466 assert!(md.contains(":status[Done]{color=green}"));
12467 }
12468
12469 #[test]
12470 fn round_trip_status() {
12471 let md = "The ticket is :status[In Progress]{color=blue}.\n";
12472 let doc = markdown_to_adf(md).unwrap();
12473 let result = adf_to_markdown(&doc).unwrap();
12474 assert!(result.contains(":status[In Progress]{color=blue}"));
12475 }
12476
12477 #[test]
12478 fn status_with_style_and_localid_roundtrips() {
12479 let adf = AdfDocument {
12480 version: 1,
12481 doc_type: "doc".to_string(),
12482 content: vec![AdfNode::paragraph(vec![{
12483 let mut node = AdfNode::status("open", "green");
12484 node.attrs.as_mut().unwrap()["style"] =
12485 serde_json::Value::String("bold".to_string());
12486 node.attrs.as_mut().unwrap()["localId"] =
12487 serde_json::Value::String("d2205ca5-84b9-4950-a730-bfe550fc146b".to_string());
12488 node
12489 }])],
12490 };
12491
12492 let md = adf_to_markdown(&adf).unwrap();
12493 assert!(
12494 md.contains("style=bold"),
12495 "Markdown should contain style attr: {md}"
12496 );
12497 assert!(
12498 md.contains("localId=d2205ca5"),
12499 "Markdown should contain localId attr: {md}"
12500 );
12501
12502 let rt = markdown_to_adf(&md).unwrap();
12503 let status = &rt.content[0].content.as_ref().unwrap()[0];
12504 let attrs = status.attrs.as_ref().unwrap();
12505 assert_eq!(attrs["text"], "open");
12506 assert_eq!(attrs["color"], "green");
12507 assert_eq!(attrs["style"], "bold");
12508 assert_eq!(
12509 attrs["localId"], "d2205ca5-84b9-4950-a730-bfe550fc146b",
12510 "localId should be preserved, got: {}",
12511 attrs["localId"]
12512 );
12513 }
12514
12515 #[test]
12516 fn status_without_style_still_works() {
12517 let md = ":status[Done]{color=green}\n";
12518 let doc = markdown_to_adf(md).unwrap();
12519 let status = &doc.content[0].content.as_ref().unwrap()[0];
12520 let attrs = status.attrs.as_ref().unwrap();
12521 assert_eq!(attrs["text"], "Done");
12522 assert_eq!(attrs["color"], "green");
12523 assert!(
12525 attrs.get("style").is_none() || attrs["style"].is_null(),
12526 "style should not be set when not provided"
12527 );
12528 }
12529
12530 #[test]
12531 fn strip_local_ids_removes_localid_from_status() {
12532 let adf = AdfDocument {
12533 version: 1,
12534 doc_type: "doc".to_string(),
12535 content: vec![AdfNode::paragraph(vec![{
12536 let mut node = AdfNode::status("open", "green");
12537 node.attrs.as_mut().unwrap()["localId"] =
12538 serde_json::Value::String("real-uuid-here".to_string());
12539 node
12540 }])],
12541 };
12542 let opts = RenderOptions {
12543 strip_local_ids: true,
12544 };
12545 let md = adf_to_markdown_with_options(&adf, &opts).unwrap();
12546 assert!(
12547 !md.contains("localId"),
12548 "localId should be stripped, got: {md}"
12549 );
12550 assert!(md.contains("color=green"), "color should be preserved");
12551 }
12552
12553 #[test]
12554 fn strip_local_ids_removes_localid_from_table() {
12555 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"}]}]}]}]}]}"#;
12556 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12557 let opts = RenderOptions {
12558 strip_local_ids: true,
12559 };
12560 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12561 assert!(
12562 !md.contains("localId"),
12563 "localId should be stripped from table, got: {md}"
12564 );
12565 assert!(md.contains("layout=default"), "layout should be preserved");
12566 }
12567
12568 #[test]
12569 fn default_options_preserve_localid() {
12570 let adf = AdfDocument {
12571 version: 1,
12572 doc_type: "doc".to_string(),
12573 content: vec![AdfNode::paragraph(vec![{
12574 let mut node = AdfNode::status("open", "green");
12575 node.attrs.as_mut().unwrap()["localId"] =
12576 serde_json::Value::String("real-uuid-here".to_string());
12577 node
12578 }])],
12579 };
12580 let md = adf_to_markdown(&adf).unwrap();
12581 assert!(
12582 md.contains("localId=real-uuid-here"),
12583 "Default should preserve localId, got: {md}"
12584 );
12585 }
12586
12587 #[test]
12588 fn mention_localid_roundtrip() {
12589 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12590 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12591 let md = adf_to_markdown(&doc).unwrap();
12592 assert!(
12593 md.contains("localId=m-001"),
12594 "mention should have localId in md: {md}"
12595 );
12596 let rt = markdown_to_adf(&md).unwrap();
12597 let mention = &rt.content[0].content.as_ref().unwrap()[0];
12598 assert_eq!(mention.attrs.as_ref().unwrap()["localId"], "m-001");
12599 }
12600
12601 #[test]
12602 fn date_localid_roundtrip() {
12603 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
12604 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12605 let md = adf_to_markdown(&doc).unwrap();
12606 assert!(
12607 md.contains("localId=d-001"),
12608 "date should have localId in md: {md}"
12609 );
12610 let rt = markdown_to_adf(&md).unwrap();
12611 let date = &rt.content[0].content.as_ref().unwrap()[0];
12612 assert_eq!(date.attrs.as_ref().unwrap()["localId"], "d-001");
12613 }
12614
12615 #[test]
12616 fn emoji_localid_roundtrip() {
12617 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"emoji","attrs":{"shortName":":smile:","localId":"e-001"}}]}]}"#;
12618 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12619 let md = adf_to_markdown(&doc).unwrap();
12620 assert!(
12621 md.contains("localId=e-001"),
12622 "emoji should have localId in md: {md}"
12623 );
12624 let rt = markdown_to_adf(&md).unwrap();
12625 let emoji = &rt.content[0].content.as_ref().unwrap()[0];
12626 assert_eq!(emoji.attrs.as_ref().unwrap()["localId"], "e-001");
12627 }
12628
12629 #[test]
12630 fn inline_card_localid_roundtrip() {
12631 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"inlineCard","attrs":{"url":"https://example.com","localId":"c-001"}}]}]}"#;
12632 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12633 let md = adf_to_markdown(&doc).unwrap();
12634 assert!(
12635 md.contains("localId=c-001"),
12636 "inlineCard should have localId in md: {md}"
12637 );
12638 let rt = markdown_to_adf(&md).unwrap();
12639 let card = &rt.content[0].content.as_ref().unwrap()[0];
12640 assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-001");
12641 }
12642
12643 #[test]
12644 fn strip_local_ids_removes_from_mention() {
12645 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12646 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12647 let opts = RenderOptions {
12648 strip_local_ids: true,
12649 };
12650 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12651 assert!(
12652 !md.contains("localId"),
12653 "localId should be stripped from mention: {md}"
12654 );
12655 assert!(md.contains("id=user123"), "other attrs should be preserved");
12656 }
12657
12658 #[test]
12659 fn strip_local_ids_removes_from_date() {
12660 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
12661 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12662 let opts = RenderOptions {
12663 strip_local_ids: true,
12664 };
12665 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12666 assert!(
12667 !md.contains("localId"),
12668 "localId should be stripped from date: {md}"
12669 );
12670 }
12671
12672 #[test]
12673 fn strip_local_ids_removes_from_block_attrs() {
12674 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"hello"}]}]}"#;
12675 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12676 let opts = RenderOptions {
12677 strip_local_ids: true,
12678 };
12679 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12680 assert!(
12681 !md.contains("localId"),
12682 "localId should be stripped from block attrs: {md}"
12683 );
12684 }
12685
12686 #[test]
12687 fn table_cell_localid_roundtrip() {
12688 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"}]}]}]}]}]}"#;
12689 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12690 let md = adf_to_markdown(&doc).unwrap();
12691 assert!(
12692 md.contains("localId=tc-001"),
12693 "tableCell should have localId in md: {md}"
12694 );
12695 let rt = markdown_to_adf(&md).unwrap();
12696 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12697 .content
12698 .as_ref()
12699 .unwrap()[0];
12700 assert_eq!(
12701 cell.attrs.as_ref().unwrap()["localId"],
12702 "tc-001",
12703 "tableCell localId should round-trip"
12704 );
12705 }
12706
12707 #[test]
12708 fn table_cell_border_mark_roundtrip() {
12709 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"}]}]}]}]}]}"##;
12710 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12711 let md = adf_to_markdown(&doc).unwrap();
12712 assert!(
12713 md.contains("border-color=#ff000033"),
12714 "tableCell should have border-color in md: {md}"
12715 );
12716 assert!(
12717 md.contains("border-size=2"),
12718 "tableCell should have border-size in md: {md}"
12719 );
12720 let rt = markdown_to_adf(&md).unwrap();
12721 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12722 .content
12723 .as_ref()
12724 .unwrap()[0];
12725 let marks = cell.marks.as_ref().expect("tableCell should have marks");
12726 assert_eq!(marks.len(), 1);
12727 assert_eq!(marks[0].mark_type, "border");
12728 let attrs = marks[0].attrs.as_ref().unwrap();
12729 assert_eq!(attrs["color"], "#ff000033");
12730 assert_eq!(attrs["size"], 2);
12731 }
12732
12733 #[test]
12734 fn table_header_border_mark_roundtrip() {
12735 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"}]}]}]}]}]}"##;
12736 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12737 let md = adf_to_markdown(&doc).unwrap();
12738 assert!(md.contains("border-color=#0000ff"), "md: {md}");
12739 assert!(md.contains("border-size=3"), "md: {md}");
12740 let rt = markdown_to_adf(&md).unwrap();
12741 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12742 .content
12743 .as_ref()
12744 .unwrap()[0];
12745 assert_eq!(cell.node_type, "tableHeader");
12746 let marks = cell.marks.as_ref().expect("tableHeader should have marks");
12747 assert_eq!(marks[0].mark_type, "border");
12748 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#0000ff");
12749 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12750 }
12751
12752 #[test]
12753 fn table_cell_border_mark_with_attrs_roundtrip() {
12754 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"}]}]}]}]}]}"##;
12755 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12756 let md = adf_to_markdown(&doc).unwrap();
12757 assert!(md.contains("bg=#e6fcff"), "md: {md}");
12758 assert!(md.contains("colspan=2"), "md: {md}");
12759 assert!(md.contains("border-color=#ff000033"), "md: {md}");
12760 let rt = markdown_to_adf(&md).unwrap();
12761 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12762 .content
12763 .as_ref()
12764 .unwrap()[0];
12765 assert_eq!(cell.attrs.as_ref().unwrap()["background"], "#e6fcff");
12766 assert_eq!(cell.attrs.as_ref().unwrap()["colspan"], 2);
12767 let marks = cell.marks.as_ref().expect("should have marks");
12768 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff000033");
12769 }
12770
12771 #[test]
12772 fn table_cell_no_border_mark_unchanged() {
12773 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"}]}]}]}]}]}"#;
12774 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12775 let md = adf_to_markdown(&doc).unwrap();
12776 assert!(
12777 !md.contains("border-color"),
12778 "no border attrs expected: {md}"
12779 );
12780 let rt = markdown_to_adf(&md).unwrap();
12781 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12782 .content
12783 .as_ref()
12784 .unwrap()[0];
12785 assert!(cell.marks.is_none(), "no marks expected on plain cell");
12786 }
12787
12788 #[test]
12789 fn table_cell_border_size_only_defaults_color() {
12790 let md = "::::table\n:::tr\n:::td{border-size=3}\ncell\n:::\n:::\n::::\n";
12793 let doc = markdown_to_adf(md).unwrap();
12794 let cell = &doc.content[0].content.as_ref().unwrap()[0]
12795 .content
12796 .as_ref()
12797 .unwrap()[0];
12798 let marks = cell.marks.as_ref().expect("should have border mark");
12799 assert_eq!(marks[0].mark_type, "border");
12800 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
12801 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12802 }
12803
12804 #[test]
12805 fn table_cell_border_color_only_defaults_size() {
12806 let md = "::::table\n:::tr\n:::td{border-color=#ff0000}\ncell\n:::\n:::\n::::\n";
12808 let doc = markdown_to_adf(md).unwrap();
12809 let cell = &doc.content[0].content.as_ref().unwrap()[0]
12810 .content
12811 .as_ref()
12812 .unwrap()[0];
12813 let marks = cell.marks.as_ref().expect("should have border mark");
12814 assert_eq!(marks[0].mark_type, "border");
12815 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
12816 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
12817 }
12818
12819 #[test]
12820 fn media_file_border_mark_roundtrip() {
12821 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}}]}]}]}"##;
12822 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12823 let md = adf_to_markdown(&doc).unwrap();
12824 assert!(
12825 md.contains("border-color=#091e4224"),
12826 "media should have border-color in md: {md}"
12827 );
12828 assert!(
12829 md.contains("border-size=2"),
12830 "media should have border-size in md: {md}"
12831 );
12832 let rt = markdown_to_adf(&md).unwrap();
12833 let media_single = &rt.content[0];
12834 let media = &media_single.content.as_ref().unwrap()[0];
12835 assert_eq!(media.node_type, "media");
12836 let marks = media.marks.as_ref().expect("media should have marks");
12837 assert_eq!(marks.len(), 1);
12838 assert_eq!(marks[0].mark_type, "border");
12839 let attrs = marks[0].attrs.as_ref().unwrap();
12840 assert_eq!(attrs["color"], "#091e4224");
12841 assert_eq!(attrs["size"], 2);
12842 }
12843
12844 #[test]
12845 fn media_external_border_mark_roundtrip() {
12846 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}}]}]}]}"##;
12847 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12848 let md = adf_to_markdown(&doc).unwrap();
12849 assert!(
12850 md.contains("border-color=#ff0000"),
12851 "external media should have border-color in md: {md}"
12852 );
12853 assert!(
12854 md.contains("border-size=3"),
12855 "external media should have border-size in md: {md}"
12856 );
12857 let rt = markdown_to_adf(&md).unwrap();
12858 let media = &rt.content[0].content.as_ref().unwrap()[0];
12859 let marks = media.marks.as_ref().expect("media should have marks");
12860 assert_eq!(marks[0].mark_type, "border");
12861 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
12862 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12863 }
12864
12865 #[test]
12866 fn media_file_no_border_mark_unchanged() {
12867 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}}]}]}"#;
12868 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12869 let md = adf_to_markdown(&doc).unwrap();
12870 assert!(
12871 !md.contains("border-color"),
12872 "no border attrs expected: {md}"
12873 );
12874 let rt = markdown_to_adf(&md).unwrap();
12875 let media = &rt.content[0].content.as_ref().unwrap()[0];
12876 assert!(media.marks.is_none(), "no marks expected on plain media");
12877 }
12878
12879 #[test]
12880 fn media_border_size_only_defaults_color() {
12881 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}}]}]}]}"#;
12882 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12883 let md = adf_to_markdown(&doc).unwrap();
12884 assert!(md.contains("border-size=4"), "md: {md}");
12885 let rt = markdown_to_adf(&md).unwrap();
12886 let media = &rt.content[0].content.as_ref().unwrap()[0];
12887 let marks = media.marks.as_ref().expect("should have border mark");
12888 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
12889 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 4);
12890 }
12891
12892 #[test]
12893 fn media_border_color_only_defaults_size() {
12894 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"}}]}]}]}"##;
12895 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12896 let md = adf_to_markdown(&doc).unwrap();
12897 assert!(md.contains("border-color=#00ff00"), "md: {md}");
12898 let rt = markdown_to_adf(&md).unwrap();
12899 let media = &rt.content[0].content.as_ref().unwrap()[0];
12900 let marks = media.marks.as_ref().expect("should have border mark");
12901 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#00ff00");
12902 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
12903 }
12904
12905 #[test]
12906 fn media_border_with_other_attrs_roundtrip() {
12907 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}}]}]}]}"##;
12908 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12909 let md = adf_to_markdown(&doc).unwrap();
12910 assert!(md.contains("layout=wide"), "md: {md}");
12911 assert!(md.contains("mediaWidth=600"), "md: {md}");
12912 assert!(md.contains("border-color=#091e4224"), "md: {md}");
12913 assert!(md.contains("border-size=2"), "md: {md}");
12914 let rt = markdown_to_adf(&md).unwrap();
12915 let ms = &rt.content[0];
12916 assert_eq!(ms.attrs.as_ref().unwrap()["layout"], "wide");
12917 let media = &ms.content.as_ref().unwrap()[0];
12918 let marks = media.marks.as_ref().expect("should have marks");
12919 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#091e4224");
12920 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 2);
12921 }
12922
12923 #[test]
12924 fn table_row_localid_roundtrip() {
12925 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"}]}]}]}]}]}"#;
12926 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12927 let md = adf_to_markdown(&doc).unwrap();
12928 assert!(
12929 md.contains("localId=tr-001"),
12930 "tableRow should have localId in md: {md}"
12931 );
12932 let rt = markdown_to_adf(&md).unwrap();
12933 let row = &rt.content[0].content.as_ref().unwrap()[0];
12934 assert_eq!(
12935 row.attrs.as_ref().unwrap()["localId"],
12936 "tr-001",
12937 "tableRow localId should round-trip"
12938 );
12939 }
12940
12941 #[test]
12942 fn list_item_localid_roundtrip() {
12943 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"}]}]}]}]}"#;
12945 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12946 let md = adf_to_markdown(&doc).unwrap();
12947 assert!(
12948 md.contains("localId=li-001"),
12949 "listItem should have localId in md: {md}"
12950 );
12951 let rt = markdown_to_adf(&md).unwrap();
12953 let list = &rt.content[0];
12954 assert!(
12955 list.attrs.is_none() || list.attrs.as_ref().unwrap().get("localId").is_none(),
12956 "bulletList should NOT have localId: {:?}",
12957 list.attrs
12958 );
12959 let item = &list.content.as_ref().unwrap()[0];
12960 assert_eq!(
12961 item.attrs.as_ref().unwrap()["localId"],
12962 "li-001",
12963 "listItem should have localId=li-001"
12964 );
12965 }
12966
12967 #[test]
12968 fn list_item_localid_not_promoted_to_parent() {
12969 let md = "- item {localId=li-002}\n";
12971 let doc = markdown_to_adf(md).unwrap();
12972 let list = &doc.content[0];
12973 assert!(
12974 list.attrs.is_none(),
12975 "bulletList should have no attrs: {:?}",
12976 list.attrs
12977 );
12978 let item = &list.content.as_ref().unwrap()[0];
12979 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "li-002");
12980 }
12981
12982 #[test]
12983 fn ordered_list_item_localid_roundtrip() {
12984 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"}]}]}]}]}"#;
12985 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12986 let md = adf_to_markdown(&doc).unwrap();
12987 assert!(md.contains("localId=oli-001"), "md: {md}");
12988 let rt = markdown_to_adf(&md).unwrap();
12989 let item = &rt.content[0].content.as_ref().unwrap()[0];
12990 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
12991 }
12992
12993 #[test]
12994 fn task_item_localid_roundtrip() {
12995 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"}]}]}]}]}"#;
12996 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12997 let md = adf_to_markdown(&doc).unwrap();
12998 assert!(md.contains("localId=ti-001"), "md: {md}");
12999 let rt = markdown_to_adf(&md).unwrap();
13000 let item = &rt.content[0].content.as_ref().unwrap()[0];
13001 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "ti-001");
13002 }
13003
13004 #[test]
13007 fn task_list_short_localid_roundtrip() {
13008 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"}]}]}]}"#;
13009 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13010 let md = adf_to_markdown(&doc).unwrap();
13011 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13013 assert!(md.contains("localId=99"), "localId=99 missing: {md}");
13014 assert!(
13016 !md.contains("localId=}"),
13017 "empty localId should not be emitted: {md}"
13018 );
13019 let rt = markdown_to_adf(&md).unwrap();
13020 let task_list = &rt.content[0];
13021 assert_eq!(task_list.node_type, "taskList");
13022 assert_eq!(rt.content.len(), 1, "should be exactly one top-level node");
13024 let items = task_list.content.as_ref().unwrap();
13025 assert_eq!(items.len(), 2);
13026 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13028 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
13029 assert!(
13030 items[0].content.is_none(),
13031 "empty taskItem should have no content: {:?}",
13032 items[0].content
13033 );
13034 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "99");
13036 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
13037 let content = items[1].content.as_ref().unwrap();
13038 assert_eq!(content.len(), 1);
13039 assert_eq!(content[0].text.as_deref(), Some("done task"));
13040 }
13041
13042 #[test]
13046 fn task_item_numeric_localid_with_hardbreak_roundtrip() {
13047 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!!)"}]}]}]}]}"#;
13048 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13049 let md = adf_to_markdown(&doc).unwrap();
13050 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13052 let rt = markdown_to_adf(&md).unwrap();
13054 assert_eq!(rt.content.len(), 1, "exactly one top-level node");
13055 let task_list = &rt.content[0];
13056 assert_eq!(task_list.node_type, "taskList");
13057 let items = task_list.content.as_ref().unwrap();
13058 assert_eq!(items.len(), 1);
13059 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13061 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
13062 let para = &items[0].content.as_ref().unwrap()[0];
13064 assert_eq!(para.node_type, "paragraph");
13065 let inlines = para.content.as_ref().unwrap();
13066 assert_eq!(inlines[0].node_type, "text");
13067 assert_eq!(
13068 inlines[0].text.as_deref(),
13069 Some("Engineering Onboarding Link")
13070 );
13071 assert_eq!(inlines[1].node_type, "hardBreak");
13072 assert_eq!(inlines[2].node_type, "text");
13073 assert_eq!(
13074 inlines[2].text.as_deref(),
13075 Some("(This has links to all the various useful tools!!)")
13076 );
13077 let rt_json = serde_json::to_string(&rt).unwrap();
13079 assert!(
13080 !rt_json.contains("{localId="),
13081 "localId attr syntax should not leak into ADF text: {rt_json}"
13082 );
13083 }
13084
13085 #[test]
13087 fn task_item_multiple_hardbreak_localids_roundtrip() {
13088 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"}]}]}]}]}"#;
13089 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13090 let md = adf_to_markdown(&doc).unwrap();
13091 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13092 assert!(md.contains("localId=67"), "localId=67 missing: {md}");
13093 let rt = markdown_to_adf(&md).unwrap();
13094 let items = rt.content[0].content.as_ref().unwrap();
13095 assert_eq!(items.len(), 2);
13096 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13097 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "67");
13098 for item in items {
13100 let para = &item.content.as_ref().unwrap()[0];
13101 assert_eq!(para.node_type, "paragraph");
13102 let inlines = para.content.as_ref().unwrap();
13103 assert_eq!(inlines[1].node_type, "hardBreak");
13104 }
13105 }
13106
13107 #[test]
13112 fn task_item_sibling_localid_hardbreak_unwrapped_roundtrip() {
13113 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"}]}]}]}"#;
13114 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13115 let md = adf_to_markdown(&doc).unwrap();
13116 assert!(
13118 md.contains(" (parenthetical"),
13119 "continuation line should be 2-space indented: {md}"
13120 );
13121 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13122 assert!(md.contains("localId=69"), "localId=69 missing: {md}");
13123 let rt = markdown_to_adf(&md).unwrap();
13124 assert_eq!(
13126 rt.content.len(),
13127 1,
13128 "should be one taskList: {:#?}",
13129 rt.content
13130 );
13131 assert_eq!(rt.content[0].node_type, "taskList");
13132 let items = rt.content[0].content.as_ref().unwrap();
13133 assert_eq!(items.len(), 2, "should have 2 taskItems");
13134 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13135 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13136 let first_content = items[0].content.as_ref().unwrap();
13138 assert!(
13139 first_content.iter().any(|n| n.node_type == "hardBreak"),
13140 "first item should contain hardBreak"
13141 );
13142 let second_content = items[1].content.as_ref().unwrap();
13144 assert_eq!(second_content[0].node_type, "text");
13145 assert_eq!(
13146 second_content[0].text.as_deref().unwrap(),
13147 "second task item"
13148 );
13149 }
13150
13151 #[test]
13154 fn task_item_sibling_localid_hardbreak_paragraph_roundtrip() {
13155 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"}]}]}]}]}"#;
13156 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13157 let md = adf_to_markdown(&doc).unwrap();
13158 let rt = markdown_to_adf(&md).unwrap();
13159 assert_eq!(
13160 rt.content.len(),
13161 1,
13162 "should be one taskList: {:#?}",
13163 rt.content
13164 );
13165 let items = rt.content[0].content.as_ref().unwrap();
13166 assert_eq!(items.len(), 2);
13167 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13168 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13169 }
13170
13171 #[test]
13174 fn task_item_three_siblings_middle_hardbreak_roundtrip() {
13175 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"}]}]}]}"#;
13176 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13177 let md = adf_to_markdown(&doc).unwrap();
13178 let rt = markdown_to_adf(&md).unwrap();
13179 assert_eq!(rt.content.len(), 1);
13180 let items = rt.content[0].content.as_ref().unwrap();
13181 assert_eq!(items.len(), 3);
13182 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "10");
13183 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "20");
13184 assert_eq!(items[2].attrs.as_ref().unwrap()["localId"], "30");
13185 let mid_content = items[1].content.as_ref().unwrap();
13187 assert!(mid_content.iter().any(|n| n.node_type == "hardBreak"));
13188 }
13189
13190 #[test]
13193 fn task_list_empty_localid_no_spurious_paragraph() {
13194 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"}]}]}]}"#;
13195 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13196 let md = adf_to_markdown(&doc).unwrap();
13197 assert!(
13198 !md.contains("{localId=}"),
13199 "empty localId should not be emitted: {md}"
13200 );
13201 let rt = markdown_to_adf(&md).unwrap();
13202 assert_eq!(
13203 rt.content.len(),
13204 1,
13205 "no spurious paragraph: {:#?}",
13206 rt.content
13207 );
13208 assert_eq!(rt.content[0].node_type, "taskList");
13209 }
13210
13211 #[test]
13213 fn task_list_localid_stripped() {
13214 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"}]}]}]}]}"#;
13215 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13216 let opts = RenderOptions {
13217 strip_local_ids: true,
13218 };
13219 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13220 assert!(!md.contains("localId"), "localId should be stripped: {md}");
13221 }
13222
13223 #[test]
13225 fn task_item_no_content_emits_localid() {
13226 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"}}]}]}"#;
13227 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13228 let md = adf_to_markdown(&doc).unwrap();
13229 assert!(
13230 md.contains("localId=abc"),
13231 "localId should be emitted even without content: {md}"
13232 );
13233 let rt = markdown_to_adf(&md).unwrap();
13234 let item = &rt.content[0].content.as_ref().unwrap()[0];
13235 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "abc");
13236 assert!(item.content.is_none(), "should have no content");
13237 }
13238
13239 #[test]
13241 fn task_list_localid_roundtrip() {
13242 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"}]}]}]}]}"#;
13243 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13244 let md = adf_to_markdown(&doc).unwrap();
13245 assert!(
13246 md.contains("localId=tl-xyz"),
13247 "taskList localId missing: {md}"
13248 );
13249 let rt = markdown_to_adf(&md).unwrap();
13250 assert_eq!(
13251 rt.content[0].attrs.as_ref().unwrap()["localId"],
13252 "tl-xyz",
13253 "taskList localId should survive round-trip"
13254 );
13255 }
13256
13257 #[test]
13259 fn task_item_paragraph_wrapper_roundtrip_no_localid() {
13260 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"}]}]}]}]}"#;
13261 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13262 let md = adf_to_markdown(&doc).unwrap();
13263 assert!(
13264 md.contains("paraLocalId=_"),
13265 "should emit paraLocalId=_ sentinel: {md}"
13266 );
13267 let rt = markdown_to_adf(&md).unwrap();
13268 let item = &rt.content[0].content.as_ref().unwrap()[0];
13269 let content = item.content.as_ref().unwrap();
13270 assert_eq!(content.len(), 1, "should have one child: {content:#?}");
13271 assert_eq!(
13272 content[0].node_type, "paragraph",
13273 "child should be a paragraph: {content:#?}"
13274 );
13275 let para_content = content[0].content.as_ref().unwrap();
13276 assert_eq!(
13277 para_content[0].text.as_deref(),
13278 Some("A task with paragraph wrapper")
13279 );
13280 assert!(
13282 content[0].attrs.is_none(),
13283 "paragraph should have no attrs: {:?}",
13284 content[0].attrs
13285 );
13286 }
13287
13288 #[test]
13290 fn task_item_paragraph_wrapper_roundtrip_with_localid() {
13291 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"}]}]}]}]}"#;
13292 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13293 let md = adf_to_markdown(&doc).unwrap();
13294 assert!(
13295 md.contains("paraLocalId=p-001"),
13296 "should emit paraLocalId=p-001: {md}"
13297 );
13298 let rt = markdown_to_adf(&md).unwrap();
13299 let item = &rt.content[0].content.as_ref().unwrap()[0];
13300 let content = item.content.as_ref().unwrap();
13301 assert_eq!(content[0].node_type, "paragraph");
13302 assert_eq!(
13303 content[0].attrs.as_ref().unwrap()["localId"],
13304 "p-001",
13305 "paragraph localId should be preserved"
13306 );
13307 }
13308
13309 #[test]
13311 fn task_item_unwrapped_inline_no_paragraph_on_roundtrip() {
13312 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"}]}]}]}"#;
13313 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13314 let md = adf_to_markdown(&doc).unwrap();
13315 assert!(
13316 !md.contains("paraLocalId"),
13317 "should NOT emit paraLocalId for unwrapped inline: {md}"
13318 );
13319 let rt = markdown_to_adf(&md).unwrap();
13320 let item = &rt.content[0].content.as_ref().unwrap()[0];
13321 let content = item.content.as_ref().unwrap();
13322 assert_eq!(
13323 content[0].node_type, "text",
13324 "should remain unwrapped: {content:#?}"
13325 );
13326 }
13327
13328 #[test]
13330 fn task_item_done_paragraph_wrapper_roundtrip() {
13331 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"}]}]}]}]}"#;
13332 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13333 let md = adf_to_markdown(&doc).unwrap();
13334 assert!(md.contains("- [x]"), "should render as done: {md}");
13335 let rt = markdown_to_adf(&md).unwrap();
13336 let item = &rt.content[0].content.as_ref().unwrap()[0];
13337 assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
13338 let content = item.content.as_ref().unwrap();
13339 assert_eq!(content[0].node_type, "paragraph");
13340 }
13341
13342 #[test]
13344 fn task_item_mixed_paragraph_and_unwrapped_roundtrip() {
13345 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"}]}]}]}"#;
13346 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13347 let md = adf_to_markdown(&doc).unwrap();
13348 let rt = markdown_to_adf(&md).unwrap();
13349 let items = rt.content[0].content.as_ref().unwrap();
13350 assert_eq!(items.len(), 2);
13351 let c1 = items[0].content.as_ref().unwrap();
13353 assert_eq!(
13354 c1[0].node_type, "paragraph",
13355 "first item should have paragraph wrapper"
13356 );
13357 let c2 = items[1].content.as_ref().unwrap();
13359 assert_eq!(
13360 c2[0].node_type, "text",
13361 "second item should remain unwrapped"
13362 );
13363 }
13364
13365 #[test]
13367 fn task_item_paragraph_wrapper_with_marks_roundtrip() {
13368 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"}]}]}]}]}]}"#;
13369 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13370 let md = adf_to_markdown(&doc).unwrap();
13371 let rt = markdown_to_adf(&md).unwrap();
13372 let item = &rt.content[0].content.as_ref().unwrap()[0];
13373 let content = item.content.as_ref().unwrap();
13374 assert_eq!(content[0].node_type, "paragraph");
13375 let para_children = content[0].content.as_ref().unwrap();
13376 assert!(
13377 para_children.len() >= 2,
13378 "paragraph should contain multiple inline nodes"
13379 );
13380 }
13381
13382 #[test]
13384 fn task_item_paragraph_wrapper_stripped_with_option() {
13385 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"}]}]}]}]}"#;
13386 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13387 let opts = RenderOptions {
13388 strip_local_ids: true,
13389 };
13390 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13391 assert!(
13392 !md.contains("paraLocalId"),
13393 "paraLocalId should be stripped: {md}"
13394 );
13395 assert!(
13396 !md.contains("localId"),
13397 "all localIds should be stripped: {md}"
13398 );
13399 }
13400
13401 #[test]
13402 fn trailing_space_preserved_with_hex_localid() {
13403 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 "}]}]}]}]}"#;
13406 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13407 let md = adf_to_markdown(&doc).unwrap();
13408 let rt = markdown_to_adf(&md).unwrap();
13409 let item = &rt.content[0].content.as_ref().unwrap()[0];
13410 assert_eq!(
13411 item.attrs.as_ref().unwrap()["localId"],
13412 "aabb112233cc",
13413 "localId should round-trip"
13414 );
13415 let para = &item.content.as_ref().unwrap()[0];
13416 let inlines = para.content.as_ref().unwrap();
13417 let last = inlines.last().unwrap();
13418 assert!(
13419 last.text.as_deref().unwrap_or("").ends_with(' '),
13420 "trailing space should be preserved, got nodes: {:?}",
13421 inlines
13422 .iter()
13423 .map(|n| (&n.node_type, &n.text))
13424 .collect::<Vec<_>>()
13425 );
13426 }
13427
13428 #[test]
13429 fn extract_trailing_local_id_preserves_trailing_space() {
13430 let (before, lid, _) = extract_trailing_local_id("trailing space {localId=aabb112233cc}");
13432 assert_eq!(before, "trailing space ");
13433 assert_eq!(lid.as_deref(), Some("aabb112233cc"));
13434 }
13435
13436 #[test]
13437 fn extract_trailing_local_id_no_trailing_space() {
13438 let (before, lid, _) = extract_trailing_local_id("text {localId=abc123}");
13439 assert_eq!(before, "text");
13440 assert_eq!(lid.as_deref(), Some("abc123"));
13441 }
13442
13443 #[test]
13444 fn extract_trailing_local_id_no_attrs() {
13445 let (before, lid, pid) = extract_trailing_local_id("plain text");
13446 assert_eq!(before, "plain text");
13447 assert!(lid.is_none());
13448 assert!(pid.is_none());
13449 }
13450
13451 #[test]
13452 fn list_item_localid_stripped() {
13453 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"}]}]}]}]}"#;
13454 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13455 let opts = RenderOptions {
13456 strip_local_ids: true,
13457 };
13458 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13459 assert!(!md.contains("localId"), "localId should be stripped: {md}");
13460 }
13461
13462 #[test]
13463 fn paragraph_localid_in_list_item_roundtrip() {
13464 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"}]}]}]}]}"#;
13466 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13467 let md = adf_to_markdown(&doc).unwrap();
13468 assert!(
13469 md.contains("paraLocalId=para-001"),
13470 "paragraph localId should be in md: {md}"
13471 );
13472 let rt = markdown_to_adf(&md).unwrap();
13473 let item = &rt.content[0].content.as_ref().unwrap()[0];
13474 assert_eq!(
13475 item.attrs.as_ref().unwrap()["localId"],
13476 "item-001",
13477 "listItem localId should survive"
13478 );
13479 let para = &item.content.as_ref().unwrap()[0];
13480 assert_eq!(
13481 para.attrs.as_ref().unwrap()["localId"],
13482 "para-001",
13483 "paragraph localId should survive round-trip"
13484 );
13485 }
13486
13487 #[test]
13488 fn paragraph_localid_in_ordered_list_item_roundtrip() {
13489 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"}]}]}]}]}"#;
13491 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13492 let md = adf_to_markdown(&doc).unwrap();
13493 assert!(md.contains("paraLocalId=op-001"), "md: {md}");
13494 let rt = markdown_to_adf(&md).unwrap();
13495 let item = &rt.content[0].content.as_ref().unwrap()[0];
13496 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
13497 let para = &item.content.as_ref().unwrap()[0];
13498 assert_eq!(para.attrs.as_ref().unwrap()["localId"], "op-001");
13499 }
13500
13501 #[test]
13502 fn paragraph_localid_only_in_list_item() {
13503 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"}]}]}]}]}"#;
13505 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13506 let md = adf_to_markdown(&doc).unwrap();
13507 assert!(
13508 md.contains("paraLocalId=para-only"),
13509 "paragraph localId should be emitted: {md}"
13510 );
13511 let rt = markdown_to_adf(&md).unwrap();
13512 let item = &rt.content[0].content.as_ref().unwrap()[0];
13513 assert!(item.attrs.is_none(), "listItem should have no attrs");
13514 let para = &item.content.as_ref().unwrap()[0];
13515 assert_eq!(para.attrs.as_ref().unwrap()["localId"], "para-only");
13516 }
13517
13518 #[test]
13519 fn paragraph_localid_in_table_header_roundtrip() {
13520 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"}]}]}]}]}]}"#;
13522 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13523 let md = adf_to_markdown(&doc).unwrap();
13524 assert!(
13526 md.contains("localId=aaaa-aaaa"),
13527 "paragraph localId should be in md: {md}"
13528 );
13529 let rt = markdown_to_adf(&md).unwrap();
13530 let cell = &rt.content[0].content.as_ref().unwrap()[0]
13531 .content
13532 .as_ref()
13533 .unwrap()[0];
13534 let para = &cell.content.as_ref().unwrap()[0];
13535 assert_eq!(
13536 para.attrs.as_ref().unwrap()["localId"],
13537 "aaaa-aaaa",
13538 "paragraph localId should survive round-trip in tableHeader"
13539 );
13540 }
13541
13542 #[test]
13543 fn paragraph_localid_in_table_cell_roundtrip() {
13544 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"}]}]}]}]}]}"#;
13546 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13547 let md = adf_to_markdown(&doc).unwrap();
13548 assert!(
13549 md.contains("localId=cell-para"),
13550 "paragraph localId should be in md: {md}"
13551 );
13552 let rt = markdown_to_adf(&md).unwrap();
13553 let cell = &rt.content[0].content.as_ref().unwrap()[1]
13555 .content
13556 .as_ref()
13557 .unwrap()[0];
13558 let para = &cell.content.as_ref().unwrap()[0];
13559 assert_eq!(
13560 para.attrs.as_ref().unwrap()["localId"],
13561 "cell-para",
13562 "paragraph localId should survive round-trip in tableCell"
13563 );
13564 }
13565
13566 #[test]
13567 fn nbsp_paragraph_with_localid_roundtrip() {
13568 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"nbsp-para"},"content":[{"type":"text","text":"\u00a0"}]}]}"#;
13570 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13571 let md = adf_to_markdown(&doc).unwrap();
13572 assert!(
13573 md.contains("::paragraph["),
13574 "nbsp should use directive form: {md}"
13575 );
13576 assert!(
13577 md.contains("localId=nbsp-para"),
13578 "localId should be in directive: {md}"
13579 );
13580 let rt = markdown_to_adf(&md).unwrap();
13581 let para = &rt.content[0];
13582 assert_eq!(
13583 para.attrs.as_ref().unwrap()["localId"],
13584 "nbsp-para",
13585 "localId should survive round-trip"
13586 );
13587 let text = para.content.as_ref().unwrap()[0].text.as_ref().unwrap();
13588 assert_eq!(text, "\u{00a0}", "nbsp should survive");
13589 }
13590
13591 #[test]
13592 fn empty_paragraph_with_localid_roundtrip() {
13593 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"empty-para"}}]}"#;
13595 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13596 let md = adf_to_markdown(&doc).unwrap();
13597 assert!(
13598 md.contains("::paragraph{localId=empty-para}"),
13599 "empty paragraph should include localId in directive: {md}"
13600 );
13601 let rt = markdown_to_adf(&md).unwrap();
13602 assert_eq!(
13603 rt.content[0].attrs.as_ref().unwrap()["localId"],
13604 "empty-para"
13605 );
13606 }
13607
13608 #[test]
13609 fn paragraph_localid_stripped_from_list_item() {
13610 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"}]}]}]}]}"#;
13612 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13613 let opts = RenderOptions {
13614 strip_local_ids: true,
13615 };
13616 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13617 assert!(!md.contains("localId"), "localId should be stripped: {md}");
13618 assert!(
13619 !md.contains("paraLocalId"),
13620 "paraLocalId should be stripped: {md}"
13621 );
13622 }
13623
13624 #[test]
13625 fn date_directive() {
13626 let doc = markdown_to_adf("Due by :date[2026-04-15].").unwrap();
13627 let content = doc.content[0].content.as_ref().unwrap();
13628 assert_eq!(content[1].node_type, "date");
13629 assert_eq!(
13631 content[1].attrs.as_ref().unwrap()["timestamp"],
13632 "1776211200000"
13633 );
13634 }
13635
13636 #[test]
13637 fn adf_date_to_markdown() {
13638 let doc = AdfDocument {
13640 version: 1,
13641 doc_type: "doc".to_string(),
13642 content: vec![AdfNode::paragraph(vec![AdfNode::date("1776211200000")])],
13643 };
13644 let md = adf_to_markdown(&doc).unwrap();
13645 assert!(md.contains(":date[2026-04-15]{timestamp=1776211200000}"));
13646 }
13647
13648 #[test]
13649 fn adf_date_iso_passthrough() {
13650 let doc = AdfDocument {
13652 version: 1,
13653 doc_type: "doc".to_string(),
13654 content: vec![AdfNode::paragraph(vec![AdfNode::date("2026-04-15")])],
13655 };
13656 let md = adf_to_markdown(&doc).unwrap();
13657 assert!(md.contains(":date[2026-04-15]{timestamp=2026-04-15}"));
13658 }
13659
13660 #[test]
13661 fn round_trip_date() {
13662 let md = "Due by :date[2026-04-15].\n";
13663 let doc = markdown_to_adf(md).unwrap();
13664 let result = adf_to_markdown(&doc).unwrap();
13665 assert!(result.contains(":date[2026-04-15]"));
13666 }
13667
13668 #[test]
13669 fn round_trip_date_non_midnight_timestamp() {
13670 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000"}}]}]}"#;
13672 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13673 let md = adf_to_markdown(&doc).unwrap();
13674 assert!(
13676 md.contains("timestamp=1700000000000"),
13677 "JFM should preserve original timestamp: {md}"
13678 );
13679 let doc2 = markdown_to_adf(&md).unwrap();
13681 let content = doc2.content[0].content.as_ref().unwrap();
13682 assert_eq!(
13683 content[0].attrs.as_ref().unwrap()["timestamp"],
13684 "1700000000000",
13685 "Round-trip must preserve original non-midnight timestamp"
13686 );
13687 }
13688
13689 #[test]
13690 fn date_epoch_ms_passthrough() {
13691 let doc = markdown_to_adf("Due by :date[1776211200000].").unwrap();
13693 let content = doc.content[0].content.as_ref().unwrap();
13694 assert_eq!(
13695 content[1].attrs.as_ref().unwrap()["timestamp"],
13696 "1776211200000"
13697 );
13698 }
13699
13700 #[test]
13701 fn date_timestamp_attr_preferred_over_content() {
13702 let md = ":date[2023-11-14]{timestamp=1700000000000}\n";
13704 let doc = markdown_to_adf(md).unwrap();
13705 let content = doc.content[0].content.as_ref().unwrap();
13706 assert_eq!(
13707 content[0].attrs.as_ref().unwrap()["timestamp"],
13708 "1700000000000",
13709 "timestamp attr should be used directly"
13710 );
13711 }
13712
13713 #[test]
13714 fn date_without_timestamp_attr_backward_compat() {
13715 let md = ":date[2026-04-15]\n";
13717 let doc = markdown_to_adf(md).unwrap();
13718 let content = doc.content[0].content.as_ref().unwrap();
13719 assert_eq!(
13720 content[0].attrs.as_ref().unwrap()["timestamp"],
13721 "1776211200000",
13722 "Should fall back to computing timestamp from date string"
13723 );
13724 }
13725
13726 #[test]
13727 fn date_with_local_id_and_timestamp() {
13728 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
13730 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13731 let md = adf_to_markdown(&doc).unwrap();
13732 assert!(
13733 md.contains("timestamp=1700000000000"),
13734 "Should contain timestamp: {md}"
13735 );
13736 assert!(md.contains("localId=d-001"), "Should contain localId: {md}");
13737 let doc2 = markdown_to_adf(&md).unwrap();
13739 let content = doc2.content[0].content.as_ref().unwrap();
13740 let attrs = content[0].attrs.as_ref().unwrap();
13741 assert_eq!(attrs["timestamp"], "1700000000000");
13742 assert_eq!(attrs["localId"], "d-001");
13743 }
13744
13745 #[test]
13746 fn mention_directive() {
13747 let doc = markdown_to_adf("Assigned to :mention[Alice]{id=abc123}.").unwrap();
13748 let content = doc.content[0].content.as_ref().unwrap();
13749 assert_eq!(content[1].node_type, "mention");
13750 assert_eq!(content[1].attrs.as_ref().unwrap()["id"], "abc123");
13751 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "Alice");
13752 }
13753
13754 #[test]
13755 fn adf_mention_to_markdown() {
13756 let doc = AdfDocument {
13757 version: 1,
13758 doc_type: "doc".to_string(),
13759 content: vec![AdfNode::paragraph(vec![AdfNode::mention(
13760 "abc123", "Alice",
13761 )])],
13762 };
13763 let md = adf_to_markdown(&doc).unwrap();
13764 assert!(md.contains(":mention[Alice]{id=abc123}"));
13765 }
13766
13767 #[test]
13768 fn round_trip_mention() {
13769 let md = "Assigned to :mention[Alice]{id=abc123}.\n";
13770 let doc = markdown_to_adf(md).unwrap();
13771 let result = adf_to_markdown(&doc).unwrap();
13772 assert!(result.contains(":mention[Alice]{id=abc123}"));
13773 }
13774
13775 #[test]
13776 fn mention_with_empty_access_level_round_trips() {
13777 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13779 {"type":"mention","attrs":{"id":"61921b41c15977006af2b1d1","text":"@Javier Inchausti","accessLevel":""}}
13780 ]}]}"#;
13781 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13782
13783 let md = adf_to_markdown(&doc).unwrap();
13784 let round_tripped = markdown_to_adf(&md).unwrap();
13785 let mention = &round_tripped.content[0].content.as_ref().unwrap()[0];
13786 assert_eq!(
13787 mention.node_type, "mention",
13788 "mention with empty accessLevel was not parsed as mention, got: {}",
13789 mention.node_type
13790 );
13791 }
13792
13793 #[test]
13794 fn span_with_color() {
13795 let doc = markdown_to_adf("This is :span[red text]{color=#ff5630}.").unwrap();
13796 let content = doc.content[0].content.as_ref().unwrap();
13797 assert_eq!(content[1].node_type, "text");
13798 assert_eq!(content[1].text.as_deref(), Some("red text"));
13799 let marks = content[1].marks.as_ref().unwrap();
13800 assert_eq!(marks[0].mark_type, "textColor");
13801 }
13802
13803 #[test]
13804 fn adf_text_color_to_markdown() {
13805 let doc = AdfDocument {
13806 version: 1,
13807 doc_type: "doc".to_string(),
13808 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
13809 "red text",
13810 vec![AdfMark::text_color("#ff5630")],
13811 )])],
13812 };
13813 let md = adf_to_markdown(&doc).unwrap();
13814 assert!(md.contains(":span[red text]{color=#ff5630}"));
13815 }
13816
13817 #[test]
13818 fn round_trip_span_color() {
13819 let md = "This is :span[red text]{color=#ff5630}.\n";
13820 let doc = markdown_to_adf(md).unwrap();
13821 let result = adf_to_markdown(&doc).unwrap();
13822 assert!(result.contains(":span[red text]{color=#ff5630}"));
13823 }
13824
13825 #[test]
13826 fn text_color_and_link_marks_both_preserved() {
13827 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13829 {"type":"text","text":"red link","marks":[
13830 {"type":"link","attrs":{"href":"https://example.com"}},
13831 {"type":"textColor","attrs":{"color":"#ff0000"}}
13832 ]}
13833 ]}]}"##;
13834 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13835 let md = adf_to_markdown(&doc).unwrap();
13836 assert!(
13837 md.contains(":span[red link]{color=#ff0000}"),
13838 "JFM should contain span with color, got: {md}"
13839 );
13840 assert!(
13841 md.contains("](https://example.com)"),
13842 "JFM should contain link href, got: {md}"
13843 );
13844 let rt = markdown_to_adf(&md).unwrap();
13846 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
13847 let marks = text_node.marks.as_ref().expect("should have marks");
13848 assert!(
13849 marks.iter().any(|m| m.mark_type == "textColor"),
13850 "should have textColor mark, got: {:?}",
13851 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13852 );
13853 assert!(
13854 marks.iter().any(|m| m.mark_type == "link"),
13855 "should have link mark, got: {:?}",
13856 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13857 );
13858 let link_mark = marks.iter().find(|m| m.mark_type == "link").unwrap();
13860 assert_eq!(
13861 link_mark.attrs.as_ref().unwrap()["href"],
13862 "https://example.com"
13863 );
13864 let color_mark = marks.iter().find(|m| m.mark_type == "textColor").unwrap();
13865 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#ff0000");
13866 }
13867
13868 #[test]
13869 fn bg_color_and_link_marks_both_preserved() {
13870 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13871 {"type":"text","text":"highlighted link","marks":[
13872 {"type":"link","attrs":{"href":"https://example.com"}},
13873 {"type":"backgroundColor","attrs":{"color":"#ffff00"}}
13874 ]}
13875 ]}]}"##;
13876 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13877 let md = adf_to_markdown(&doc).unwrap();
13878 assert!(md.contains("bg=#ffff00"), "should have bg color: {md}");
13879 assert!(
13880 md.contains("](https://example.com)"),
13881 "should have link: {md}"
13882 );
13883 let rt = markdown_to_adf(&md).unwrap();
13884 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
13885 let marks = text_node.marks.as_ref().expect("should have marks");
13886 assert!(marks.iter().any(|m| m.mark_type == "backgroundColor"));
13887 assert!(marks.iter().any(|m| m.mark_type == "link"));
13888 }
13889
13890 #[test]
13891 fn text_color_link_and_strong_rendering() {
13892 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13894 {"type":"text","text":"bold red link","marks":[
13895 {"type":"strong"},
13896 {"type":"link","attrs":{"href":"https://example.com"}},
13897 {"type":"textColor","attrs":{"color":"#ff0000"}}
13898 ]}
13899 ]}]}"##;
13900 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13901 let md = adf_to_markdown(&doc).unwrap();
13902 assert!(
13903 md.starts_with("**") && md.trim().ends_with("**"),
13904 "should have bold wrapping: {md}"
13905 );
13906 assert!(md.contains("color=#ff0000"), "should have color: {md}");
13907 assert!(
13908 md.contains("](https://example.com)"),
13909 "should have link: {md}"
13910 );
13911 }
13912
13913 #[test]
13914 fn subsup_and_link_marks_both_preserved() {
13915 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13916 {"type":"text","text":"note","marks":[
13917 {"type":"link","attrs":{"href":"https://example.com"}},
13918 {"type":"subsup","attrs":{"type":"sup"}}
13919 ]}
13920 ]}]}"#;
13921 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13922 let md = adf_to_markdown(&doc).unwrap();
13923 assert!(md.contains("sup"), "should have sup: {md}");
13924 assert!(
13925 md.contains("](https://example.com)"),
13926 "should have link: {md}"
13927 );
13928 let rt = markdown_to_adf(&md).unwrap();
13929 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
13930 let marks = text_node.marks.as_ref().expect("should have marks");
13931 assert!(marks.iter().any(|m| m.mark_type == "subsup"));
13932 assert!(marks.iter().any(|m| m.mark_type == "link"));
13933 }
13934
13935 #[test]
13936 fn text_color_without_link_unchanged() {
13937 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13939 {"type":"text","text":"just red","marks":[
13940 {"type":"textColor","attrs":{"color":"#ff0000"}}
13941 ]}
13942 ]}]}"##;
13943 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13944 let md = adf_to_markdown(&doc).unwrap();
13945 assert!(md.contains(":span[just red]{color=#ff0000}"), "md: {md}");
13946 assert!(!md.contains("](http"), "should NOT have link syntax: {md}");
13947 }
13948
13949 #[test]
13950 fn inline_extension_directive() {
13951 let doc =
13952 markdown_to_adf("See :extension[fallback]{type=com.app key=widget} here.").unwrap();
13953 let content = doc.content[0].content.as_ref().unwrap();
13954 assert_eq!(content[1].node_type, "inlineExtension");
13955 assert_eq!(
13956 content[1].attrs.as_ref().unwrap()["extensionType"],
13957 "com.app"
13958 );
13959 assert_eq!(content[1].attrs.as_ref().unwrap()["extensionKey"], "widget");
13960 }
13961
13962 #[test]
13963 fn adf_inline_extension_to_markdown() {
13964 let doc = AdfDocument {
13965 version: 1,
13966 doc_type: "doc".to_string(),
13967 content: vec![AdfNode::paragraph(vec![AdfNode::inline_extension(
13968 "com.app",
13969 "widget",
13970 Some("fallback"),
13971 )])],
13972 };
13973 let md = adf_to_markdown(&doc).unwrap();
13974 assert!(md.contains(":extension[fallback]{type=com.app key=widget}"));
13975 }
13976
13977 #[test]
13980 fn parse_ordered_list_marker_valid() {
13981 let result = parse_ordered_list_marker("1. Hello");
13982 assert_eq!(result, Some((1, "Hello")));
13983 }
13984
13985 #[test]
13986 fn parse_ordered_list_marker_high_number() {
13987 let result = parse_ordered_list_marker("42. Item");
13988 assert_eq!(result, Some((42, "Item")));
13989 }
13990
13991 #[test]
13992 fn parse_ordered_list_marker_not_a_list() {
13993 assert!(parse_ordered_list_marker("not a list").is_none());
13994 assert!(parse_ordered_list_marker("1.no space").is_none());
13995 }
13996
13997 #[test]
13998 fn is_list_start_various() {
13999 assert!(is_list_start("- item"));
14000 assert!(is_list_start("* item"));
14001 assert!(is_list_start("+ item"));
14002 assert!(is_list_start("1. item"));
14003 assert!(!is_list_start("not a list"));
14004 }
14005
14006 #[test]
14007 fn is_horizontal_rule_various() {
14008 assert!(is_horizontal_rule("---"));
14009 assert!(is_horizontal_rule("***"));
14010 assert!(is_horizontal_rule("___"));
14011 assert!(is_horizontal_rule("------"));
14012 assert!(!is_horizontal_rule("--"));
14013 assert!(!is_horizontal_rule("abc"));
14014 }
14015
14016 #[test]
14017 fn is_table_separator_valid() {
14018 assert!(is_table_separator("| --- | --- |"));
14019 assert!(is_table_separator("|:---:|:---|"));
14020 assert!(!is_table_separator("no pipes here"));
14021 }
14022
14023 #[test]
14024 fn parse_table_row_cells() {
14025 let cells = parse_table_row("| A | B | C |");
14026 assert_eq!(cells, vec!["A", "B", "C"]);
14027 }
14028
14029 #[test]
14030 fn parse_table_row_escaped_pipe_in_cell() {
14031 let cells = parse_table_row(r"| a\|b | c |");
14033 assert_eq!(cells, vec!["a|b", "c"]);
14034 }
14035
14036 #[test]
14037 fn parse_table_row_escaped_pipe_in_code_span() {
14038 let cells = parse_table_row(r"| `parser.decode[T\|json]` | other |");
14040 assert_eq!(cells, vec!["`parser.decode[T|json]`", "other"]);
14041 }
14042
14043 #[test]
14044 fn parse_table_row_preserves_other_backslashes() {
14045 let cells = parse_table_row(r"| a\\b | c\*d |");
14047 assert_eq!(cells, vec![r"a\\b", r"c\*d"]);
14048 }
14049
14050 #[test]
14051 fn parse_image_syntax_valid() {
14052 let result = parse_image_syntax("");
14053 assert_eq!(result, Some(("alt", "url")));
14054 }
14055
14056 #[test]
14057 fn parse_image_syntax_not_image() {
14058 assert!(parse_image_syntax("not an image").is_none());
14059 }
14060
14061 #[test]
14064 fn find_closing_paren_simple() {
14065 assert_eq!(find_closing_paren("(hello)", 0), Some(6));
14066 }
14067
14068 #[test]
14069 fn find_closing_paren_nested() {
14070 assert_eq!(find_closing_paren("(a(b)c)", 0), Some(6));
14071 }
14072
14073 #[test]
14074 fn find_closing_paren_unmatched() {
14075 assert_eq!(find_closing_paren("(no close", 0), None);
14076 }
14077
14078 #[test]
14079 fn find_closing_paren_offset() {
14080 assert_eq!(find_closing_paren("xx(inner)", 2), Some(8));
14082 }
14083
14084 #[test]
14087 fn try_parse_link_url_with_parens() {
14088 let input = "[here](https://example.com/faq#access-(permissions)-rest)";
14089 let result = try_parse_link(input, 0);
14090 assert_eq!(
14091 result,
14092 Some((
14093 input.len(),
14094 "here",
14095 "https://example.com/faq#access-(permissions)-rest"
14096 ))
14097 );
14098 }
14099
14100 #[test]
14101 fn try_parse_link_url_no_parens() {
14102 let input = "[text](https://example.com)";
14103 let result = try_parse_link(input, 0);
14104 assert_eq!(result, Some((input.len(), "text", "https://example.com")));
14105 }
14106
14107 #[test]
14108 fn try_parse_link_url_with_multiple_nested_parens() {
14109 let input = "[x](http://en.wikipedia.org/wiki/Foo_(bar_(baz)))";
14110 let result = try_parse_link(input, 0);
14111 assert_eq!(
14112 result,
14113 Some((
14114 input.len(),
14115 "x",
14116 "http://en.wikipedia.org/wiki/Foo_(bar_(baz))"
14117 ))
14118 );
14119 }
14120
14121 #[test]
14122 fn parse_image_syntax_url_with_parens() {
14123 let result = parse_image_syntax(")");
14124 assert_eq!(result, Some(("alt", "https://example.com/page_(1)")));
14125 }
14126
14127 #[test]
14128 fn parse_image_syntax_url_no_parens() {
14129 let result = parse_image_syntax("");
14130 assert_eq!(result, Some(("alt", "https://example.com")));
14131 }
14132
14133 #[test]
14134 fn link_with_parens_round_trip() {
14135 let href = "https://example.com/faq#I-need-access-(permissions)-added-in-Monitor";
14136 let mut text_node = AdfNode::text("here");
14137 text_node.marks = Some(vec![AdfMark::link(href)]);
14138 let adf_input = AdfDocument {
14139 version: 1,
14140 doc_type: "doc".to_string(),
14141 content: vec![AdfNode::paragraph(vec![text_node])],
14142 };
14143
14144 let jfm = adf_to_markdown(&adf_input).unwrap();
14145 let adf_output = markdown_to_adf(&jfm).unwrap();
14146
14147 let para = &adf_output.content[0];
14149 let text_node = ¶.content.as_ref().unwrap()[0];
14150 let mark = &text_node.marks.as_ref().unwrap()[0];
14151 let result_href = mark.attrs.as_ref().unwrap()["href"].as_str().unwrap();
14152
14153 assert_eq!(result_href, href);
14154 }
14155
14156 #[test]
14157 fn flush_plain_empty_range() {
14158 let mut nodes = Vec::new();
14159 flush_plain("hello", 3, 3, &mut nodes);
14160 assert!(nodes.is_empty());
14161 }
14162
14163 #[test]
14164 fn add_mark_to_unmarked_node() {
14165 let mut node = AdfNode::text("test");
14166 add_mark(&mut node, AdfMark::strong());
14167 assert_eq!(node.marks.as_ref().unwrap().len(), 1);
14168 }
14169
14170 #[test]
14171 fn add_mark_to_marked_node() {
14172 let mut node = AdfNode::text_with_marks("test", vec![AdfMark::strong()]);
14173 add_mark(&mut node, AdfMark::em());
14174 assert_eq!(node.marks.as_ref().unwrap().len(), 2);
14175 }
14176
14177 #[test]
14180 fn directive_table_basic() {
14181 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";
14182 let doc = markdown_to_adf(md).unwrap();
14183 assert_eq!(doc.content[0].node_type, "table");
14184 let rows = doc.content[0].content.as_ref().unwrap();
14185 assert_eq!(rows.len(), 2);
14186 assert_eq!(
14187 rows[0].content.as_ref().unwrap()[0].node_type,
14188 "tableHeader"
14189 );
14190 assert_eq!(rows[1].content.as_ref().unwrap()[0].node_type, "tableCell");
14191 }
14192
14193 #[test]
14194 fn directive_table_with_block_content() {
14195 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";
14196 let doc = markdown_to_adf(md).unwrap();
14197 let rows = doc.content[0].content.as_ref().unwrap();
14198 let cell = &rows[0].content.as_ref().unwrap()[0];
14199 let content = cell.content.as_ref().unwrap();
14201 assert!(content.len() >= 2);
14202 assert_eq!(content[1].node_type, "bulletList");
14203 }
14204
14205 #[test]
14206 fn directive_table_with_cell_attrs() {
14207 let md = "::::table\n:::tr\n:::td{colspan=2 bg=#DEEBFF}\nSpanning cell\n:::\n:::\n::::\n";
14208 let doc = markdown_to_adf(md).unwrap();
14209 let cell = &doc.content[0].content.as_ref().unwrap()[0]
14210 .content
14211 .as_ref()
14212 .unwrap()[0];
14213 let attrs = cell.attrs.as_ref().unwrap();
14214 assert_eq!(attrs["colspan"], 2);
14215 assert_eq!(attrs["background"], "#DEEBFF");
14216 }
14217
14218 #[test]
14219 fn directive_table_with_css_var_background() {
14220 let bg = "var(--ds-background-accent-gray-subtlest, var(--ds-background-accent-gray-subtlest, #F1F2F4))";
14221 let md = format!("::::table\n:::tr\n:::th{{bg=\"{bg}\"}}\nHeader\n:::\n:::\n::::\n");
14222 let doc = markdown_to_adf(&md).unwrap();
14223 let row = &doc.content[0].content.as_ref().unwrap()[0];
14224 let cells = row.content.as_ref().unwrap();
14225 assert_eq!(cells.len(), 1, "row must have at least one cell");
14226 let attrs = cells[0].attrs.as_ref().unwrap();
14227 assert_eq!(attrs["background"], bg);
14228 }
14229
14230 #[test]
14231 fn css_var_background_round_trips() {
14232 let bg = "var(--ds-background-accent-gray-subtlest, #F1F2F4)";
14233 let adf = AdfDocument {
14234 version: 1,
14235 doc_type: "doc".to_string(),
14236 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14237 AdfNode::table_header_with_attrs(
14238 vec![AdfNode::paragraph(vec![AdfNode::text("Header")])],
14239 serde_json::json!({"background": bg}),
14240 ),
14241 ])])],
14242 };
14243 let md = adf_to_markdown(&adf).unwrap();
14244 assert!(
14245 md.contains(&format!("bg=\"{bg}\"")),
14246 "bg value must be quoted in markdown: {md}"
14247 );
14248
14249 let round_tripped = markdown_to_adf(&md).unwrap();
14250 let row = &round_tripped.content[0].content.as_ref().unwrap()[0];
14251 let cells = row.content.as_ref().unwrap();
14252 assert_eq!(cells.len(), 1, "round-tripped row must have one cell");
14253 let rt_attrs = cells[0].attrs.as_ref().unwrap();
14254 assert_eq!(rt_attrs["background"], bg);
14255 }
14256
14257 #[test]
14258 fn directive_table_with_table_attrs() {
14259 let md = "::::table{layout=wide numbered}\n:::tr\n:::td\nCell\n:::\n:::\n::::\n";
14260 let doc = markdown_to_adf(md).unwrap();
14261 let attrs = doc.content[0].attrs.as_ref().unwrap();
14262 assert_eq!(attrs["layout"], "wide");
14263 assert_eq!(attrs["isNumberColumnEnabled"], true);
14264 }
14265
14266 #[test]
14267 fn adf_table_with_block_content_renders_directive_form() {
14268 let doc = AdfDocument {
14270 version: 1,
14271 doc_type: "doc".to_string(),
14272 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14273 AdfNode::table_cell(vec![
14274 AdfNode::paragraph(vec![AdfNode::text("Cell with list:")]),
14275 AdfNode::bullet_list(vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
14276 AdfNode::text("Item 1"),
14277 ])])]),
14278 ]),
14279 ])])],
14280 };
14281 let md = adf_to_markdown(&doc).unwrap();
14282 assert!(md.contains("::::table"));
14283 assert!(md.contains(":::td"));
14284 assert!(md.contains("- Item 1"));
14285 }
14286
14287 #[test]
14288 fn adf_table_inline_only_renders_pipe_form() {
14289 let doc = AdfDocument {
14291 version: 1,
14292 doc_type: "doc".to_string(),
14293 content: vec![AdfNode::table(vec![
14294 AdfNode::table_row(vec![
14295 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
14296 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14297 ]),
14298 AdfNode::table_row(vec![
14299 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
14300 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14301 ]),
14302 ])],
14303 };
14304 let md = adf_to_markdown(&doc).unwrap();
14305 assert!(md.contains("| H1 | H2 |"));
14306 assert!(!md.contains("::::table"));
14307 }
14308
14309 #[test]
14310 fn adf_table_header_outside_first_row_renders_directive() {
14311 let doc = AdfDocument {
14312 version: 1,
14313 doc_type: "doc".to_string(),
14314 content: vec![AdfNode::table(vec![
14315 AdfNode::table_row(vec![
14316 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H")])]),
14317 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C")])]),
14318 ]),
14319 AdfNode::table_row(vec![
14320 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14321 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14322 ]),
14323 ])],
14324 };
14325 let md = adf_to_markdown(&doc).unwrap();
14326 assert!(md.contains("::::table"));
14327 assert!(md.contains(":::th"));
14328 }
14329
14330 #[test]
14331 fn adf_table_cell_attrs_rendered() {
14332 let doc = AdfDocument {
14333 version: 1,
14334 doc_type: "doc".to_string(),
14335 content: vec![AdfNode::table(vec![
14336 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
14337 AdfNode::text("H"),
14338 ])])]),
14339 AdfNode::table_row(vec![AdfNode::table_cell_with_attrs(
14340 vec![AdfNode::paragraph(vec![AdfNode::text("C")])],
14341 serde_json::json!({"background": "#DEEBFF", "colspan": 2}),
14342 )]),
14343 ])],
14344 };
14345 let md = adf_to_markdown(&doc).unwrap();
14346 assert!(md.contains("{colspan=2 bg=#DEEBFF}"));
14347 }
14348
14349 #[test]
14352 fn pipe_table_cell_attrs() {
14353 let md = "| H1 | H2 |\n|---|---|\n| {bg=#DEEBFF} highlighted | normal |\n";
14354 let doc = markdown_to_adf(md).unwrap();
14355 let rows = doc.content[0].content.as_ref().unwrap();
14356 let cell = &rows[1].content.as_ref().unwrap()[0];
14357 let attrs = cell.attrs.as_ref().unwrap();
14358 assert_eq!(attrs["background"], "#DEEBFF");
14359 }
14360
14361 #[test]
14362 fn pipe_table_cell_colspan() {
14363 let md = "| H1 | H2 |\n|---|---|\n| {colspan=2} spanning |\n";
14364 let doc = markdown_to_adf(md).unwrap();
14365 let rows = doc.content[0].content.as_ref().unwrap();
14366 let cell = &rows[1].content.as_ref().unwrap()[0];
14367 let attrs = cell.attrs.as_ref().unwrap();
14368 assert_eq!(attrs["colspan"], 2);
14369 }
14370
14371 #[test]
14372 fn trailing_space_after_mention_in_table_cell_preserved() {
14373 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":[
14375 {"type":"mention","attrs":{"id":"aaa","text":"@Rob"}},
14376 {"type":"text","text":" "}
14377 ]}]}]}]}]}"#;
14378 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14379 let md = adf_to_markdown(&doc).unwrap();
14380 let round_tripped = markdown_to_adf(&md).unwrap();
14381 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14382 .content
14383 .as_ref()
14384 .unwrap()[0];
14385 let para = &cell.content.as_ref().unwrap()[0];
14386 let inlines = para.content.as_ref().unwrap();
14387 assert!(
14388 inlines.len() >= 2,
14389 "expected mention + text(' ') nodes, got {} nodes: {:?}",
14390 inlines.len(),
14391 inlines.iter().map(|n| &n.node_type).collect::<Vec<_>>()
14392 );
14393 assert_eq!(inlines[0].node_type, "mention");
14394 assert_eq!(inlines[1].node_type, "text");
14395 assert_eq!(inlines[1].text.as_deref(), Some(" "));
14396 }
14397
14398 #[test]
14401 fn pipe_table_column_alignment() {
14402 let md = "| Left | Center | Right |\n|:---|:---:|---:|\n| L | C | R |\n";
14403 let doc = markdown_to_adf(md).unwrap();
14404 let rows = doc.content[0].content.as_ref().unwrap();
14405 let h_cells = rows[0].content.as_ref().unwrap();
14407 assert!(h_cells[0].content.as_ref().unwrap()[0].marks.is_none());
14409 let center_marks = h_cells[1].content.as_ref().unwrap()[0]
14411 .marks
14412 .as_ref()
14413 .unwrap();
14414 assert_eq!(center_marks[0].attrs.as_ref().unwrap()["align"], "center");
14415 let right_marks = h_cells[2].content.as_ref().unwrap()[0]
14417 .marks
14418 .as_ref()
14419 .unwrap();
14420 assert_eq!(right_marks[0].attrs.as_ref().unwrap()["align"], "end");
14421 }
14422
14423 #[test]
14424 fn adf_table_alignment_roundtrip() {
14425 let doc = AdfDocument {
14426 version: 1,
14427 doc_type: "doc".to_string(),
14428 content: vec![AdfNode::table(vec![
14429 AdfNode::table_row(vec![
14430 AdfNode::table_header(vec![{
14431 let mut p = AdfNode::paragraph(vec![AdfNode::text("Center")]);
14432 p.marks = Some(vec![AdfMark::alignment("center")]);
14433 p
14434 }]),
14435 AdfNode::table_header(vec![{
14436 let mut p = AdfNode::paragraph(vec![AdfNode::text("Right")]);
14437 p.marks = Some(vec![AdfMark::alignment("end")]);
14438 p
14439 }]),
14440 ]),
14441 AdfNode::table_row(vec![
14442 AdfNode::table_cell(vec![{
14443 let mut p = AdfNode::paragraph(vec![AdfNode::text("C")]);
14444 p.marks = Some(vec![AdfMark::alignment("center")]);
14445 p
14446 }]),
14447 AdfNode::table_cell(vec![{
14448 let mut p = AdfNode::paragraph(vec![AdfNode::text("R")]);
14449 p.marks = Some(vec![AdfMark::alignment("end")]);
14450 p
14451 }]),
14452 ]),
14453 ])],
14454 };
14455 let md = adf_to_markdown(&doc).unwrap();
14456 assert!(md.contains(":---:"));
14457 assert!(md.contains("---:"));
14458 }
14459
14460 #[test]
14463 fn panel_custom_attrs_round_trip() {
14464 let md = ":::panel{type=custom icon=\":star:\" color=\"#DEEBFF\"}\nContent\n:::\n";
14465 let doc = markdown_to_adf(md).unwrap();
14466 let panel = &doc.content[0];
14467 let attrs = panel.attrs.as_ref().unwrap();
14468 assert_eq!(attrs["panelType"], "custom");
14469 assert_eq!(attrs["panelIcon"], ":star:");
14470 assert_eq!(attrs["panelColor"], "#DEEBFF");
14471
14472 let result = adf_to_markdown(&doc).unwrap();
14473 assert!(result.contains("type=custom"));
14474 assert!(result.contains("icon="));
14475 assert!(result.contains("color="));
14476 }
14477
14478 #[test]
14481 fn block_card_with_layout() {
14482 let md = "::card[https://example.com]{layout=wide}\n";
14483 let doc = markdown_to_adf(md).unwrap();
14484 let attrs = doc.content[0].attrs.as_ref().unwrap();
14485 assert_eq!(attrs["layout"], "wide");
14486
14487 let result = adf_to_markdown(&doc).unwrap();
14488 assert!(result.contains("::card[https://example.com]{layout=wide}"));
14489 }
14490
14491 #[test]
14494 fn extension_with_params() {
14495 let md = r#"::extension{type=com.atlassian.macro key=jira-chart params='{"jql":"project=PROJ"}'}"#;
14496 let doc = markdown_to_adf(&format!("{md}\n")).unwrap();
14497 let attrs = doc.content[0].attrs.as_ref().unwrap();
14498 assert_eq!(attrs["parameters"]["jql"], "project=PROJ");
14499 }
14500
14501 #[test]
14502 fn leaf_extension_layout_preserved_in_roundtrip() {
14503 let adf_json = r#"{"version":1,"type":"doc","content":[
14505 {"type":"extension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"toc","layout":"default","parameters":{}}}
14506 ]}"#;
14507 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14508 let md = adf_to_markdown(&doc).unwrap();
14509 assert!(
14510 md.contains("layout=default"),
14511 "JFM should contain layout=default, got: {md}"
14512 );
14513 let round_tripped = markdown_to_adf(&md).unwrap();
14514 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14515 assert_eq!(attrs["layout"], "default", "layout should be preserved");
14516 assert_eq!(attrs["extensionKey"], "toc");
14517 }
14518
14519 #[test]
14520 fn bodied_extension_layout_preserved_in_roundtrip() {
14521 let adf_json = r#"{"version":1,"type":"doc","content":[
14523 {"type":"bodiedExtension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"expand","layout":"wide"},
14524 "content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}
14525 ]}"#;
14526 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14527 let md = adf_to_markdown(&doc).unwrap();
14528 assert!(
14529 md.contains("layout=wide"),
14530 "JFM should contain layout=wide, got: {md}"
14531 );
14532 let round_tripped = markdown_to_adf(&md).unwrap();
14533 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14534 assert_eq!(attrs["layout"], "wide", "layout should be preserved");
14535 }
14536
14537 #[test]
14538 fn bodied_extension_parameters_preserved_in_roundtrip() {
14539 let adf_json = r#"{"version":1,"type":"doc","content":[
14541 {"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":{}}},
14542 "content":[{"type":"paragraph","content":[{"type":"text","text":"Content inside bodied extension"}]}]}
14543 ]}"#;
14544 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14545 let md = adf_to_markdown(&doc).unwrap();
14546 assert!(
14547 md.contains("params="),
14548 "JFM should contain params attribute, got: {md}"
14549 );
14550 let round_tripped = markdown_to_adf(&md).unwrap();
14551 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14552 assert_eq!(
14553 attrs["parameters"]["macroMetadata"]["title"], "Page Properties",
14554 "parameters should be preserved in round-trip"
14555 );
14556 assert_eq!(attrs["extensionKey"], "details");
14557 assert_eq!(attrs["layout"], "default");
14558 assert_eq!(attrs["localId"], "aabbccdd-1234");
14559 }
14560
14561 #[test]
14562 fn bodied_extension_malformed_params_ignored() {
14563 let md = ":::extension{type=com.atlassian.macro key=details params='not-valid-json'}\nContent\n:::\n";
14565 let doc = markdown_to_adf(md).unwrap();
14566 let attrs = doc.content[0].attrs.as_ref().unwrap();
14567 assert_eq!(attrs["extensionKey"], "details");
14568 assert!(attrs.get("parameters").is_none());
14570 }
14571
14572 #[test]
14573 fn leaf_extension_localid_preserved_in_roundtrip() {
14574 let adf_json = r#"{"version":1,"type":"doc","content":[
14576 {"type":"extension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"toc","layout":"default","localId":"abc-123"}}
14577 ]}"#;
14578 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14579 let md = adf_to_markdown(&doc).unwrap();
14580 let round_tripped = markdown_to_adf(&md).unwrap();
14581 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14582 assert_eq!(attrs["layout"], "default");
14583 assert_eq!(attrs["localId"], "abc-123");
14584 }
14585
14586 #[test]
14589 fn mention_with_user_type() {
14590 let md = "Hi :mention[Alice]{id=abc123 userType=DEFAULT}.\n";
14591 let doc = markdown_to_adf(md).unwrap();
14592 let mention = &doc.content[0].content.as_ref().unwrap()[1];
14593 assert_eq!(mention.attrs.as_ref().unwrap()["userType"], "DEFAULT");
14594
14595 let result = adf_to_markdown(&doc).unwrap();
14596 assert!(result.contains("userType=DEFAULT"));
14597 }
14598
14599 #[test]
14602 fn directive_table_colwidth() {
14603 let md = "::::table\n:::tr\n:::td{colwidth=100,200}\nCell\n:::\n:::\n::::\n";
14604 let doc = markdown_to_adf(md).unwrap();
14605 let cell = &doc.content[0].content.as_ref().unwrap()[0]
14606 .content
14607 .as_ref()
14608 .unwrap()[0];
14609 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14610 assert_eq!(colwidth, &[serde_json::json!(100), serde_json::json!(200)]);
14611 }
14612
14613 #[test]
14614 fn directive_table_colwidth_float_roundtrip() {
14615 let adf_doc = serde_json::json!({
14618 "type": "doc",
14619 "version": 1,
14620 "content": [{
14621 "type": "table",
14622 "content": [{
14623 "type": "tableRow",
14624 "content": [
14625 {
14626 "type": "tableHeader",
14627 "attrs": { "colwidth": [157.0] },
14628 "content": [{ "type": "paragraph" }]
14629 },
14630 {
14631 "type": "tableHeader",
14632 "attrs": { "colwidth": [863.0] },
14633 "content": [{ "type": "paragraph" }]
14634 }
14635 ]
14636 }]
14637 }]
14638 });
14639 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
14640 let md = adf_to_markdown(&doc).unwrap();
14641 assert!(
14642 md.contains("colwidth=157.0"),
14643 "expected colwidth=157.0 in markdown, got: {md}"
14644 );
14645 assert!(
14646 md.contains("colwidth=863.0"),
14647 "expected colwidth=863.0 in markdown, got: {md}"
14648 );
14649 let doc2 = markdown_to_adf(&md).unwrap();
14651 let row = &doc2.content[0].content.as_ref().unwrap()[0];
14652 let header1 = &row.content.as_ref().unwrap()[0];
14653 let header2 = &row.content.as_ref().unwrap()[1];
14654 assert_eq!(
14655 header1.attrs.as_ref().unwrap()["colwidth"]
14656 .as_array()
14657 .unwrap(),
14658 &[serde_json::json!(157.0)]
14659 );
14660 assert_eq!(
14661 header2.attrs.as_ref().unwrap()["colwidth"]
14662 .as_array()
14663 .unwrap(),
14664 &[serde_json::json!(863.0)]
14665 );
14666 }
14667
14668 #[test]
14669 fn colwidth_float_preserved_in_roundtrip() {
14670 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":[]}]}]}]}]}"#;
14672 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14673 let md = adf_to_markdown(&doc).unwrap();
14674 let round_tripped = markdown_to_adf(&md).unwrap();
14675 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14676 .content
14677 .as_ref()
14678 .unwrap()[0];
14679 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14680 assert_eq!(
14681 colwidth,
14682 &[serde_json::json!(254.0), serde_json::json!(416.0)],
14683 "colwidth should preserve float values"
14684 );
14685 }
14686
14687 #[test]
14688 fn colwidth_integer_preserved_in_roundtrip() {
14689 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"}]}]}]}]}]}"#;
14691 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14692 let md = adf_to_markdown(&doc).unwrap();
14693 assert!(
14694 md.contains("colwidth=150"),
14695 "expected colwidth=150 (no decimal) in markdown, got: {md}"
14696 );
14697 assert!(
14698 !md.contains("colwidth=150.0"),
14699 "colwidth should not have .0 suffix for integers, got: {md}"
14700 );
14701 let round_tripped = markdown_to_adf(&md).unwrap();
14703 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14704 .content
14705 .as_ref()
14706 .unwrap()[0];
14707 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14708 assert_eq!(
14709 colwidth,
14710 &[serde_json::json!(150)],
14711 "colwidth should preserve integer values"
14712 );
14713 let json_output = serde_json::to_string(&round_tripped).unwrap();
14715 assert!(
14716 json_output.contains(r#""colwidth":[150]"#),
14717 "JSON should contain integer colwidth, got: {json_output}"
14718 );
14719 }
14720
14721 #[test]
14722 fn colwidth_mixed_int_and_float_roundtrip() {
14723 let int_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100,200]}}]}]}]}"#;
14726 let float_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100.0,200.0]}}]}]}]}"#;
14727
14728 let int_doc: AdfDocument = serde_json::from_str(int_json).unwrap();
14730 let int_md = adf_to_markdown(&int_doc).unwrap();
14731 assert!(
14732 int_md.contains("colwidth=100,200"),
14733 "integer colwidth in md: {int_md}"
14734 );
14735 let int_rt = markdown_to_adf(&int_md).unwrap();
14736 let int_serial = serde_json::to_string(&int_rt).unwrap();
14737 assert!(
14738 int_serial.contains(r#""colwidth":[100,200]"#),
14739 "integer colwidth in JSON: {int_serial}"
14740 );
14741
14742 let float_doc: AdfDocument = serde_json::from_str(float_json).unwrap();
14744 let float_md = adf_to_markdown(&float_doc).unwrap();
14745 assert!(
14746 float_md.contains("colwidth=100.0,200.0"),
14747 "float colwidth in md: {float_md}"
14748 );
14749 let float_rt = markdown_to_adf(&float_md).unwrap();
14750 let float_serial = serde_json::to_string(&float_rt).unwrap();
14751 assert!(
14752 float_serial.contains(r#""colwidth":[100.0,200.0]"#),
14753 "float colwidth in JSON: {float_serial}"
14754 );
14755 }
14756
14757 #[test]
14758 fn colwidth_fractional_float_preserved() {
14759 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"}]}]}]}]}]}"#;
14761 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14762 let md = adf_to_markdown(&doc).unwrap();
14763 assert!(
14764 md.contains("colwidth=100.5"),
14765 "expected colwidth=100.5 in markdown, got: {md}"
14766 );
14767 }
14768
14769 #[test]
14770 fn colwidth_non_numeric_values_skipped() {
14771 let adf_doc = serde_json::json!({
14773 "type": "doc",
14774 "version": 1,
14775 "content": [{
14776 "type": "table",
14777 "content": [{
14778 "type": "tableRow",
14779 "content": [{
14780 "type": "tableCell",
14781 "attrs": { "colwidth": ["invalid"] },
14782 "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "cell" }] }]
14783 }]
14784 }]
14785 }]
14786 });
14787 let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
14788 let md = adf_to_markdown(&doc).unwrap();
14789 assert!(
14791 !md.contains("colwidth"),
14792 "non-numeric colwidth should be filtered out, got: {md}"
14793 );
14794 }
14795
14796 #[test]
14797 fn default_rowspan_colspan_preserved_in_roundtrip() {
14798 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"}]}]}]}]}]}"#;
14800 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14801 let md = adf_to_markdown(&doc).unwrap();
14802 let round_tripped = markdown_to_adf(&md).unwrap();
14803 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14804 .content
14805 .as_ref()
14806 .unwrap()[0];
14807 let attrs = cell.attrs.as_ref().unwrap();
14808 assert_eq!(attrs["rowspan"], 1, "rowspan=1 should be preserved");
14809 assert_eq!(attrs["colspan"], 1, "colspan=1 should be preserved");
14810 }
14811
14812 #[test]
14815 fn table_localid_preserved_in_roundtrip() {
14816 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"}]}]}]}]}]}"#;
14818 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14819 let md = adf_to_markdown(&doc).unwrap();
14820 assert!(
14821 md.contains("localId="),
14822 "JFM should contain localId, got: {md}"
14823 );
14824 let round_tripped = markdown_to_adf(&md).unwrap();
14825 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14826 assert_eq!(
14827 attrs["localId"], "7afd4550-e66c-4b12-875f-a91c6c7b62c7",
14828 "localId should be preserved"
14829 );
14830 }
14831
14832 #[test]
14833 fn paragraph_localid_preserved_in_roundtrip() {
14834 let adf_json = r#"{"version":1,"type":"doc","content":[
14836 {"type":"paragraph","attrs":{"localId":"abc-123"},"content":[{"type":"text","text":"hello"}]}
14837 ]}"#;
14838 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14839 let md = adf_to_markdown(&doc).unwrap();
14840 assert!(
14841 md.contains("localId=abc-123"),
14842 "JFM should contain localId, got: {md}"
14843 );
14844 let round_tripped = markdown_to_adf(&md).unwrap();
14845 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14846 assert_eq!(attrs["localId"], "abc-123", "localId should be preserved");
14847 }
14848
14849 #[test]
14850 fn heading_localid_preserved_in_roundtrip() {
14851 let adf_json = r#"{"version":1,"type":"doc","content":[
14852 {"type":"heading","attrs":{"level":2,"localId":"h-456"},"content":[{"type":"text","text":"Title"}]}
14853 ]}"#;
14854 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14855 let md = adf_to_markdown(&doc).unwrap();
14856 let round_tripped = markdown_to_adf(&md).unwrap();
14857 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14858 assert_eq!(attrs["localId"], "h-456");
14859 }
14860
14861 #[test]
14862 fn localid_with_alignment_preserved() {
14863 let adf_json = r#"{"version":1,"type":"doc","content":[
14865 {"type":"paragraph","attrs":{"localId":"p-789"},"marks":[{"type":"alignment","attrs":{"align":"center"}}],
14866 "content":[{"type":"text","text":"centered"}]}
14867 ]}"#;
14868 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14869 let md = adf_to_markdown(&doc).unwrap();
14870 assert!(md.contains("localId=p-789"), "should have localId: {md}");
14871 assert!(md.contains("align=center"), "should have align: {md}");
14872 let round_tripped = markdown_to_adf(&md).unwrap();
14873 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14874 assert_eq!(attrs["localId"], "p-789");
14875 let marks = round_tripped.content[0].marks.as_ref().unwrap();
14876 assert!(marks.iter().any(|m| m.mark_type == "alignment"));
14877 }
14878
14879 #[test]
14880 fn table_layout_default_preserved_in_roundtrip() {
14881 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"}]}]}]}]}]}"#;
14883 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14884 let md = adf_to_markdown(&doc).unwrap();
14885 let round_tripped = markdown_to_adf(&md).unwrap();
14886 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14887 assert_eq!(
14888 attrs["layout"], "default",
14889 "layout='default' should be preserved"
14890 );
14891 }
14892
14893 #[test]
14894 fn table_is_number_column_enabled_false_preserved() {
14895 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"}]}]}]}]}]}"#;
14897 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14898 let md = adf_to_markdown(&doc).unwrap();
14899 let round_tripped = markdown_to_adf(&md).unwrap();
14900 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14901 assert_eq!(
14902 attrs["isNumberColumnEnabled"], false,
14903 "isNumberColumnEnabled=false should be preserved"
14904 );
14905 }
14906
14907 #[test]
14908 fn table_is_number_column_enabled_true_preserved() {
14909 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"}]}]}]}]}]}"#;
14911 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14912 let md = adf_to_markdown(&doc).unwrap();
14913 let round_tripped = markdown_to_adf(&md).unwrap();
14914 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14915 assert_eq!(
14916 attrs["isNumberColumnEnabled"], true,
14917 "isNumberColumnEnabled=true should be preserved"
14918 );
14919 }
14920
14921 #[test]
14922 fn directive_table_is_number_column_enabled_false_preserved() {
14923 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
14926 {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
14927 {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
14928 ]}]}]}]}"#;
14929 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14930 let md = adf_to_markdown(&doc).unwrap();
14931 assert!(md.contains("::::table"), "should use directive table form");
14932 assert!(
14933 md.contains("numbered=false"),
14934 "should contain numbered=false, got: {md}"
14935 );
14936 let round_tripped = markdown_to_adf(&md).unwrap();
14937 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14938 assert_eq!(attrs["isNumberColumnEnabled"], false);
14939 assert_eq!(attrs["layout"], "default");
14940 }
14941
14942 #[test]
14943 fn directive_table_is_number_column_enabled_true_preserved() {
14944 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":true,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
14946 {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
14947 {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
14948 ]}]}]}]}"#;
14949 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14950 let md = adf_to_markdown(&doc).unwrap();
14951 assert!(md.contains("::::table"), "should use directive table form");
14952 assert!(
14953 md.contains("numbered}") || md.contains("numbered "),
14954 "should contain numbered flag, got: {md}"
14955 );
14956 let round_tripped = markdown_to_adf(&md).unwrap();
14957 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14958 assert_eq!(attrs["isNumberColumnEnabled"], true);
14959 }
14960
14961 #[test]
14962 fn trailing_space_in_bullet_list_item_preserved() {
14963 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
14965 {"type":"listItem","content":[{"type":"paragraph","content":[
14966 {"type":"text","text":"Before link "},
14967 {"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
14968 {"type":"text","text":" "}
14969 ]}]}
14970 ]}]}"#;
14971 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14972 let md = adf_to_markdown(&doc).unwrap();
14973 let round_tripped = markdown_to_adf(&md).unwrap();
14974 let list = &round_tripped.content[0];
14975 let item = &list.content.as_ref().unwrap()[0];
14976 let para = &item.content.as_ref().unwrap()[0];
14977 let inlines = para.content.as_ref().unwrap();
14978 let last = inlines.last().unwrap();
14979 assert_eq!(
14980 last.text.as_deref(),
14981 Some(" "),
14982 "trailing space text node should be preserved, got nodes: {:?}",
14983 inlines
14984 .iter()
14985 .map(|n| (&n.node_type, &n.text))
14986 .collect::<Vec<_>>()
14987 );
14988 }
14989
14990 #[test]
14991 fn trailing_space_after_mention_in_bullet_list_preserved() {
14992 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
14994 {"type":"listItem","content":[{"type":"paragraph","content":[
14995 {"type":"mention","attrs":{"id":"abc","text":"@Alice"}},
14996 {"type":"text","text":" "}
14997 ]}]}
14998 ]}]}"#;
14999 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15000 let md = adf_to_markdown(&doc).unwrap();
15001 let round_tripped = markdown_to_adf(&md).unwrap();
15002 let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
15003 .content
15004 .as_ref()
15005 .unwrap()[0];
15006 let inlines = para.content.as_ref().unwrap();
15007 assert!(
15008 inlines.len() >= 2,
15009 "should have mention + trailing space, got {} nodes",
15010 inlines.len()
15011 );
15012 assert_eq!(inlines.last().unwrap().text.as_deref(), Some(" "));
15013 }
15014
15015 #[test]
15016 fn trailing_space_in_ordered_list_item_preserved() {
15017 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15019 {"type":"listItem","content":[{"type":"paragraph","content":[
15020 {"type":"text","text":"item "},
15021 {"type":"text","text":"link","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
15022 {"type":"text","text":" "}
15023 ]}]}
15024 ]}]}"#;
15025 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15026 let md = adf_to_markdown(&doc).unwrap();
15027 let round_tripped = markdown_to_adf(&md).unwrap();
15028 let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
15029 .content
15030 .as_ref()
15031 .unwrap()[0];
15032 let inlines = para.content.as_ref().unwrap();
15033 let last = inlines.last().unwrap();
15034 assert_eq!(
15035 last.text.as_deref(),
15036 Some(" "),
15037 "trailing space should be preserved in ordered list item"
15038 );
15039 }
15040
15041 #[test]
15042 fn trailing_space_in_heading_text_preserved() {
15043 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[
15045 {"type":"text","text":"Firefighting Engineers "}
15046 ]}]}"#;
15047 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15048 let md = adf_to_markdown(&doc).unwrap();
15049 let round_tripped = markdown_to_adf(&md).unwrap();
15050 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15051 assert_eq!(
15052 inlines[0].text.as_deref(),
15053 Some("Firefighting Engineers "),
15054 "trailing space in heading should be preserved"
15055 );
15056 }
15057
15058 #[test]
15059 fn trailing_space_in_heading_before_bold_preserved() {
15060 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
15062 {"type":"text","text":"Classic "},
15063 {"type":"text","text":"bold","marks":[{"type":"strong"}]}
15064 ]}]}"#;
15065 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15066 let md = adf_to_markdown(&doc).unwrap();
15067 let round_tripped = markdown_to_adf(&md).unwrap();
15068 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15069 assert_eq!(
15070 inlines[0].text.as_deref(),
15071 Some("Classic "),
15072 "trailing space in heading text before bold should be preserved"
15073 );
15074 }
15075
15076 #[test]
15077 fn leading_space_in_heading_text_preserved() {
15078 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":3},"content":[
15080 {"type":"text","text":" #general-channel"}
15081 ]}]}"#;
15082 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15083 let md = adf_to_markdown(&doc).unwrap();
15084 let round_tripped = markdown_to_adf(&md).unwrap();
15085 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15086 assert_eq!(
15087 inlines[0].text.as_deref(),
15088 Some(" #general-channel"),
15089 "leading spaces in heading text should be preserved"
15090 );
15091 }
15092
15093 #[test]
15094 fn leading_space_in_heading_before_bold_preserved() {
15095 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
15097 {"type":"text","text":" indented"},
15098 {"type":"text","text":" bold","marks":[{"type":"strong"}]}
15099 ]}]}"#;
15100 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15101 let md = adf_to_markdown(&doc).unwrap();
15102 let round_tripped = markdown_to_adf(&md).unwrap();
15103 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15104 assert_eq!(
15105 inlines[0].text.as_deref(),
15106 Some(" indented"),
15107 "leading spaces in heading text before bold should be preserved"
15108 );
15109 }
15110
15111 #[test]
15112 fn heading_multiple_leading_spaces_markdown_parse() {
15113 let md = "### \t #general-channel";
15115 let doc = markdown_to_adf(md).unwrap();
15116 let inlines = doc.content[0].content.as_ref().unwrap();
15117 assert_eq!(
15118 inlines[0].text.as_deref(),
15119 Some("\t #general-channel"),
15120 "leading whitespace in heading text should be preserved during JFM parsing"
15121 );
15122 }
15123
15124 #[test]
15125 fn trailing_space_in_paragraph_text_preserved() {
15126 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15128 {"type":"text","text":"word followed by space "},
15129 {"type":"text","text":"next node","marks":[{"type":"strong"}]}
15130 ]}]}"#;
15131 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15132 let md = adf_to_markdown(&doc).unwrap();
15133 let round_tripped = markdown_to_adf(&md).unwrap();
15134 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15135 assert_eq!(
15136 inlines[0].text.as_deref(),
15137 Some("word followed by space "),
15138 "trailing space in paragraph text should be preserved"
15139 );
15140 }
15141
15142 #[test]
15143 fn nested_bullet_list_roundtrip() {
15144 let adf_doc = serde_json::json!({
15146 "type": "doc",
15147 "version": 1,
15148 "content": [{
15149 "type": "bulletList",
15150 "content": [{
15151 "type": "listItem",
15152 "content": [
15153 {
15154 "type": "paragraph",
15155 "content": [{"type": "text", "text": "parent item"}]
15156 },
15157 {
15158 "type": "bulletList",
15159 "content": [
15160 {
15161 "type": "listItem",
15162 "content": [{
15163 "type": "paragraph",
15164 "content": [{"type": "text", "text": "sub item 1"}]
15165 }]
15166 },
15167 {
15168 "type": "listItem",
15169 "content": [{
15170 "type": "paragraph",
15171 "content": [{"type": "text", "text": "sub item 2"}]
15172 }]
15173 }
15174 ]
15175 }
15176 ]
15177 }]
15178 }]
15179 });
15180 let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
15181 let md = adf_to_markdown(&doc).unwrap();
15182 assert!(
15183 md.contains("- parent item\n"),
15184 "expected top-level item in markdown, got: {md}"
15185 );
15186 assert!(
15187 md.contains(" - sub item 1\n"),
15188 "expected indented sub item 1 in markdown, got: {md}"
15189 );
15190 assert!(
15191 md.contains(" - sub item 2\n"),
15192 "expected indented sub item 2 in markdown, got: {md}"
15193 );
15194
15195 let doc2 = markdown_to_adf(&md).unwrap();
15197 let list = &doc2.content[0];
15198 assert_eq!(list.node_type, "bulletList");
15199 let item = &list.content.as_ref().unwrap()[0];
15200 assert_eq!(item.node_type, "listItem");
15201 let item_content = item.content.as_ref().unwrap();
15202 assert_eq!(
15203 item_content.len(),
15204 2,
15205 "listItem should have paragraph + nested list"
15206 );
15207 assert_eq!(item_content[0].node_type, "paragraph");
15208 assert_eq!(item_content[1].node_type, "bulletList");
15209 let sub_items = item_content[1].content.as_ref().unwrap();
15210 assert_eq!(sub_items.len(), 2);
15211 }
15212
15213 #[test]
15214 fn nested_bullet_in_table_cell_roundtrip() {
15215 let md = "::::table\n:::tr\n:::td\n- parent\n - child\n:::\n:::\n::::\n";
15216 let doc = markdown_to_adf(md).unwrap();
15217 let table = &doc.content[0];
15218 let row = &table.content.as_ref().unwrap()[0];
15219 let cell = &row.content.as_ref().unwrap()[0];
15220 let list = &cell.content.as_ref().unwrap()[0];
15221 assert_eq!(list.node_type, "bulletList");
15222 let item = &list.content.as_ref().unwrap()[0];
15223 let item_content = item.content.as_ref().unwrap();
15224 assert_eq!(
15225 item_content.len(),
15226 2,
15227 "listItem should have paragraph + nested list"
15228 );
15229 assert_eq!(item_content[1].node_type, "bulletList");
15230
15231 let md2 = adf_to_markdown(&doc).unwrap();
15233 assert!(
15234 md2.contains(" - child"),
15235 "expected indented child in round-tripped markdown, got: {md2}"
15236 );
15237 }
15238
15239 #[test]
15240 fn nested_ordered_list_roundtrip() {
15241 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15243 {"type":"listItem","content":[
15244 {"type":"paragraph","content":[{"type":"text","text":"Top level"}]},
15245 {"type":"orderedList","attrs":{"order":1},"content":[
15246 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 1"}]}]},
15247 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 2"}]}]}
15248 ]}
15249 ]},
15250 {"type":"listItem","content":[
15251 {"type":"paragraph","content":[{"type":"text","text":"Second top"}]}
15252 ]}
15253 ]}]}"#;
15254 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15255 let md = adf_to_markdown(&doc).unwrap();
15256 let round_tripped = markdown_to_adf(&md).unwrap();
15257
15258 let outer = &round_tripped.content[0];
15260 assert_eq!(outer.node_type, "orderedList");
15261 assert_eq!(
15262 outer.attrs.as_ref().unwrap()["order"],
15263 1,
15264 "explicit order=1 must be preserved via trailing {{order=1}} (issue #547)"
15265 );
15266 let outer_items = outer.content.as_ref().unwrap();
15267 assert_eq!(
15268 outer_items.len(),
15269 2,
15270 "outer list should have 2 items, got {}",
15271 outer_items.len()
15272 );
15273
15274 let first_item = &outer_items[0];
15276 let first_content = first_item.content.as_ref().unwrap();
15277 assert_eq!(
15278 first_content.len(),
15279 2,
15280 "first listItem should have paragraph + nested list, got {}",
15281 first_content.len()
15282 );
15283 assert_eq!(first_content[0].node_type, "paragraph");
15284 assert_eq!(first_content[1].node_type, "orderedList");
15285 let nested_items = first_content[1].content.as_ref().unwrap();
15286 assert_eq!(nested_items.len(), 2, "nested list should have 2 items");
15287 }
15288
15289 #[test]
15290 fn nested_ordered_list_markdown_parsing() {
15291 let md = "1. Top level\n 1. Nested 1\n 2. Nested 2\n2. Second top\n";
15293 let doc = markdown_to_adf(md).unwrap();
15294 let outer = &doc.content[0];
15295 assert_eq!(outer.node_type, "orderedList");
15296 let outer_items = outer.content.as_ref().unwrap();
15297 assert_eq!(outer_items.len(), 2, "should have 2 top-level items");
15298
15299 let first_content = outer_items[0].content.as_ref().unwrap();
15300 assert_eq!(
15301 first_content.len(),
15302 2,
15303 "first item should have paragraph + nested list"
15304 );
15305 assert_eq!(first_content[1].node_type, "orderedList");
15306 }
15307
15308 #[test]
15309 fn bullet_list_nested_inside_ordered_list() {
15310 let md = "1. Ordered item\n - Bullet child 1\n - Bullet child 2\n2. Second ordered\n";
15312 let doc = markdown_to_adf(md).unwrap();
15313 let outer = &doc.content[0];
15314 assert_eq!(outer.node_type, "orderedList");
15315 let outer_items = outer.content.as_ref().unwrap();
15316 assert_eq!(outer_items.len(), 2);
15317
15318 let first_content = outer_items[0].content.as_ref().unwrap();
15319 assert_eq!(
15320 first_content.len(),
15321 2,
15322 "first item should have paragraph + nested list"
15323 );
15324 assert_eq!(first_content[1].node_type, "bulletList");
15325 let sub_items = first_content[1].content.as_ref().unwrap();
15326 assert_eq!(sub_items.len(), 2, "nested bullet list should have 2 items");
15327 }
15328
15329 #[test]
15330 fn ordered_list_order_attr_one_is_elided() {
15331 let md = "1. A\n2. B\n";
15335 let doc = markdown_to_adf(md).unwrap();
15336 assert!(
15337 doc.content[0].attrs.is_none(),
15338 "attrs should be elided when order=1"
15339 );
15340
15341 let md2 = adf_to_markdown(&doc).unwrap();
15343 let doc2 = markdown_to_adf(&md2).unwrap();
15344 assert!(
15345 doc2.content[0].attrs.is_none(),
15346 "attrs should remain elided after round-trip"
15347 );
15348 }
15349
15350 #[test]
15351 fn issue_547_ordered_list_no_attrs_roundtrip_byte_identical() {
15352 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"}]}]}]}]}"#;
15355 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15356 let md = adf_to_markdown(&doc).unwrap();
15357 let rt = markdown_to_adf(&md).unwrap();
15358 assert!(
15359 rt.content[0].attrs.is_none(),
15360 "round-tripped orderedList should not have attrs, got: {:?}",
15361 rt.content[0].attrs
15362 );
15363
15364 let rt_json = serde_json::to_string(&rt).unwrap();
15366 assert!(
15367 !rt_json.contains("\"order\""),
15368 "round-tripped JSON should not contain \"order\", got: {rt_json}"
15369 );
15370 }
15371
15372 fn assert_roundtrip_byte_identical(adf_json: &str) {
15378 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15379 let md = adf_to_markdown(&doc).unwrap();
15380 let rt = markdown_to_adf(&md).unwrap();
15381
15382 let canonical_src: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15383 let canonical_rt: serde_json::Value =
15384 serde_json::from_str(&serde_json::to_string(&rt).unwrap()).unwrap();
15385 assert_eq!(
15386 canonical_src, canonical_rt,
15387 "round-trip diverged\n src: {canonical_src}\n rt: {canonical_rt}\n md: {md:?}"
15388 );
15389 }
15390
15391 #[test]
15392 fn issue_547_single_item_no_attrs_roundtrip() {
15393 assert_roundtrip_byte_identical(
15394 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"only"}]}]}]}]}"#,
15395 );
15396 }
15397
15398 #[test]
15399 fn issue_547_many_items_no_attrs_roundtrip() {
15400 assert_roundtrip_byte_identical(
15401 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"}]}]}]}]}"#,
15402 );
15403 }
15404
15405 #[test]
15406 fn issue_547_non_default_order_preserved() {
15407 assert_roundtrip_byte_identical(
15410 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":5},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"fifth"}]}]}]}]}"#,
15411 );
15412 }
15413
15414 #[test]
15415 fn issue_547_nested_ordered_in_ordered_no_attrs_roundtrip() {
15416 assert_roundtrip_byte_identical(
15418 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"}]}]}]}]}]}]}"#,
15419 );
15420 }
15421
15422 #[test]
15423 fn issue_547_ordered_nested_in_bullet_no_attrs_roundtrip() {
15424 assert_roundtrip_byte_identical(
15425 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"}]}]}]}]}]}]}"#,
15426 );
15427 }
15428
15429 #[test]
15430 fn issue_547_bullet_nested_in_ordered_no_attrs_roundtrip() {
15431 assert_roundtrip_byte_identical(
15432 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"}]}]}]}]}]}]}"#,
15433 );
15434 }
15435
15436 #[test]
15437 fn issue_547_ordered_list_between_paragraphs_roundtrip() {
15438 assert_roundtrip_byte_identical(
15439 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"}]}]}"#,
15440 );
15441 }
15442
15443 #[test]
15444 fn issue_547_ordered_list_with_marked_text_roundtrip() {
15445 assert_roundtrip_byte_identical(
15446 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"bold","marks":[{"type":"strong"}]}]}]}]}]}"#,
15447 );
15448 }
15449
15450 #[test]
15451 fn issue_547_ordered_list_with_link_roundtrip() {
15452 assert_roundtrip_byte_identical(
15453 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"}}]}]}]}]}]}"#,
15454 );
15455 }
15456
15457 #[test]
15458 fn issue_547_ordered_list_with_hardbreak_roundtrip() {
15459 assert_roundtrip_byte_identical(
15460 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"}]}]}]}]}"#,
15461 );
15462 }
15463
15464 #[test]
15465 fn issue_547_triple_nested_ordered_roundtrip() {
15466 assert_roundtrip_byte_identical(
15467 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"}]}]}]}]}]}]}]}]}"#,
15468 );
15469 }
15470
15471 #[test]
15472 fn issue_547_ordered_list_heading_rule_mix_roundtrip() {
15473 assert_roundtrip_byte_identical(
15474 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"}]}"#,
15475 );
15476 }
15477
15478 #[test]
15479 fn issue_547_ordered_list_listitem_localid_roundtrip() {
15480 assert_roundtrip_byte_identical(
15482 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]}]}"#,
15483 );
15484 }
15485
15486 #[test]
15487 fn issue_547_explicit_order_one_preserved_roundtrip() {
15488 assert_roundtrip_byte_identical(
15493 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"First item"}]}]}]}]}"#,
15494 );
15495 }
15496
15497 #[test]
15498 fn issue_547_explicit_order_one_nested_preserved_roundtrip() {
15499 assert_roundtrip_byte_identical(
15502 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"}]}]}]}]}]}]}"#,
15503 );
15504 }
15505
15506 #[test]
15507 fn issue_547_mixed_explicit_and_implicit_order_roundtrip() {
15508 assert_roundtrip_byte_identical(
15511 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"}]}]}]}]}"#,
15512 );
15513 }
15514
15515 #[test]
15516 fn issue_547_explicit_order_one_with_listitem_localid_roundtrip() {
15517 assert_roundtrip_byte_identical(
15521 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"}]}]}]}]}"#,
15522 );
15523 }
15524
15525 #[test]
15526 fn issue_547_order_attr_signal_appears_only_for_explicit_one() {
15527 let no_attrs = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
15531 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"}]}]}]}]}"#;
15532 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"}]}]}]}]}"#;
15533
15534 let md_no =
15535 adf_to_markdown(&serde_json::from_str::<AdfDocument>(no_attrs).unwrap()).unwrap();
15536 let md_one =
15537 adf_to_markdown(&serde_json::from_str::<AdfDocument>(explicit_one).unwrap()).unwrap();
15538 let md_five =
15539 adf_to_markdown(&serde_json::from_str::<AdfDocument>(order_five).unwrap()).unwrap();
15540
15541 assert!(
15542 !md_no.contains("{order="),
15543 "no-attrs source must not emit order signal, got: {md_no:?}"
15544 );
15545 assert!(
15546 md_one.contains("{order=1}"),
15547 "explicit order=1 must emit trailing signal, got: {md_one:?}"
15548 );
15549 assert!(
15550 !md_five.contains("{order="),
15551 "order=5 is already encoded by marker; must not emit signal, got: {md_five:?}"
15552 );
15553 }
15554
15555 #[test]
15558 fn file_media_roundtrip() {
15559 let adf_doc = serde_json::json!({
15561 "type": "doc",
15562 "version": 1,
15563 "content": [{
15564 "type": "mediaSingle",
15565 "attrs": {"layout": "center"},
15566 "content": [{
15567 "type": "media",
15568 "attrs": {
15569 "type": "file",
15570 "id": "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d",
15571 "collection": "contentId-8220672100",
15572 "height": 56,
15573 "width": 312,
15574 "alt": "Screenshot.png"
15575 }
15576 }]
15577 }]
15578 });
15579 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15580 let md = adf_to_markdown(&doc).unwrap();
15581 assert!(
15582 md.contains("type=file"),
15583 "expected type=file in markdown, got: {md}"
15584 );
15585 assert!(
15586 md.contains("id=6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d"),
15587 "expected id in markdown, got: {md}"
15588 );
15589 assert!(
15590 md.contains("collection=contentId-8220672100"),
15591 "expected collection in markdown, got: {md}"
15592 );
15593 let doc2 = markdown_to_adf(&md).unwrap();
15595 let ms = &doc2.content[0];
15596 assert_eq!(ms.node_type, "mediaSingle");
15597 let media = &ms.content.as_ref().unwrap()[0];
15598 assert_eq!(media.node_type, "media");
15599 let attrs = media.attrs.as_ref().unwrap();
15600 assert_eq!(attrs["type"], "file");
15601 assert_eq!(attrs["id"], "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d");
15602 assert_eq!(attrs["collection"], "contentId-8220672100");
15603 assert_eq!(attrs["height"], 56);
15604 assert_eq!(attrs["width"], 312);
15605 assert_eq!(attrs["alt"], "Screenshot.png");
15606 }
15607
15608 #[test]
15612 fn file_media_roundtrip_issue_550_reproducer() {
15613 let adf_json = r#"{
15614 "version": 1,
15615 "type": "doc",
15616 "content": [
15617 {
15618 "type": "mediaSingle",
15619 "attrs": {"layout": "center"},
15620 "content": [
15621 {
15622 "type": "media",
15623 "attrs": {
15624 "type": "file",
15625 "id": "abc-123-def-456",
15626 "collection": "my-collection",
15627 "width": 941,
15628 "height": 655
15629 }
15630 }
15631 ]
15632 }
15633 ]
15634 }"#;
15635 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15636 let md = adf_to_markdown(&doc).unwrap();
15637 let rt = markdown_to_adf(&md).unwrap();
15638 let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15639 let actual = serde_json::to_value(&rt).unwrap();
15640 assert_eq!(
15641 actual, expected,
15642 "roundtrip should preserve file media attrs; md was:\n{md}"
15643 );
15644 }
15645
15646 #[test]
15652 fn file_media_roundtrip_id_with_spaces() {
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": "abc 123 def 456",
15666 "collection": "my-collection",
15667 "width": 800,
15668 "height": 600
15669 }
15670 }
15671 ]
15672 }
15673 ]
15674 }"#;
15675 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15676 let md = adf_to_markdown(&doc).unwrap();
15677 assert!(
15678 md.contains(r#"id="abc 123 def 456""#),
15679 "id with spaces should be quoted in JFM, got:\n{md}"
15680 );
15681 let rt = markdown_to_adf(&md).unwrap();
15682 let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15683 let actual = serde_json::to_value(&rt).unwrap();
15684 assert_eq!(
15685 actual, expected,
15686 "space-containing id must round-trip; md was:\n{md}"
15687 );
15688 }
15689
15690 #[test]
15692 fn file_media_roundtrip_collection_with_spaces() {
15693 let adf_json = r#"{
15694 "version": 1,
15695 "type": "doc",
15696 "content": [
15697 {
15698 "type": "mediaSingle",
15699 "attrs": {"layout": "center"},
15700 "content": [
15701 {
15702 "type": "media",
15703 "attrs": {
15704 "type": "file",
15705 "id": "abc-123",
15706 "collection": "my collection with spaces"
15707 }
15708 }
15709 ]
15710 }
15711 ]
15712 }"#;
15713 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15714 let md = adf_to_markdown(&doc).unwrap();
15715 let rt = markdown_to_adf(&md).unwrap();
15716 let media = &rt.content[0].content.as_ref().unwrap()[0];
15717 assert_eq!(
15718 media.attrs.as_ref().unwrap()["collection"],
15719 "my collection with spaces"
15720 );
15721 }
15722
15723 #[test]
15725 fn file_media_roundtrip_occurrence_key_with_spaces() {
15726 let adf_json = r#"{
15727 "version": 1,
15728 "type": "doc",
15729 "content": [
15730 {
15731 "type": "mediaSingle",
15732 "attrs": {"layout": "center"},
15733 "content": [
15734 {
15735 "type": "media",
15736 "attrs": {
15737 "type": "file",
15738 "id": "x",
15739 "collection": "y",
15740 "occurrenceKey": "key with spaces"
15741 }
15742 }
15743 ]
15744 }
15745 ]
15746 }"#;
15747 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15748 let md = adf_to_markdown(&doc).unwrap();
15749 let rt = markdown_to_adf(&md).unwrap();
15750 let media = &rt.content[0].content.as_ref().unwrap()[0];
15751 assert_eq!(
15752 media.attrs.as_ref().unwrap()["occurrenceKey"],
15753 "key with spaces"
15754 );
15755 }
15756
15757 #[test]
15759 fn file_media_roundtrip_id_with_quote_char() {
15760 let adf_json = r#"{
15761 "version": 1,
15762 "type": "doc",
15763 "content": [
15764 {
15765 "type": "mediaSingle",
15766 "attrs": {"layout": "center"},
15767 "content": [
15768 {
15769 "type": "media",
15770 "attrs": {
15771 "type": "file",
15772 "id": "a\"b\"c",
15773 "collection": "col"
15774 }
15775 }
15776 ]
15777 }
15778 ]
15779 }"#;
15780 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15781 let md = adf_to_markdown(&doc).unwrap();
15782 let rt = markdown_to_adf(&md).unwrap();
15783 let media = &rt.content[0].content.as_ref().unwrap()[0];
15784 assert_eq!(media.attrs.as_ref().unwrap()["id"], "a\"b\"c");
15785 }
15786
15787 #[test]
15790 fn media_inline_roundtrip_id_with_spaces() {
15791 let adf_json = r#"{
15792 "version": 1,
15793 "type": "doc",
15794 "content": [
15795 {
15796 "type": "paragraph",
15797 "content": [
15798 {"type": "text", "text": "before "},
15799 {
15800 "type": "mediaInline",
15801 "attrs": {
15802 "type": "file",
15803 "id": "a b c",
15804 "collection": "my col"
15805 }
15806 },
15807 {"type": "text", "text": " after"}
15808 ]
15809 }
15810 ]
15811 }"#;
15812 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15813 let md = adf_to_markdown(&doc).unwrap();
15814 let rt = markdown_to_adf(&md).unwrap();
15815 let inline = &rt.content[0].content.as_ref().unwrap()[1];
15816 assert_eq!(inline.node_type, "mediaInline");
15817 let attrs = inline.attrs.as_ref().unwrap();
15818 assert_eq!(attrs["id"], "a b c");
15819 assert_eq!(attrs["collection"], "my col");
15820 }
15821
15822 #[test]
15825 fn file_media_roundtrip_preserves_occurrence_key() {
15826 let adf_json = r#"{
15827 "version": 1,
15828 "type": "doc",
15829 "content": [
15830 {
15831 "type": "mediaSingle",
15832 "attrs": {"layout": "center"},
15833 "content": [
15834 {
15835 "type": "media",
15836 "attrs": {
15837 "type": "file",
15838 "id": "abc-123",
15839 "collection": "my-collection",
15840 "occurrenceKey": "unique-key-xyz",
15841 "width": 200,
15842 "height": 100
15843 }
15844 }
15845 ]
15846 }
15847 ]
15848 }"#;
15849 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15850 let md = adf_to_markdown(&doc).unwrap();
15851 assert!(
15852 md.contains("occurrenceKey=unique-key-xyz"),
15853 "expected occurrenceKey in markdown, got: {md}"
15854 );
15855 let rt = markdown_to_adf(&md).unwrap();
15856 let media = &rt.content[0].content.as_ref().unwrap()[0];
15857 let attrs = media.attrs.as_ref().unwrap();
15858 assert_eq!(attrs["occurrenceKey"], "unique-key-xyz");
15859 assert_eq!(attrs["type"], "file");
15860 assert_eq!(attrs["id"], "abc-123");
15861 assert_eq!(attrs["collection"], "my-collection");
15862 }
15863
15864 #[test]
15867 fn media_single_caption_adf_to_markdown() {
15868 let adf_doc = serde_json::json!({
15869 "type": "doc",
15870 "version": 1,
15871 "content": [{
15872 "type": "mediaSingle",
15873 "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
15874 "content": [
15875 {
15876 "type": "media",
15877 "attrs": {
15878 "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
15879 "type": "file",
15880 "collection": "contentId-123456",
15881 "width": 800,
15882 "height": 600
15883 }
15884 },
15885 {
15886 "type": "caption",
15887 "content": [{"type": "text", "text": "An image caption here"}]
15888 }
15889 ]
15890 }]
15891 });
15892 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15893 let md = adf_to_markdown(&doc).unwrap();
15894 assert!(
15895 md.contains(":::caption"),
15896 "expected :::caption in markdown, got: {md}"
15897 );
15898 assert!(
15899 md.contains("An image caption here"),
15900 "expected caption text in markdown, got: {md}"
15901 );
15902 }
15903
15904 #[test]
15905 fn media_single_caption_markdown_to_adf() {
15906 let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nAn image caption here\n:::\n";
15907 let doc = markdown_to_adf(md).unwrap();
15908 let ms = &doc.content[0];
15909 assert_eq!(ms.node_type, "mediaSingle");
15910 let content = ms.content.as_ref().unwrap();
15911 assert_eq!(content.len(), 2, "expected media + caption children");
15912 assert_eq!(content[0].node_type, "media");
15913 assert_eq!(content[1].node_type, "caption");
15914 let caption_content = content[1].content.as_ref().unwrap();
15915 assert_eq!(
15916 caption_content[0].text.as_deref(),
15917 Some("An image caption here")
15918 );
15919 }
15920
15921 #[test]
15922 fn media_single_caption_round_trip() {
15923 let adf_doc = serde_json::json!({
15925 "type": "doc",
15926 "version": 1,
15927 "content": [{
15928 "type": "mediaSingle",
15929 "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
15930 "content": [
15931 {
15932 "type": "media",
15933 "attrs": {
15934 "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
15935 "type": "file",
15936 "collection": "contentId-123456",
15937 "width": 800,
15938 "height": 600
15939 }
15940 },
15941 {
15942 "type": "caption",
15943 "content": [{"type": "text", "text": "An image caption here"}]
15944 }
15945 ]
15946 }]
15947 });
15948 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15949 let md = adf_to_markdown(&doc).unwrap();
15950 let doc2 = markdown_to_adf(&md).unwrap();
15951 let ms = &doc2.content[0];
15952 assert_eq!(ms.node_type, "mediaSingle");
15953 let content = ms.content.as_ref().unwrap();
15954 assert_eq!(
15955 content.len(),
15956 2,
15957 "expected media + caption after round-trip"
15958 );
15959 assert_eq!(content[1].node_type, "caption");
15960 let caption_content = content[1].content.as_ref().unwrap();
15961 assert_eq!(
15962 caption_content[0].text.as_deref(),
15963 Some("An image caption here")
15964 );
15965 }
15966
15967 #[test]
15968 fn media_single_caption_with_inline_marks() {
15969 let adf_doc = serde_json::json!({
15970 "type": "doc",
15971 "version": 1,
15972 "content": [{
15973 "type": "mediaSingle",
15974 "attrs": {"layout": "center"},
15975 "content": [
15976 {
15977 "type": "media",
15978 "attrs": {"type": "external", "url": "https://example.com/img.png"}
15979 },
15980 {
15981 "type": "caption",
15982 "content": [
15983 {"type": "text", "text": "A "},
15984 {"type": "text", "text": "bold", "marks": [{"type": "strong"}]},
15985 {"type": "text", "text": " caption"}
15986 ]
15987 }
15988 ]
15989 }]
15990 });
15991 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15992 let md = adf_to_markdown(&doc).unwrap();
15993 assert!(
15994 md.contains("**bold**"),
15995 "expected bold in caption, got: {md}"
15996 );
15997
15998 let doc2 = markdown_to_adf(&md).unwrap();
15999 let content = doc2.content[0].content.as_ref().unwrap();
16000 assert_eq!(content.len(), 2, "expected media + caption");
16001 assert_eq!(content[1].node_type, "caption");
16002 let caption_inlines = content[1].content.as_ref().unwrap();
16003 let bold_node = caption_inlines
16004 .iter()
16005 .find(|n| n.text.as_deref() == Some("bold"))
16006 .unwrap();
16007 let marks = bold_node.marks.as_ref().unwrap();
16008 assert_eq!(marks[0].mark_type, "strong");
16009 }
16010
16011 #[test]
16012 fn media_single_no_caption_unaffected() {
16013 let adf_doc = serde_json::json!({
16015 "type": "doc",
16016 "version": 1,
16017 "content": [{
16018 "type": "mediaSingle",
16019 "attrs": {"layout": "center"},
16020 "content": [{
16021 "type": "media",
16022 "attrs": {"type": "external", "url": "https://example.com/img.png"}
16023 }]
16024 }]
16025 });
16026 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16027 let md = adf_to_markdown(&doc).unwrap();
16028 assert!(
16029 !md.contains(":::caption"),
16030 "should not emit caption when none present"
16031 );
16032 let doc2 = markdown_to_adf(&md).unwrap();
16033 let content = doc2.content[0].content.as_ref().unwrap();
16034 assert_eq!(content.len(), 1, "should only have media child");
16035 assert_eq!(content[0].node_type, "media");
16036 }
16037
16038 #[test]
16039 fn media_single_empty_caption_round_trip() {
16040 let adf_doc = serde_json::json!({
16042 "type": "doc",
16043 "version": 1,
16044 "content": [{
16045 "type": "mediaSingle",
16046 "attrs": {"layout": "center"},
16047 "content": [
16048 {
16049 "type": "media",
16050 "attrs": {"type": "external", "url": "https://example.com/img.png"}
16051 },
16052 {
16053 "type": "caption"
16054 }
16055 ]
16056 }]
16057 });
16058 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16059 let md = adf_to_markdown(&doc).unwrap();
16060 assert!(
16061 md.contains(":::caption"),
16062 "expected :::caption even for empty caption, got: {md}"
16063 );
16064 assert!(
16065 md.contains(":::\n"),
16066 "expected closing ::: fence, got: {md}"
16067 );
16068 }
16069
16070 #[test]
16071 fn media_single_external_caption_round_trip() {
16072 let md = "\n:::caption\nImage description\n:::\n";
16074 let doc = markdown_to_adf(md).unwrap();
16075 let ms = &doc.content[0];
16076 assert_eq!(ms.node_type, "mediaSingle");
16077 let content = ms.content.as_ref().unwrap();
16078 assert_eq!(content.len(), 2);
16079 assert_eq!(content[0].node_type, "media");
16080 assert_eq!(content[1].node_type, "caption");
16081
16082 let md2 = adf_to_markdown(&doc).unwrap();
16083 let doc2 = markdown_to_adf(&md2).unwrap();
16084 let content2 = doc2.content[0].content.as_ref().unwrap();
16085 assert_eq!(content2.len(), 2);
16086 assert_eq!(content2[1].node_type, "caption");
16087 let caption_text = content2[1].content.as_ref().unwrap();
16088 assert_eq!(caption_text[0].text.as_deref(), Some("Image description"));
16089 }
16090
16091 #[test]
16094 fn media_single_caption_localid_roundtrip() {
16095 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"}]}]}]}"#;
16096 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16097 let md = adf_to_markdown(&doc).unwrap();
16098 assert!(
16099 md.contains("localId=9da8c2104471"),
16100 "caption localId should appear in markdown: {md}"
16101 );
16102 let rt = markdown_to_adf(&md).unwrap();
16103 let content = rt.content[0].content.as_ref().unwrap();
16104 let caption = &content[1];
16105 assert_eq!(caption.node_type, "caption");
16106 assert_eq!(
16107 caption.attrs.as_ref().unwrap()["localId"],
16108 "9da8c2104471",
16109 "caption localId should round-trip"
16110 );
16111 }
16112
16113 #[test]
16114 fn media_single_caption_without_localid() {
16115 let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nPlain caption\n:::\n";
16116 let doc = markdown_to_adf(md).unwrap();
16117 let caption = &doc.content[0].content.as_ref().unwrap()[1];
16118 assert_eq!(caption.node_type, "caption");
16119 assert!(
16120 caption.attrs.is_none(),
16121 "caption without localId should not gain attrs"
16122 );
16123 let md2 = adf_to_markdown(&doc).unwrap();
16124 assert!(
16125 !md2.contains("localId"),
16126 "no localId should appear in output: {md2}"
16127 );
16128 }
16129
16130 #[test]
16131 fn media_single_caption_localid_stripped_when_option_set() {
16132 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"}]}]}]}"#;
16133 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16134 let opts = RenderOptions {
16135 strip_local_ids: true,
16136 ..Default::default()
16137 };
16138 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16139 assert!(!md.contains("localId"), "localId should be stripped: {md}");
16140 }
16141
16142 #[test]
16143 fn table_width_roundtrip() {
16144 let adf_doc = serde_json::json!({
16146 "type": "doc",
16147 "version": 1,
16148 "content": [{
16149 "type": "table",
16150 "attrs": {"layout": "default", "width": 760.0},
16151 "content": [{
16152 "type": "tableRow",
16153 "content": [{
16154 "type": "tableHeader",
16155 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16156 }]
16157 }]
16158 }]
16159 });
16160 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16161 let md = adf_to_markdown(&doc).unwrap();
16162 assert!(
16163 md.contains("width=760.0"),
16164 "expected width=760.0 in markdown (float preserved), got: {md}"
16165 );
16166 let doc2 = markdown_to_adf(&md).unwrap();
16168 let table = &doc2.content[0];
16169 assert_eq!(table.node_type, "table");
16170 let table_attrs = table.attrs.as_ref().unwrap();
16171 assert_eq!(table_attrs["width"], 760.0);
16172 assert!(
16173 table_attrs["width"].is_f64(),
16174 "expected float width to be preserved as f64, got: {:?}",
16175 table_attrs["width"]
16176 );
16177 }
16178
16179 #[test]
16180 fn table_integer_width_roundtrip_preserves_integer() {
16181 let adf_doc = serde_json::json!({
16184 "type": "doc",
16185 "version": 1,
16186 "content": [{
16187 "type": "table",
16188 "attrs": {
16189 "isNumberColumnEnabled": false,
16190 "layout": "center",
16191 "localId": "abc-123",
16192 "width": 1420
16193 },
16194 "content": [{
16195 "type": "tableRow",
16196 "content": [{
16197 "type": "tableCell",
16198 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Cell"}]}]
16199 }]
16200 }]
16201 }]
16202 });
16203 let doc: crate::atlassian::adf::AdfDocument =
16204 serde_json::from_value(adf_doc.clone()).unwrap();
16205 let md = adf_to_markdown(&doc).unwrap();
16206 assert!(
16207 md.contains("width=1420"),
16208 "expected width=1420 in markdown, got: {md}"
16209 );
16210 assert!(
16211 !md.contains("width=1420.0"),
16212 "integer width should not be rendered with decimal: {md}"
16213 );
16214
16215 let doc2 = markdown_to_adf(&md).unwrap();
16216 let table = &doc2.content[0];
16217 assert_eq!(table.node_type, "table");
16218 let table_attrs = table.attrs.as_ref().unwrap();
16219 assert_eq!(table_attrs["width"], 1420);
16220 assert!(
16221 table_attrs["width"].is_u64() || table_attrs["width"].is_i64(),
16222 "width should remain an integer, got: {:?}",
16223 table_attrs["width"]
16224 );
16225 assert!(
16226 !table_attrs["width"].is_f64(),
16227 "width should not be a float, got: {:?}",
16228 table_attrs["width"]
16229 );
16230
16231 let roundtripped = serde_json::to_value(&doc2).unwrap();
16233 let orig_width = &adf_doc["content"][0]["attrs"]["width"];
16234 let rt_width = &roundtripped["content"][0]["attrs"]["width"];
16235 assert_eq!(
16236 orig_width, rt_width,
16237 "width value must roundtrip byte-for-byte"
16238 );
16239 }
16240
16241 #[test]
16242 fn table_fractional_width_roundtrip() {
16243 let adf_doc = serde_json::json!({
16245 "type": "doc",
16246 "version": 1,
16247 "content": [{
16248 "type": "table",
16249 "attrs": {"layout": "default", "width": 760.5},
16250 "content": [{
16251 "type": "tableRow",
16252 "content": [{
16253 "type": "tableHeader",
16254 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16255 }]
16256 }]
16257 }]
16258 });
16259 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16260 let md = adf_to_markdown(&doc).unwrap();
16261 assert!(
16262 md.contains("width=760.5"),
16263 "expected width=760.5 in markdown, got: {md}"
16264 );
16265 let doc2 = markdown_to_adf(&md).unwrap();
16266 let table_attrs = doc2.content[0].attrs.as_ref().unwrap();
16267 assert_eq!(table_attrs["width"], 760.5);
16268 assert!(table_attrs["width"].is_f64());
16269 }
16270
16271 #[test]
16272 fn pipe_table_integer_width_roundtrip() {
16273 let md = "| A | B |\n|---|---|\n| 1 | 2 |\n{layout=default width=1420}\n";
16275 let doc = markdown_to_adf(md).unwrap();
16276 let table = &doc.content[0];
16277 assert_eq!(table.node_type, "table");
16278 let attrs = table.attrs.as_ref().unwrap();
16279 assert_eq!(attrs["width"], 1420);
16280 assert!(
16281 attrs["width"].is_u64() || attrs["width"].is_i64(),
16282 "pipe-table width must stay integer, got: {:?}",
16283 attrs["width"]
16284 );
16285 }
16286
16287 #[test]
16288 fn file_media_width_type_roundtrip() {
16289 let adf_doc = serde_json::json!({
16291 "type": "doc",
16292 "version": 1,
16293 "content": [{
16294 "type": "mediaSingle",
16295 "attrs": {"layout": "center", "width": 312, "widthType": "pixel"},
16296 "content": [{
16297 "type": "media",
16298 "attrs": {
16299 "type": "file",
16300 "id": "abc123",
16301 "collection": "contentId-999",
16302 "height": 56,
16303 "width": 312
16304 }
16305 }]
16306 }]
16307 });
16308 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16309 let md = adf_to_markdown(&doc).unwrap();
16310 assert!(
16311 md.contains("widthType=pixel"),
16312 "expected widthType=pixel in markdown, got: {md}"
16313 );
16314 let doc2 = markdown_to_adf(&md).unwrap();
16315 let ms = &doc2.content[0];
16316 let ms_attrs = ms.attrs.as_ref().unwrap();
16317 assert_eq!(ms_attrs["widthType"], "pixel");
16318 assert_eq!(ms_attrs["width"], 312);
16319 }
16320
16321 #[test]
16322 fn file_media_mode_roundtrip() {
16323 let adf_doc = serde_json::json!({
16325 "type": "doc",
16326 "version": 1,
16327 "content": [{
16328 "type": "mediaSingle",
16329 "attrs": {"layout": "wide", "mode": "wide", "width": 1200},
16330 "content": [{
16331 "type": "media",
16332 "attrs": {
16333 "type": "file",
16334 "id": "abc123",
16335 "collection": "test",
16336 "width": 1200,
16337 "height": 600
16338 }
16339 }]
16340 }]
16341 });
16342 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16343 let md = adf_to_markdown(&doc).unwrap();
16344 assert!(
16345 md.contains("mode=wide"),
16346 "expected mode=wide in markdown, got: {md}"
16347 );
16348 let doc2 = markdown_to_adf(&md).unwrap();
16349 let ms = &doc2.content[0];
16350 let ms_attrs = ms.attrs.as_ref().unwrap();
16351 assert_eq!(ms_attrs["mode"], "wide");
16352 assert_eq!(ms_attrs["layout"], "wide");
16353 assert_eq!(ms_attrs["width"], 1200);
16354 }
16355
16356 #[test]
16357 fn external_media_mode_roundtrip() {
16358 let adf_doc = serde_json::json!({
16360 "type": "doc",
16361 "version": 1,
16362 "content": [{
16363 "type": "mediaSingle",
16364 "attrs": {"layout": "wide", "mode": "wide"},
16365 "content": [{
16366 "type": "media",
16367 "attrs": {
16368 "type": "external",
16369 "url": "https://example.com/image.png"
16370 }
16371 }]
16372 }]
16373 });
16374 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16375 let md = adf_to_markdown(&doc).unwrap();
16376 assert!(
16377 md.contains("mode=wide"),
16378 "expected mode=wide in markdown, got: {md}"
16379 );
16380 let doc2 = markdown_to_adf(&md).unwrap();
16381 let ms = &doc2.content[0];
16382 let ms_attrs = ms.attrs.as_ref().unwrap();
16383 assert_eq!(ms_attrs["mode"], "wide");
16384 assert_eq!(ms_attrs["layout"], "wide");
16385 }
16386
16387 #[test]
16388 fn media_mode_only_roundtrip() {
16389 let adf_doc = serde_json::json!({
16391 "type": "doc",
16392 "version": 1,
16393 "content": [{
16394 "type": "mediaSingle",
16395 "attrs": {"layout": "center", "mode": "default"},
16396 "content": [{
16397 "type": "media",
16398 "attrs": {
16399 "type": "external",
16400 "url": "https://example.com/image.png"
16401 }
16402 }]
16403 }]
16404 });
16405 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16406 let md = adf_to_markdown(&doc).unwrap();
16407 assert!(
16408 md.contains("mode=default"),
16409 "expected mode=default in markdown, got: {md}"
16410 );
16411 let doc2 = markdown_to_adf(&md).unwrap();
16412 let ms = &doc2.content[0];
16413 let ms_attrs = ms.attrs.as_ref().unwrap();
16414 assert_eq!(ms_attrs["mode"], "default");
16415 }
16416
16417 #[test]
16418 fn file_media_hex_localid_roundtrip() {
16419 let adf_doc = serde_json::json!({
16421 "type": "doc",
16422 "version": 1,
16423 "content": [{
16424 "type": "mediaSingle",
16425 "attrs": {"layout": "wide", "width": 1200, "widthType": "pixel"},
16426 "content": [{
16427 "type": "media",
16428 "attrs": {
16429 "type": "file",
16430 "id": "eb7a9c3b-314e-4458-8200-4b22b67b122e",
16431 "collection": "contentId-123",
16432 "height": 484,
16433 "width": 915,
16434 "alt": "image.png",
16435 "localId": "0e79f58ac382"
16436 }
16437 }]
16438 }]
16439 });
16440 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16441 let md = adf_to_markdown(&doc).unwrap();
16442 assert!(
16443 md.contains("localId=0e79f58ac382"),
16444 "expected localId=0e79f58ac382 in markdown, got: {md}"
16445 );
16446 let doc2 = markdown_to_adf(&md).unwrap();
16447 let ms = &doc2.content[0];
16448 let media = &ms.content.as_ref().unwrap()[0];
16449 let attrs = media.attrs.as_ref().unwrap();
16450 assert_eq!(attrs["localId"], "0e79f58ac382");
16451 }
16452
16453 #[test]
16454 fn file_media_uuid_localid_roundtrip() {
16455 let adf_doc = serde_json::json!({
16457 "type": "doc",
16458 "version": 1,
16459 "content": [{
16460 "type": "mediaSingle",
16461 "attrs": {"layout": "center"},
16462 "content": [{
16463 "type": "media",
16464 "attrs": {
16465 "type": "file",
16466 "id": "abc-123",
16467 "collection": "contentId-456",
16468 "height": 100,
16469 "width": 200,
16470 "localId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
16471 }
16472 }]
16473 }]
16474 });
16475 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16476 let md = adf_to_markdown(&doc).unwrap();
16477 assert!(
16478 md.contains("localId=a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
16479 "expected UUID localId in markdown, got: {md}"
16480 );
16481 let doc2 = markdown_to_adf(&md).unwrap();
16482 let media = &doc2.content[0].content.as_ref().unwrap()[0];
16483 let attrs = media.attrs.as_ref().unwrap();
16484 assert_eq!(attrs["localId"], "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
16485 }
16486
16487 #[test]
16488 fn file_media_null_uuid_localid_stripped() {
16489 let adf_doc = serde_json::json!({
16491 "type": "doc",
16492 "version": 1,
16493 "content": [{
16494 "type": "mediaSingle",
16495 "attrs": {"layout": "center"},
16496 "content": [{
16497 "type": "media",
16498 "attrs": {
16499 "type": "file",
16500 "id": "abc-123",
16501 "collection": "contentId-456",
16502 "height": 100,
16503 "width": 200,
16504 "localId": "00000000-0000-0000-0000-000000000000"
16505 }
16506 }]
16507 }]
16508 });
16509 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16510 let md = adf_to_markdown(&doc).unwrap();
16511 assert!(
16512 !md.contains("localId="),
16513 "null UUID localId should be stripped, got: {md}"
16514 );
16515 }
16516
16517 #[test]
16518 fn file_media_localid_stripped_when_option_set() {
16519 let adf_doc = serde_json::json!({
16521 "type": "doc",
16522 "version": 1,
16523 "content": [{
16524 "type": "mediaSingle",
16525 "attrs": {"layout": "center"},
16526 "content": [{
16527 "type": "media",
16528 "attrs": {
16529 "type": "file",
16530 "id": "abc-123",
16531 "collection": "contentId-456",
16532 "height": 100,
16533 "width": 200,
16534 "localId": "0e79f58ac382"
16535 }
16536 }]
16537 }]
16538 });
16539 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16540 let opts = RenderOptions {
16541 strip_local_ids: true,
16542 ..Default::default()
16543 };
16544 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16545 assert!(
16546 !md.contains("localId="),
16547 "localId should be stripped with strip_local_ids, got: {md}"
16548 );
16549 }
16550
16551 #[test]
16552 fn external_media_localid_roundtrip() {
16553 let adf_doc = serde_json::json!({
16555 "type": "doc",
16556 "version": 1,
16557 "content": [{
16558 "type": "mediaSingle",
16559 "attrs": {"layout": "center"},
16560 "content": [{
16561 "type": "media",
16562 "attrs": {
16563 "type": "external",
16564 "url": "https://example.com/image.png",
16565 "alt": "test",
16566 "localId": "deadbeef1234"
16567 }
16568 }]
16569 }]
16570 });
16571 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16572 let md = adf_to_markdown(&doc).unwrap();
16573 assert!(
16574 md.contains("localId=deadbeef1234"),
16575 "expected localId in markdown for external media, got: {md}"
16576 );
16577 let doc2 = markdown_to_adf(&md).unwrap();
16578 let media = &doc2.content[0].content.as_ref().unwrap()[0];
16579 let attrs = media.attrs.as_ref().unwrap();
16580 assert_eq!(attrs["localId"], "deadbeef1234");
16581 }
16582
16583 #[test]
16584 fn bracket_in_text_not_parsed_as_link() {
16585 let md = ":check_mark: [Task] Unable to start trial ([Link](https://example.com/link))";
16587 let doc = markdown_to_adf(md).unwrap();
16588 let para = &doc.content[0];
16589 assert_eq!(para.node_type, "paragraph");
16590 let content = para.content.as_ref().unwrap();
16591 let text_nodes: Vec<_> = content.iter().filter(|n| n.node_type == "text").collect();
16593 let has_task_bracket = text_nodes
16594 .iter()
16595 .any(|n| n.text.as_deref().unwrap_or("").contains("[Task]"));
16596 assert!(
16597 has_task_bracket,
16598 "expected [Task] in plain text, nodes: {content:?}"
16599 );
16600 let link_nodes: Vec<_> = content
16602 .iter()
16603 .filter(|n| {
16604 n.marks
16605 .as_ref()
16606 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
16607 })
16608 .collect();
16609 assert!(!link_nodes.is_empty(), "expected a link node");
16610 assert_eq!(
16611 link_nodes[0].text.as_deref(),
16612 Some("Link"),
16613 "link text should be 'Link'"
16614 );
16615 }
16616
16617 #[test]
16618 fn empty_paragraph_roundtrip() {
16619 let mut adf_in = AdfDocument::new();
16621 adf_in.content = vec![
16622 AdfNode::paragraph(vec![AdfNode::text("before")]),
16623 AdfNode::paragraph(vec![]),
16624 AdfNode::paragraph(vec![AdfNode::text("after")]),
16625 ];
16626 let md = adf_to_markdown(&adf_in).unwrap();
16627 let adf_out = markdown_to_adf(&md).unwrap();
16628 assert_eq!(
16629 adf_out.content.len(),
16630 3,
16631 "should have 3 blocks, markdown:\n{md}"
16632 );
16633 assert_eq!(adf_out.content[0].node_type, "paragraph");
16634 assert_eq!(adf_out.content[1].node_type, "paragraph");
16635 assert!(
16636 adf_out.content[1].content.is_none(),
16637 "middle paragraph should be empty"
16638 );
16639 assert_eq!(adf_out.content[2].node_type, "paragraph");
16640 }
16641
16642 #[test]
16643 fn nbsp_paragraph_roundtrip() {
16644 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}";
16646 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16647 let md = adf_to_markdown(&doc).unwrap();
16648 assert!(
16649 md.contains("::paragraph["),
16650 "NBSP paragraph should use directive form: {md}"
16651 );
16652 let rt = markdown_to_adf(&md).unwrap();
16653 assert_eq!(rt.content.len(), 1, "should have 1 block");
16654 assert_eq!(rt.content[0].node_type, "paragraph");
16655 let text = rt.content[0].content.as_ref().unwrap()[0]
16656 .text
16657 .as_deref()
16658 .unwrap_or("");
16659 assert_eq!(text, "\u{00a0}", "NBSP should survive round-trip");
16660 }
16661
16662 #[test]
16663 fn nbsp_in_nested_expand_roundtrip() {
16664 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"nestedExpand\",\"attrs\":{\"title\":\"Section\"},\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}]}";
16666 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16667 let md = adf_to_markdown(&doc).unwrap();
16668 let rt = markdown_to_adf(&md).unwrap();
16669 let ne = &rt.content[0];
16670 assert_eq!(ne.node_type, "nestedExpand");
16671 let inner = ne.content.as_ref().unwrap();
16672 assert_eq!(inner.len(), 1, "should have 1 inner block");
16673 assert_eq!(inner[0].node_type, "paragraph");
16674 let content = inner[0].content.as_ref().unwrap();
16675 assert!(!content.is_empty(), "paragraph should not be empty");
16676 let text = content[0].text.as_deref().unwrap_or("");
16677 assert_eq!(text, "\u{00a0}", "NBSP should survive in nestedExpand");
16678 }
16679
16680 #[test]
16681 fn nbsp_followed_by_content() {
16682 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\"}]}]}";
16684 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16685 let md = adf_to_markdown(&doc).unwrap();
16686 let rt = markdown_to_adf(&md).unwrap();
16687 assert!(rt.content.len() >= 2, "should have at least 2 blocks");
16688 let after_para = rt.content.iter().find(|n| {
16690 n.node_type == "paragraph"
16691 && n.content
16692 .as_ref()
16693 .and_then(|c| c.first())
16694 .and_then(|n| n.text.as_deref())
16695 .is_some_and(|t| t.contains("after"))
16696 });
16697 assert!(after_para.is_some(), "should have paragraph with 'after'");
16698 }
16699
16700 #[test]
16701 fn nbsp_paragraph_with_marks_survives() {
16702 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\",\"marks\":[{\"type\":\"strong\"}]}]}]}";
16705 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16706 let md = adf_to_markdown(&doc).unwrap();
16707 assert!(md.contains("**"), "should have bold markers: {md}");
16708 let rt = markdown_to_adf(&md).unwrap();
16709 let content = rt.content[0].content.as_ref().unwrap();
16710 assert!(!content.is_empty(), "should preserve content");
16711 }
16712
16713 #[test]
16714 fn regular_paragraph_unchanged() {
16715 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}"#;
16717 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16718 let md = adf_to_markdown(&doc).unwrap();
16719 assert!(
16720 !md.contains("::paragraph"),
16721 "regular paragraphs should not use directive form: {md}"
16722 );
16723 assert!(md.contains("hello"));
16724 }
16725
16726 #[test]
16727 fn paragraph_directive_with_content_parsed() {
16728 let md = "::paragraph[\u{00a0}]\n";
16730 let doc = markdown_to_adf(md).unwrap();
16731 assert_eq!(doc.content.len(), 1);
16732 assert_eq!(doc.content[0].node_type, "paragraph");
16733 let content = doc.content[0].content.as_ref().unwrap();
16734 assert!(!content.is_empty(), "should have inline content");
16735 assert_eq!(content[0].text.as_deref().unwrap(), "\u{00a0}");
16736 }
16737
16738 #[test]
16739 fn nbsp_paragraph_in_list_item_with_nested_list() {
16740 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"}]}]}]}]}]}]}"#;
16742 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16743 let md = adf_to_markdown(&doc).unwrap();
16744 let rt = markdown_to_adf(&md).unwrap();
16745 let list = &rt.content[0];
16746 assert_eq!(list.node_type, "bulletList");
16747 let item = &list.content.as_ref().unwrap()[0];
16748 let item_content = item.content.as_ref().unwrap();
16749 assert_eq!(
16750 item_content.len(),
16751 2,
16752 "listItem should have paragraph + nested list, got: {item_content:?}"
16753 );
16754 let para = &item_content[0];
16755 assert_eq!(para.node_type, "paragraph");
16756 let para_content = para
16757 .content
16758 .as_ref()
16759 .expect("paragraph should have content");
16760 assert!(
16761 !para_content.is_empty(),
16762 "NBSP paragraph content should not be empty"
16763 );
16764 assert_eq!(
16765 para_content[0].text.as_deref().unwrap(),
16766 "\u{00a0}",
16767 "NBSP should survive round-trip inside listItem"
16768 );
16769 }
16770
16771 #[test]
16772 fn nbsp_paragraph_in_list_item_with_local_ids() {
16773 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"}]}]}]}]}]}]}"#;
16775 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16776 let md = adf_to_markdown(&doc).unwrap();
16777 let rt = markdown_to_adf(&md).unwrap();
16778 let list = &rt.content[0];
16779 let item = &list.content.as_ref().unwrap()[0];
16780 assert_eq!(
16782 item.attrs.as_ref().unwrap()["localId"],
16783 "li-001",
16784 "listItem localId should survive"
16785 );
16786 let item_content = item.content.as_ref().unwrap();
16787 assert_eq!(item_content.len(), 2);
16788 let para = &item_content[0];
16790 assert_eq!(
16791 para.attrs.as_ref().unwrap()["localId"],
16792 "p-001",
16793 "paragraph localId should survive"
16794 );
16795 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16796 assert_eq!(text, "\u{00a0}", "NBSP should survive with localIds");
16797 }
16798
16799 #[test]
16800 fn nbsp_paragraph_in_list_item_without_nested_list() {
16801 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"}]}]}]}]}"#;
16803 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16804 let md = adf_to_markdown(&doc).unwrap();
16805 let rt = markdown_to_adf(&md).unwrap();
16806 let list = &rt.content[0];
16807 let item = &list.content.as_ref().unwrap()[0];
16808 let para = &item.content.as_ref().unwrap()[0];
16809 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16810 assert_eq!(text, "\u{00a0}", "NBSP should survive in simple list item");
16811 }
16812
16813 #[test]
16814 fn nbsp_paragraph_in_ordered_list_item_with_nested_list() {
16815 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"}]}]}]}]}]}]}"#;
16817 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16818 let md = adf_to_markdown(&doc).unwrap();
16819 let rt = markdown_to_adf(&md).unwrap();
16820 let list = &rt.content[0];
16821 let item = &list.content.as_ref().unwrap()[0];
16822 let item_content = item.content.as_ref().unwrap();
16823 assert_eq!(item_content.len(), 2);
16824 let para = &item_content[0];
16825 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16826 assert_eq!(text, "\u{00a0}", "NBSP should survive in ordered list item");
16827 }
16828
16829 #[test]
16830 fn list_item_leading_space_preserved() {
16831 let md = "- hello world\n- - text";
16833 let doc = markdown_to_adf(md).unwrap();
16834 let list = &doc.content[0];
16835 assert_eq!(list.node_type, "bulletList");
16836 let items = list.content.as_ref().unwrap();
16837 let first_para = &items[0].content.as_ref().unwrap()[0];
16839 let first_text = &first_para.content.as_ref().unwrap()[0];
16840 assert_eq!(first_text.text.as_deref(), Some("hello world"));
16841 }
16842
16843 #[test]
16844 fn list_item_leading_space_not_stripped() {
16845 let md = "- leading space text";
16848 let doc = markdown_to_adf(md).unwrap();
16849 let list = &doc.content[0];
16850 let items = list.content.as_ref().unwrap();
16851 let para = &items[0].content.as_ref().unwrap()[0];
16852 let text_node = ¶.content.as_ref().unwrap()[0];
16853 assert_eq!(
16855 text_node.text.as_deref(),
16856 Some(" leading space text"),
16857 "leading space should be preserved"
16858 );
16859 }
16860
16861 #[test]
16866 fn hardbreak_in_cell_uses_directive_table() {
16867 let adf = AdfDocument {
16870 version: 1,
16871 doc_type: "doc".to_string(),
16872 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16873 AdfNode::table_cell(vec![AdfNode::paragraph(vec![
16874 AdfNode::text("before"),
16875 AdfNode::hard_break(),
16876 AdfNode::text("after"),
16877 ])]),
16878 ])])],
16879 };
16880 let md = adf_to_markdown(&adf).unwrap();
16881 assert!(
16883 md.contains(":::td") || md.contains("::::table"),
16884 "Table with hardBreak should use directive form, got:\n{md}"
16885 );
16886 assert!(
16887 !md.contains("| before"),
16888 "Should NOT use pipe syntax with hardBreak"
16889 );
16890 }
16891
16892 #[test]
16893 fn hardbreak_in_cell_roundtrips() {
16894 let adf = AdfDocument {
16896 version: 1,
16897 doc_type: "doc".to_string(),
16898 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16899 AdfNode::table_cell(vec![AdfNode::paragraph(vec![
16900 AdfNode::text("line one"),
16901 AdfNode::hard_break(),
16902 AdfNode::text("line two"),
16903 ])]),
16904 ])])],
16905 };
16906 let md = adf_to_markdown(&adf).unwrap();
16907 let roundtripped = markdown_to_adf(&md).unwrap();
16908
16909 assert_eq!(roundtripped.content.len(), 1);
16911 assert_eq!(roundtripped.content[0].node_type, "table");
16912 let rows = roundtripped.content[0].content.as_ref().unwrap();
16913 assert_eq!(
16914 rows.len(),
16915 1,
16916 "Should have exactly 1 row, got {}",
16917 rows.len()
16918 );
16919 }
16920
16921 #[test]
16922 fn hardbreak_in_paragraph_roundtrips() {
16923 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
16925 {"type":"text","text":"line one"},
16926 {"type":"hardBreak"},
16927 {"type":"text","text":"line two"}
16928 ]}]}"#;
16929 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16930 let md = adf_to_markdown(&doc).unwrap();
16931 let round_tripped = markdown_to_adf(&md).unwrap();
16932 let inlines = round_tripped.content[0].content.as_ref().unwrap();
16933 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16934 assert_eq!(
16935 types,
16936 vec!["text", "hardBreak", "text"],
16937 "hardBreak should be preserved, got: {types:?}"
16938 );
16939 assert_eq!(inlines[0].text.as_deref(), Some("line one"));
16940 assert_eq!(inlines[2].text.as_deref(), Some("line two"));
16941 }
16942
16943 #[test]
16944 fn consecutive_hardbreaks_in_paragraph_roundtrip() {
16945 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
16947 {"type":"text","text":"before"},
16948 {"type":"hardBreak"},
16949 {"type":"hardBreak"},
16950 {"type":"text","text":"after"}
16951 ]}]}"#;
16952 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16953 let md = adf_to_markdown(&doc).unwrap();
16954 let round_tripped = markdown_to_adf(&md).unwrap();
16955 assert_eq!(
16956 round_tripped.content.len(),
16957 1,
16958 "Should remain a single paragraph, got {} blocks",
16959 round_tripped.content.len()
16960 );
16961 let inlines = round_tripped.content[0].content.as_ref().unwrap();
16962 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16963 assert_eq!(
16964 types,
16965 vec!["text", "hardBreak", "hardBreak", "text"],
16966 "Both hardBreaks should be preserved, got: {types:?}"
16967 );
16968 assert_eq!(inlines[0].text.as_deref(), Some("before"));
16969 assert_eq!(inlines[3].text.as_deref(), Some("after"));
16970 }
16971
16972 #[test]
16973 fn hardbreak_only_paragraph_roundtrips() {
16974 let adf_json = r#"{"version":1,"type":"doc","content":[
16976 {"type":"paragraph","content":[{"type":"hardBreak"}]}
16977 ]}"#;
16978 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16979 let md = adf_to_markdown(&doc).unwrap();
16980 let round_tripped = markdown_to_adf(&md).unwrap();
16981 assert_eq!(
16982 round_tripped.content.len(),
16983 1,
16984 "Paragraph should not be dropped, got {} blocks",
16985 round_tripped.content.len()
16986 );
16987 let inlines = round_tripped.content[0].content.as_ref().unwrap();
16988 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16989 assert_eq!(
16990 types,
16991 vec!["hardBreak"],
16992 "hardBreak-only paragraph should preserve its content, got: {types:?}"
16993 );
16994 }
16995
16996 #[test]
16997 fn issue_410_full_reproducer_roundtrips() {
16998 let adf_json = r#"{"version":1,"type":"doc","content":[
17000 {"type":"paragraph","content":[
17001 {"type":"text","text":"before"},
17002 {"type":"hardBreak"},
17003 {"type":"hardBreak"},
17004 {"type":"text","text":"after"}
17005 ]},
17006 {"type":"paragraph","content":[
17007 {"type":"hardBreak"}
17008 ]}
17009 ]}"#;
17010 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17011 let md = adf_to_markdown(&doc).unwrap();
17012 let round_tripped = markdown_to_adf(&md).unwrap();
17013 assert_eq!(
17014 round_tripped.content.len(),
17015 2,
17016 "Should have exactly 2 paragraphs, got {}",
17017 round_tripped.content.len()
17018 );
17019 let p1 = round_tripped.content[0].content.as_ref().unwrap();
17021 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
17022 assert_eq!(types1, vec!["text", "hardBreak", "hardBreak", "text"]);
17023 let p2 = round_tripped.content[1].content.as_ref().unwrap();
17025 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
17026 assert_eq!(types2, vec!["hardBreak"]);
17027 }
17028
17029 #[test]
17030 fn trailing_space_hardbreak_still_parsed() {
17031 let md = "line one \nline two\n";
17033 let doc = markdown_to_adf(md).unwrap();
17034 let inlines = doc.content[0].content.as_ref().unwrap();
17035 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17036 assert_eq!(
17037 types,
17038 vec!["text", "hardBreak", "text"],
17039 "Trailing-space hardBreak should still parse, got: {types:?}"
17040 );
17041 }
17042
17043 #[test]
17044 fn trailing_hardbreak_at_end_of_paragraph_roundtrips() {
17045 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17047 {"type":"text","text":"text"},
17048 {"type":"hardBreak"}
17049 ]}]}"#;
17050 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17051 let md = adf_to_markdown(&doc).unwrap();
17052 let round_tripped = markdown_to_adf(&md).unwrap();
17053 let inlines = round_tripped.content[0].content.as_ref().unwrap();
17054 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17055 assert_eq!(
17056 types,
17057 vec!["text", "hardBreak"],
17058 "Trailing hardBreak should be preserved, got: {types:?}"
17059 );
17060 }
17061
17062 #[test]
17063 #[test]
17064 fn table_with_header_row_uses_pipe_syntax() {
17065 let adf = AdfDocument {
17067 version: 1,
17068 doc_type: "doc".to_string(),
17069 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17070 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("header cell")])]),
17071 ])])],
17072 };
17073 let md = adf_to_markdown(&adf).unwrap();
17074 assert!(
17075 md.contains("| header cell |"),
17076 "Table with header row should use pipe syntax, got:\n{md}"
17077 );
17078 }
17079
17080 #[test]
17081 fn table_without_header_row_uses_directive_syntax() {
17082 let adf = AdfDocument {
17085 version: 1,
17086 doc_type: "doc".to_string(),
17087 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17088 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("simple cell")])]),
17089 ])])],
17090 };
17091 let md = adf_to_markdown(&adf).unwrap();
17092 assert!(
17093 md.contains("::::table"),
17094 "Table without header row should use directive syntax, got:\n{md}"
17095 );
17096 }
17097
17098 #[test]
17099 fn tablecell_first_row_preserved_on_roundtrip() {
17100 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[
17102 {"type":"tableRow","content":[
17103 {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row1 cell"}]}]}
17104 ]},
17105 {"type":"tableRow","content":[
17106 {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row2 cell"}]}]}
17107 ]}
17108 ]}]}"#;
17109 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17110 let md = adf_to_markdown(&doc).unwrap();
17111 let round_tripped = markdown_to_adf(&md).unwrap();
17112 let rows = round_tripped.content[0].content.as_ref().unwrap();
17113 let row0_cell = &rows[0].content.as_ref().unwrap()[0];
17114 assert_eq!(
17115 row0_cell.node_type, "tableCell",
17116 "first row cell should remain tableCell, got: {}",
17117 row0_cell.node_type
17118 );
17119 let row1_cell = &rows[1].content.as_ref().unwrap()[0];
17120 assert_eq!(row1_cell.node_type, "tableCell");
17121 }
17122
17123 #[test]
17124 fn mixed_header_and_cell_first_row_uses_pipe() {
17125 let adf = AdfDocument {
17127 version: 1,
17128 doc_type: "doc".to_string(),
17129 content: vec![AdfNode::table(vec![
17130 AdfNode::table_row(vec![
17131 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17132 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17133 ]),
17134 AdfNode::table_row(vec![
17135 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
17136 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
17137 ]),
17138 ])],
17139 };
17140 let md = adf_to_markdown(&adf).unwrap();
17141 assert!(
17142 md.contains("| H1 |"),
17143 "Table with header first row should use pipe syntax, got:\n{md}"
17144 );
17145 assert!(!md.contains("::::table"), "should not use directive syntax");
17146 }
17147
17148 #[test]
17151 fn render_pipe_table_escapes_pipe_in_code_span_cell() {
17152 let adf = AdfDocument {
17155 version: 1,
17156 doc_type: "doc".to_string(),
17157 content: vec![AdfNode::table(vec![
17158 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17159 AdfNode::text("Header"),
17160 ])])]),
17161 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17162 AdfNode::text_with_marks("a|b", vec![AdfMark::code()]),
17163 ])])]),
17164 ])],
17165 };
17166 let md = adf_to_markdown(&adf).unwrap();
17167 assert!(
17168 md.contains(r"`a\|b`"),
17169 "Pipe inside code span must be escaped, got:\n{md}"
17170 );
17171 }
17172
17173 #[test]
17174 fn render_pipe_table_escapes_pipe_in_plain_text_cell() {
17175 let adf = AdfDocument {
17176 version: 1,
17177 doc_type: "doc".to_string(),
17178 content: vec![AdfNode::table(vec![
17179 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17180 AdfNode::text("Header"),
17181 ])])]),
17182 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17183 AdfNode::text("x|y"),
17184 ])])]),
17185 ])],
17186 };
17187 let md = adf_to_markdown(&adf).unwrap();
17188 assert!(
17189 md.contains(r"x\|y"),
17190 "Pipe inside plain-text cell must be escaped, got:\n{md}"
17191 );
17192 }
17193
17194 #[test]
17195 fn code_span_with_pipe_in_table_cell_roundtrips() {
17196 let adf_json = r#"{
17198 "version": 1,
17199 "type": "doc",
17200 "content": [{
17201 "type": "table",
17202 "attrs": {"isNumberColumnEnabled": false, "layout": "default", "localId": "abc-789"},
17203 "content": [
17204 {"type": "tableRow", "content": [
17205 {"type": "tableHeader", "attrs": {}, "content": [
17206 {"type": "paragraph", "content": [{"type": "text", "text": "Before"}]}
17207 ]},
17208 {"type": "tableHeader", "attrs": {}, "content": [
17209 {"type": "paragraph", "content": [{"type": "text", "text": "After"}]}
17210 ]}
17211 ]},
17212 {"type": "tableRow", "content": [
17213 {"type": "tableCell", "attrs": {}, "content": [
17214 {"type": "paragraph", "content": [
17215 {"type": "text", "text": "parse(json).extract[T]", "marks": [{"type": "code"}]}
17216 ]}
17217 ]},
17218 {"type": "tableCell", "attrs": {}, "content": [
17219 {"type": "paragraph", "content": [
17220 {"type": "text", "text": "parser.decode[T|json]", "marks": [{"type": "code"}]}
17221 ]}
17222 ]}
17223 ]}
17224 ]
17225 }]
17226 }"#;
17227 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17228 let md = adf_to_markdown(&doc).unwrap();
17229 let round_tripped = markdown_to_adf(&md).unwrap();
17230
17231 let rows = round_tripped.content[0].content.as_ref().unwrap();
17232 assert_eq!(
17233 rows.len(),
17234 2,
17235 "Table should have 2 rows, got: {}",
17236 rows.len()
17237 );
17238
17239 let body_row = rows[1].content.as_ref().unwrap();
17240 assert_eq!(
17241 body_row.len(),
17242 2,
17243 "Body row should have 2 cells (not split by the pipe), got: {}",
17244 body_row.len()
17245 );
17246
17247 let second_cell = &body_row[1];
17248 let para = second_cell.content.as_ref().unwrap().first().unwrap();
17249 let inlines = para.content.as_ref().unwrap();
17250 assert_eq!(inlines.len(), 1, "Cell should have a single text node");
17251 assert_eq!(
17252 inlines[0].text.as_deref(),
17253 Some("parser.decode[T|json]"),
17254 "Code-span text must be preserved with literal pipe"
17255 );
17256 let marks = inlines[0]
17257 .marks
17258 .as_ref()
17259 .expect("code mark must be preserved");
17260 assert!(
17261 marks.iter().any(|m| m.mark_type == "code"),
17262 "text node should carry the code mark"
17263 );
17264 }
17265
17266 #[test]
17267 fn plain_text_pipe_in_table_cell_roundtrips() {
17268 let adf = AdfDocument {
17270 version: 1,
17271 doc_type: "doc".to_string(),
17272 content: vec![AdfNode::table(vec![
17273 AdfNode::table_row(vec![
17274 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17275 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17276 ]),
17277 AdfNode::table_row(vec![
17278 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a|b")])]),
17279 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("c")])]),
17280 ]),
17281 ])],
17282 };
17283 let md = adf_to_markdown(&adf).unwrap();
17284 let round_tripped = markdown_to_adf(&md).unwrap();
17285 let rows = round_tripped.content[0].content.as_ref().unwrap();
17286 let body_row = rows[1].content.as_ref().unwrap();
17287 assert_eq!(
17288 body_row.len(),
17289 2,
17290 "Body row should keep 2 cells, got: {}",
17291 body_row.len()
17292 );
17293 let first_cell_text = body_row[0].content.as_ref().unwrap()[0]
17294 .content
17295 .as_ref()
17296 .unwrap()[0]
17297 .text
17298 .as_deref();
17299 assert_eq!(first_cell_text, Some("a|b"));
17300 }
17301
17302 #[test]
17303 fn cell_contains_hard_break_true() {
17304 let para = AdfNode::paragraph(vec![
17305 AdfNode::text("a"),
17306 AdfNode::hard_break(),
17307 AdfNode::text("b"),
17308 ]);
17309 assert!(cell_contains_hard_break(¶));
17310 }
17311
17312 #[test]
17313 fn cell_contains_hard_break_false() {
17314 let para = AdfNode::paragraph(vec![AdfNode::text("no break here")]);
17315 assert!(!cell_contains_hard_break(¶));
17316 }
17317
17318 #[test]
17319 fn cell_contains_hard_break_empty() {
17320 let para = AdfNode::paragraph(vec![]);
17321 assert!(!cell_contains_hard_break(¶));
17322 }
17323
17324 #[test]
17327 fn multi_paragraph_panel_roundtrips() {
17328 let adf = AdfDocument {
17329 version: 1,
17330 doc_type: "doc".to_string(),
17331 content: vec![AdfNode {
17332 node_type: "panel".to_string(),
17333 attrs: Some(serde_json::json!({"panelType": "info"})),
17334 content: Some(vec![
17335 AdfNode::paragraph(vec![AdfNode::text("First paragraph.")]),
17336 AdfNode::paragraph(vec![AdfNode::text("Second paragraph.")]),
17337 ]),
17338 text: None,
17339 marks: None,
17340 local_id: None,
17341 parameters: None,
17342 }],
17343 };
17344
17345 let md = adf_to_markdown(&adf).unwrap();
17346 assert!(
17348 md.contains("First paragraph.\n\nSecond paragraph."),
17349 "Panel should have blank line between paragraphs, got:\n{md}"
17350 );
17351
17352 let roundtripped = markdown_to_adf(&md).unwrap();
17354 assert_eq!(roundtripped.content.len(), 1);
17355 assert_eq!(roundtripped.content[0].node_type, "panel");
17356 let panel_content = roundtripped.content[0].content.as_ref().unwrap();
17357 assert_eq!(
17358 panel_content.len(),
17359 2,
17360 "Panel should have 2 paragraphs after round-trip, got {}",
17361 panel_content.len()
17362 );
17363 }
17364
17365 #[test]
17366 fn multi_paragraph_expand_roundtrips() {
17367 let adf = AdfDocument {
17368 version: 1,
17369 doc_type: "doc".to_string(),
17370 content: vec![AdfNode {
17371 node_type: "expand".to_string(),
17372 attrs: Some(serde_json::json!({"title": "Details"})),
17373 content: Some(vec![
17374 AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17375 AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17376 ]),
17377 text: None,
17378 marks: None,
17379 local_id: None,
17380 parameters: None,
17381 }],
17382 };
17383
17384 let md = adf_to_markdown(&adf).unwrap();
17385 let roundtripped = markdown_to_adf(&md).unwrap();
17386 let expand_content = roundtripped.content[0].content.as_ref().unwrap();
17387 assert_eq!(
17388 expand_content.len(),
17389 2,
17390 "Expand should have 2 paragraphs after round-trip, got {}",
17391 expand_content.len()
17392 );
17393 }
17394
17395 #[test]
17396 fn consecutive_nested_expands_in_table_cell_roundtrip() {
17397 let cell_content = vec![
17398 AdfNode {
17399 node_type: "nestedExpand".to_string(),
17400 attrs: Some(serde_json::json!({"title": "First"})),
17401 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 1")])]),
17402 text: None,
17403 marks: None,
17404 local_id: None,
17405 parameters: None,
17406 },
17407 AdfNode {
17408 node_type: "nestedExpand".to_string(),
17409 attrs: Some(serde_json::json!({"title": "Second"})),
17410 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 2")])]),
17411 text: None,
17412 marks: None,
17413 local_id: None,
17414 parameters: None,
17415 },
17416 ];
17417 let adf = AdfDocument {
17418 version: 1,
17419 doc_type: "doc".to_string(),
17420 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17421 AdfNode::table_cell(cell_content),
17422 ])])],
17423 };
17424
17425 let md = adf_to_markdown(&adf).unwrap();
17426 assert!(
17427 md.contains(":::\n\n:::nested-expand"),
17428 "Should have blank line between consecutive nested-expands in cell, got:\n{md}"
17429 );
17430
17431 let rt = markdown_to_adf(&md).unwrap();
17432 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17433 .content
17434 .as_ref()
17435 .unwrap()[0];
17436 let cell_nodes = cell.content.as_ref().unwrap();
17437 let expand_count = cell_nodes
17438 .iter()
17439 .filter(|n| n.node_type == "nestedExpand")
17440 .count();
17441 assert_eq!(
17442 expand_count, 2,
17443 "Both nested-expands should survive round-trip, got {expand_count}"
17444 );
17445 }
17446
17447 #[test]
17448 fn multi_paragraph_in_table_cell_roundtrip() {
17449 let adf = AdfDocument {
17451 version: 1,
17452 doc_type: "doc".to_string(),
17453 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17454 AdfNode::table_cell(vec![
17455 AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17456 AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17457 ]),
17458 ])])],
17459 };
17460
17461 let md = adf_to_markdown(&adf).unwrap();
17462 assert!(
17463 md.contains("Para one.\n\nPara two."),
17464 "Should have blank line between paragraphs in cell, got:\n{md}"
17465 );
17466
17467 let rt = markdown_to_adf(&md).unwrap();
17468 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17469 .content
17470 .as_ref()
17471 .unwrap()[0];
17472 let para_count = cell
17473 .content
17474 .as_ref()
17475 .unwrap()
17476 .iter()
17477 .filter(|n| n.node_type == "paragraph")
17478 .count();
17479 assert_eq!(para_count, 2, "Both paragraphs should survive round-trip");
17480 }
17481
17482 #[test]
17483 fn panel_inside_table_cell_roundtrip() {
17484 let adf = AdfDocument {
17486 version: 1,
17487 doc_type: "doc".to_string(),
17488 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17489 AdfNode::table_cell(vec![
17490 AdfNode::paragraph(vec![AdfNode::text("Before panel.")]),
17491 AdfNode {
17492 node_type: "panel".to_string(),
17493 attrs: Some(serde_json::json!({"panelType": "info"})),
17494 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text(
17495 "Panel content",
17496 )])]),
17497 text: None,
17498 marks: None,
17499 local_id: None,
17500 parameters: None,
17501 },
17502 ]),
17503 ])])],
17504 };
17505
17506 let md = adf_to_markdown(&adf).unwrap();
17507 assert!(
17508 md.contains(":::panel"),
17509 "Should contain panel directive, got:\n{md}"
17510 );
17511
17512 let rt = markdown_to_adf(&md).unwrap();
17513 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17514 .content
17515 .as_ref()
17516 .unwrap()[0];
17517 let has_panel = cell
17518 .content
17519 .as_ref()
17520 .unwrap()
17521 .iter()
17522 .any(|n| n.node_type == "panel");
17523 assert!(has_panel, "Panel should survive round-trip in table cell");
17524 }
17525
17526 #[test]
17527 fn three_consecutive_expands_in_table_cell() {
17528 let make_expand = |title: &str| AdfNode {
17529 node_type: "nestedExpand".to_string(),
17530 attrs: Some(serde_json::json!({"title": title})),
17531 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("content")])]),
17532 text: None,
17533 marks: None,
17534 local_id: None,
17535 parameters: None,
17536 };
17537 let adf = AdfDocument {
17538 version: 1,
17539 doc_type: "doc".to_string(),
17540 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17541 AdfNode::table_cell(vec![
17542 make_expand("First"),
17543 make_expand("Second"),
17544 make_expand("Third"),
17545 ]),
17546 ])])],
17547 };
17548
17549 let md = adf_to_markdown(&adf).unwrap();
17550 let rt = markdown_to_adf(&md).unwrap();
17551 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17552 .content
17553 .as_ref()
17554 .unwrap()[0];
17555 let expand_count = cell
17556 .content
17557 .as_ref()
17558 .unwrap()
17559 .iter()
17560 .filter(|n| n.node_type == "nestedExpand")
17561 .count();
17562 assert_eq!(expand_count, 3, "All 3 expands should survive round-trip");
17563 }
17564
17565 #[test]
17568 fn nested_expand_inside_panel() {
17569 let md = ":::panel{type=info}\n:::expand{title=\"Details\"}\nHidden content\n:::\nMore panel content\n:::";
17574 let adf = markdown_to_adf(md).unwrap();
17575
17576 let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
17577 assert!(err.violations.iter().any(|v| matches!(
17578 v,
17579 crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
17580 parent_type, child_type, ..
17581 } if parent_type == "panel" && child_type == "expand"
17582 )));
17583 }
17584
17585 #[test]
17586 fn nested_expand_inside_table_cell() {
17587 let md = "::::table\n:::tr\n:::td\n:::expand{title=\"Details\"}\nExpand content\n:::\n:::\n:::\n::::";
17591 let adf = markdown_to_adf(md).unwrap();
17592
17593 let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
17594 assert!(err.violations.iter().any(|v| matches!(
17595 v,
17596 crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
17597 parent_type, child_type, ..
17598 } if parent_type == "tableCell" && child_type == "expand"
17599 )));
17600 }
17601
17602 #[test]
17603 fn nested_expand_inside_layout_column() {
17604 let md = ":::layout\n:::column{width=50}\n:::expand{title=\"Col Expand\"}\nExpanded\n:::\n:::\n:::column{width=50}\nFiller paragraph.\n:::\n:::";
17609 let adf = markdown_to_adf(md).unwrap();
17610
17611 assert_eq!(adf.content.len(), 1);
17612 assert_eq!(adf.content[0].node_type, "layoutSection");
17613
17614 let columns = adf.content[0].content.as_ref().unwrap();
17615 assert_eq!(columns.len(), 2);
17616 let col_content = columns[0].content.as_ref().unwrap();
17617 assert!(
17618 col_content.iter().any(|n| n.node_type == "expand"),
17619 "Column should contain an expand node, got: {:?}",
17620 col_content.iter().map(|n| &n.node_type).collect::<Vec<_>>()
17621 );
17622
17623 crate::atlassian::adf_validated::validate(&adf).unwrap();
17625 }
17626
17627 #[test]
17628 fn expand_localid_in_directive_attrs() {
17629 let adf_json = r#"{"version":1,"type":"doc","content":[
17631 {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
17632 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17633 ]}
17634 ]}"#;
17635 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17636 let md = adf_to_markdown(&doc).unwrap();
17637 assert!(
17638 md.contains("localId=exp-001"),
17639 "should contain localId: {md}"
17640 );
17641 assert!(
17642 md.contains(":::expand{"),
17643 "should have expand directive with attrs: {md}"
17644 );
17645 assert!(
17646 !md.contains(":::\n{localId="),
17647 "localId should NOT be trailing: {md}"
17648 );
17649 }
17650
17651 #[test]
17652 fn expand_localid_roundtrip() {
17653 let adf_json = r#"{"version":1,"type":"doc","content":[
17654 {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
17655 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17656 ]}
17657 ]}"#;
17658 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17659 let md = adf_to_markdown(&doc).unwrap();
17660 let rt = markdown_to_adf(&md).unwrap();
17661 let expand = &rt.content[0];
17662 assert_eq!(expand.node_type, "expand");
17663 assert_eq!(
17664 expand.local_id.as_deref(),
17665 Some("exp-001"),
17666 "expand localId should survive round-trip"
17667 );
17668 assert_eq!(
17669 expand.attrs.as_ref().unwrap()["title"],
17670 "Details",
17671 "expand title should survive round-trip"
17672 );
17673 }
17674
17675 #[test]
17676 fn nested_expand_localid_roundtrip() {
17677 let adf_json = r#"{"version":1,"type":"doc","content":[
17678 {"type":"nestedExpand","attrs":{"localId":"ne-001","title":"S"},"content":[
17679 {"type":"paragraph","content":[{"type":"text","text":"content"}]}
17680 ]}
17681 ]}"#;
17682 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17683 let md = adf_to_markdown(&doc).unwrap();
17684 assert!(
17685 md.contains(":::nested-expand{"),
17686 "should have directive: {md}"
17687 );
17688 assert!(md.contains("localId=ne-001"), "should have localId: {md}");
17689 let rt = markdown_to_adf(&md).unwrap();
17690 let ne = &rt.content[0];
17691 assert_eq!(ne.node_type, "nestedExpand");
17692 assert_eq!(ne.local_id.as_deref(), Some("ne-001"));
17693 }
17694
17695 #[test]
17696 fn nested_expand_localid_followed_by_content() {
17697 let adf_json = "{\
17699 \"version\":1,\"type\":\"doc\",\"content\":[\
17700 {\"type\":\"nestedExpand\",\"attrs\":{\"localId\":\"exp-001\",\"title\":\"S\"},\"content\":[\
17701 {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}\
17702 ]},\
17703 {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]}\
17704 ]}";
17705 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17706 let md = adf_to_markdown(&doc).unwrap();
17707 let rt = markdown_to_adf(&md).unwrap();
17708 let ne = &rt.content[0];
17710 assert_eq!(ne.node_type, "nestedExpand");
17711 assert_eq!(
17712 ne.local_id.as_deref(),
17713 Some("exp-001"),
17714 "nestedExpand should preserve localId"
17715 );
17716 let para = &rt.content[1];
17718 assert_eq!(para.node_type, "paragraph");
17719 let text = para.content.as_ref().unwrap()[0]
17720 .text
17721 .as_deref()
17722 .unwrap_or("");
17723 assert!(
17724 !text.contains("localId"),
17725 "following paragraph should not contain localId: {text}"
17726 );
17727 assert!(
17728 text.contains("after"),
17729 "following paragraph should contain 'after': {text}"
17730 );
17731 }
17732
17733 #[test]
17734 fn expand_localid_without_title() {
17735 let adf_json = r#"{"version":1,"type":"doc","content":[
17736 {"type":"expand","attrs":{"localId":"exp-002"},"content":[
17737 {"type":"paragraph","content":[{"type":"text","text":"no title"}]}
17738 ]}
17739 ]}"#;
17740 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17741 let md = adf_to_markdown(&doc).unwrap();
17742 assert!(
17743 md.contains(":::expand{localId=exp-002}"),
17744 "should have localId without title: {md}"
17745 );
17746 let rt = markdown_to_adf(&md).unwrap();
17747 assert_eq!(rt.content[0].local_id.as_deref(), Some("exp-002"));
17748 }
17749
17750 #[test]
17751 fn expand_localid_stripped() {
17752 let adf_json = r#"{"version":1,"type":"doc","content":[
17753 {"type":"expand","attrs":{"localId":"exp-001","title":"X"},"content":[
17754 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17755 ]}
17756 ]}"#;
17757 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17758 let opts = RenderOptions {
17759 strip_local_ids: true,
17760 };
17761 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
17762 assert!(!md.contains("localId"), "localId should be stripped: {md}");
17763 assert!(
17764 md.contains(":::expand{title=\"X\"}"),
17765 "title should remain: {md}"
17766 );
17767 }
17768
17769 #[test]
17772 fn expand_top_level_localid_roundtrip() {
17773 let adf_json = r#"{"version":1,"type":"doc","content":[
17775 {"type":"expand","attrs":{"title":"My Section"},"localId":"abc-123","content":[
17776 {"type":"paragraph","content":[{"type":"text","text":"hello"}]}
17777 ]}
17778 ]}"#;
17779 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17780 assert_eq!(doc.content[0].local_id.as_deref(), Some("abc-123"));
17781 let md = adf_to_markdown(&doc).unwrap();
17782 assert!(
17783 md.contains("localId=abc-123"),
17784 "JFM should contain localId: {md}"
17785 );
17786 let rt = markdown_to_adf(&md).unwrap();
17787 let expand = &rt.content[0];
17788 assert_eq!(expand.node_type, "expand");
17789 assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
17790 assert_eq!(
17791 expand.attrs.as_ref().unwrap()["title"],
17792 "My Section",
17793 "title should survive round-trip"
17794 );
17795 }
17796
17797 #[test]
17798 fn expand_parameters_roundtrip() {
17799 let adf_json = r#"{"version":1,"type":"doc","content":[
17801 {"type":"expand","attrs":{"title":"Props"},"parameters":{"macroMetadata":{"macroId":{"value":"m-001"},"schemaVersion":{"value":"1"}}},"content":[
17802 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17803 ]}
17804 ]}"#;
17805 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17806 assert!(doc.content[0].parameters.is_some());
17807 let md = adf_to_markdown(&doc).unwrap();
17808 assert!(md.contains("params="), "JFM should contain params: {md}");
17809 let rt = markdown_to_adf(&md).unwrap();
17810 let expand = &rt.content[0];
17811 let params = expand
17812 .parameters
17813 .as_ref()
17814 .expect("parameters should survive round-trip");
17815 assert_eq!(params["macroMetadata"]["macroId"]["value"], "m-001");
17816 assert_eq!(params["macroMetadata"]["schemaVersion"]["value"], "1");
17817 }
17818
17819 #[test]
17820 fn expand_localid_and_parameters_roundtrip() {
17821 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"}]}]}]}"#;
17823 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17824 let md = adf_to_markdown(&doc).unwrap();
17825 let rt = markdown_to_adf(&md).unwrap();
17826 let expand = &rt.content[0];
17827 assert_eq!(expand.node_type, "expand");
17828 assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
17829 assert_eq!(expand.attrs.as_ref().unwrap()["title"], "My Section");
17830 let params = expand
17831 .parameters
17832 .as_ref()
17833 .expect("parameters should survive");
17834 assert_eq!(params["macroMetadata"]["macroId"]["value"], "macro-001");
17835 assert_eq!(params["macroMetadata"]["title"], "Page Properties");
17836 }
17837
17838 #[test]
17839 fn nested_expand_top_level_localid_and_parameters_roundtrip() {
17840 let adf_json = r#"{"version":1,"type":"doc","content":[
17841 {"type":"nestedExpand","attrs":{"title":"Nested"},"localId":"ne-100","parameters":{"macroMetadata":{"macroId":{"value":"nm-001"}}},"content":[
17842 {"type":"paragraph","content":[{"type":"text","text":"inner"}]}
17843 ]}
17844 ]}"#;
17845 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17846 let md = adf_to_markdown(&doc).unwrap();
17847 assert!(
17848 md.contains(":::nested-expand{"),
17849 "should use nested-expand: {md}"
17850 );
17851 assert!(md.contains("localId=ne-100"), "should have localId: {md}");
17852 assert!(md.contains("params="), "should have params: {md}");
17853 let rt = markdown_to_adf(&md).unwrap();
17854 let ne = &rt.content[0];
17855 assert_eq!(ne.node_type, "nestedExpand");
17856 assert_eq!(ne.local_id.as_deref(), Some("ne-100"));
17857 assert_eq!(
17858 ne.parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
17859 "nm-001"
17860 );
17861 }
17862
17863 #[test]
17864 fn expand_top_level_localid_stripped() {
17865 let adf_json = r#"{"version":1,"type":"doc","content":[
17867 {"type":"expand","attrs":{"title":"X"},"localId":"exp-strip","content":[
17868 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17869 ]}
17870 ]}"#;
17871 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17872 let opts = RenderOptions {
17873 strip_local_ids: true,
17874 };
17875 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
17876 assert!(!md.contains("localId"), "localId should be stripped: {md}");
17877 assert!(
17878 md.contains(":::expand{title=\"X\"}"),
17879 "title should remain: {md}"
17880 );
17881 }
17882
17883 #[test]
17884 fn expand_parameters_without_localid() {
17885 let adf_json = r#"{"version":1,"type":"doc","content":[
17887 {"type":"expand","attrs":{"title":"P"},"parameters":{"macroMetadata":{"macroId":{"value":"solo"}}},"content":[
17888 {"type":"paragraph","content":[{"type":"text","text":"data"}]}
17889 ]}
17890 ]}"#;
17891 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17892 let md = adf_to_markdown(&doc).unwrap();
17893 assert!(!md.contains("localId"), "no localId: {md}");
17894 assert!(md.contains("params="), "has params: {md}");
17895 let rt = markdown_to_adf(&md).unwrap();
17896 assert!(rt.content[0].local_id.is_none());
17897 assert_eq!(
17898 rt.content[0].parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
17899 "solo"
17900 );
17901 }
17902
17903 #[test]
17904 fn expand_localid_without_parameters() {
17905 let adf_json = r#"{"version":1,"type":"doc","content":[
17907 {"type":"expand","attrs":{"title":"L"},"localId":"lid-only","content":[
17908 {"type":"paragraph","content":[{"type":"text","text":"txt"}]}
17909 ]}
17910 ]}"#;
17911 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17912 let md = adf_to_markdown(&doc).unwrap();
17913 assert!(md.contains("localId=lid-only"), "has localId: {md}");
17914 assert!(!md.contains("params="), "no params: {md}");
17915 let rt = markdown_to_adf(&md).unwrap();
17916 assert_eq!(rt.content[0].local_id.as_deref(), Some("lid-only"));
17917 assert!(rt.content[0].parameters.is_none());
17918 }
17919
17920 #[test]
17921 fn nested_panel_inside_panel() {
17922 let md = ":::panel{type=info}\n:::panel{type=warning}\nInner warning\n:::\n:::";
17923 let adf = markdown_to_adf(md).unwrap();
17924
17925 assert_eq!(adf.content.len(), 1);
17927 assert_eq!(adf.content[0].node_type, "panel");
17928
17929 let panel_content = adf.content[0].content.as_ref().unwrap();
17931 assert!(
17932 panel_content.iter().any(|n| n.node_type == "panel"),
17933 "Outer panel should contain an inner panel, got: {:?}",
17934 panel_content
17935 .iter()
17936 .map(|n| &n.node_type)
17937 .collect::<Vec<_>>()
17938 );
17939 }
17940
17941 #[test]
17942 fn content_after_directive_table_is_preserved() {
17943 let md = "\
17945## Before table
17946
17947::::table{layout=default}
17948:::tr
17949:::th{}
17950Cell
17951:::
17952:::
17953::::
17954
17955## After table
17956
17957Paragraph after.";
17958 let adf = markdown_to_adf(md).unwrap();
17959 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
17960 assert_eq!(
17961 types,
17962 vec!["heading", "table", "heading", "paragraph"],
17963 "Content after table was dropped: got {types:?}"
17964 );
17965 }
17966
17967 #[test]
17968 fn paragraph_after_directive_table_is_preserved() {
17969 let md = "\
17971::::table{layout=default}
17972:::tr
17973:::th{}
17974Header
17975:::
17976:::
17977::::
17978
17979Just a paragraph.";
17980 let adf = markdown_to_adf(md).unwrap();
17981 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
17982 assert_eq!(
17983 types,
17984 vec!["table", "paragraph"],
17985 "Paragraph after table was dropped: got {types:?}"
17986 );
17987 }
17988
17989 #[test]
17990 fn extension_after_directive_table_is_preserved() {
17991 let md = "\
17993::::table{layout=default}
17994:::tr
17995:::th{}
17996Header
17997:::
17998:::
17999::::
18000
18001::extension{type=com.atlassian.confluence.macro.core key=toc}";
18002 let adf = markdown_to_adf(md).unwrap();
18003 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18004 assert_eq!(
18005 types,
18006 vec!["table", "extension"],
18007 "Extension after table was dropped: got {types:?}"
18008 );
18009 }
18010
18011 #[test]
18012 fn multiple_blocks_after_directive_table() {
18013 let md = "\
18015## Heading 1
18016
18017::::table{layout=default}
18018:::tr
18019:::td{}
18020A
18021:::
18022:::td{}
18023B
18024:::
18025:::
18026::::
18027
18028## Heading 2
18029
18030Some text.
18031
18032---
18033
18034::::table{layout=default}
18035:::tr
18036:::th{}
18037C
18038:::
18039:::
18040::::
18041
18042## Heading 3";
18043 let adf = markdown_to_adf(md).unwrap();
18044 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18045 assert_eq!(
18046 types,
18047 vec![
18048 "heading",
18049 "table",
18050 "heading",
18051 "paragraph",
18052 "rule",
18053 "table",
18054 "heading"
18055 ],
18056 "Content after tables was dropped: got {types:?}"
18057 );
18058 }
18059
18060 #[test]
18063 fn adf_table_caption_to_markdown() {
18064 let doc = AdfDocument {
18065 version: 1,
18066 doc_type: "doc".to_string(),
18067 content: vec![AdfNode::table(vec![
18068 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18069 AdfNode::text("cell"),
18070 ])])]),
18071 AdfNode::caption(vec![AdfNode::text("Table caption")]),
18072 ])],
18073 };
18074 let md = adf_to_markdown(&doc).unwrap();
18075 assert!(
18076 md.contains("::::table"),
18077 "table with caption must use directive form"
18078 );
18079 assert!(
18080 md.contains(":::caption"),
18081 "caption directive missing, got: {md}"
18082 );
18083 assert!(
18084 md.contains("Table caption"),
18085 "caption text missing, got: {md}"
18086 );
18087 }
18088
18089 #[test]
18090 fn directive_table_caption_parses() {
18091 let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nTable caption\n:::\n::::\n";
18092 let doc = markdown_to_adf(md).unwrap();
18093 let table = &doc.content[0];
18094 assert_eq!(table.node_type, "table");
18095 let children = table.content.as_ref().unwrap();
18096 assert_eq!(children.len(), 2, "expected row + caption");
18097 assert_eq!(children[0].node_type, "tableRow");
18098 assert_eq!(children[1].node_type, "caption");
18099 let caption_content = children[1].content.as_ref().unwrap();
18100 assert_eq!(caption_content[0].text.as_deref(), Some("Table caption"));
18101 }
18102
18103 #[test]
18104 fn table_caption_round_trip_from_adf_json() {
18105 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18106 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18107 {"type":"caption","content":[{"type":"text","text":"Table caption"}]}
18108 ]}]}"#;
18109 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18110 let md = adf_to_markdown(&doc).unwrap();
18111 assert!(md.contains("Table caption"), "caption text lost in ADF→JFM");
18112 let round_tripped = markdown_to_adf(&md).unwrap();
18113 let children = round_tripped.content[0].content.as_ref().unwrap();
18114 let caption = children.iter().find(|n| n.node_type == "caption");
18115 assert!(caption.is_some(), "caption lost on round-trip");
18116 let caption_text = caption.unwrap().content.as_ref().unwrap();
18117 assert_eq!(caption_text[0].text.as_deref(), Some("Table caption"));
18118 }
18119
18120 #[test]
18121 fn table_caption_with_inline_marks_round_trips() {
18122 let doc = AdfDocument {
18123 version: 1,
18124 doc_type: "doc".to_string(),
18125 content: vec![AdfNode::table(vec![
18126 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18127 AdfNode::text("data"),
18128 ])])]),
18129 AdfNode::caption(vec![
18130 AdfNode::text("Caption with "),
18131 AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
18132 ]),
18133 ])],
18134 };
18135 let md = adf_to_markdown(&doc).unwrap();
18136 assert!(md.contains("**bold**"), "bold mark missing in caption");
18137 let round_tripped = markdown_to_adf(&md).unwrap();
18138 let caption = round_tripped.content[0]
18139 .content
18140 .as_ref()
18141 .unwrap()
18142 .iter()
18143 .find(|n| n.node_type == "caption")
18144 .expect("caption node missing after round-trip");
18145 let inlines = caption.content.as_ref().unwrap();
18146 let bold_node = inlines.iter().find(|n| {
18147 n.marks
18148 .as_ref()
18149 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
18150 });
18151 assert!(bold_node.is_some(), "bold mark lost in caption round-trip");
18152 }
18153
18154 #[test]
18157 fn table_caption_localid_roundtrip() {
18158 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18159 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18160 {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Table with localId"}]}
18161 ]}]}"#;
18162 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18163 let md = adf_to_markdown(&doc).unwrap();
18164 assert!(
18165 md.contains("localId=abcdef123456"),
18166 "table caption localId should appear in markdown: {md}"
18167 );
18168 let rt = markdown_to_adf(&md).unwrap();
18169 let caption = rt.content[0]
18170 .content
18171 .as_ref()
18172 .unwrap()
18173 .iter()
18174 .find(|n| n.node_type == "caption")
18175 .expect("caption should survive round-trip");
18176 assert_eq!(
18177 caption.attrs.as_ref().unwrap()["localId"],
18178 "abcdef123456",
18179 "table caption localId should round-trip"
18180 );
18181 }
18182
18183 #[test]
18184 fn table_caption_without_localid_unchanged() {
18185 let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nPlain caption\n:::\n::::\n";
18186 let doc = markdown_to_adf(md).unwrap();
18187 let caption = doc.content[0]
18188 .content
18189 .as_ref()
18190 .unwrap()
18191 .iter()
18192 .find(|n| n.node_type == "caption")
18193 .unwrap();
18194 assert!(
18195 caption.attrs.is_none(),
18196 "table caption without localId should not gain attrs"
18197 );
18198 let md2 = adf_to_markdown(&doc).unwrap();
18199 assert!(!md2.contains("localId"), "no localId should appear: {md2}");
18200 }
18201
18202 #[test]
18203 fn table_caption_localid_stripped_when_option_set() {
18204 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18205 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18206 {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Stripped"}]}
18207 ]}]}"#;
18208 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18209 let opts = RenderOptions {
18210 strip_local_ids: true,
18211 ..Default::default()
18212 };
18213 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18214 assert!(
18215 !md.contains("localId"),
18216 "table caption localId should be stripped: {md}"
18217 );
18218 }
18219
18220 #[test]
18221 #[test]
18222 fn tablecell_empty_attrs_preserved_on_roundtrip() {
18223 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"}]}]}]}]}]}"#;
18225 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18226 let md = adf_to_markdown(&doc).unwrap();
18227 let round_tripped = markdown_to_adf(&md).unwrap();
18228 let rows = round_tripped.content[0].content.as_ref().unwrap();
18229 let cell = &rows[0].content.as_ref().unwrap()[0];
18230 assert!(
18231 cell.attrs.is_some(),
18232 "tableCell attrs should be preserved, got None"
18233 );
18234 assert_eq!(
18235 cell.attrs.as_ref().unwrap(),
18236 &serde_json::json!({}),
18237 "tableCell attrs should be an empty object"
18238 );
18239 }
18240
18241 #[test]
18242 fn tablecell_empty_attrs_serialized_in_json() {
18243 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"}]}]}]}]}]}"#;
18245 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18246 let md = adf_to_markdown(&doc).unwrap();
18247 let round_tripped = markdown_to_adf(&md).unwrap();
18248 let json = serde_json::to_string(&round_tripped).unwrap();
18249 assert!(
18250 json.contains(r#""attrs":{}"#),
18251 "serialized JSON should contain \"attrs\":{{}}, got: {json}"
18252 );
18253 }
18254
18255 #[test]
18256 fn tablecell_empty_attrs_renders_braces_in_markdown() {
18257 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"}]}]}]}]}]}"#;
18259 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18260 let md = adf_to_markdown(&doc).unwrap();
18261 assert!(
18263 md.contains("{} hello"),
18264 "cell with empty attrs should render '{{}} hello', got: {md}"
18265 );
18266 assert!(
18267 !md.contains("{} world"),
18268 "cell without attrs should not render '{{}}', got: {md}"
18269 );
18270 }
18271
18272 #[test]
18273 fn tablecell_no_attrs_unchanged_on_roundtrip() {
18274 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"}]}]}]}]}]}"#;
18276 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18277 let md = adf_to_markdown(&doc).unwrap();
18278 let round_tripped = markdown_to_adf(&md).unwrap();
18279 let rows = round_tripped.content[0].content.as_ref().unwrap();
18280 let cell = &rows[0].content.as_ref().unwrap()[0];
18281 assert!(
18282 cell.attrs.is_none(),
18283 "tableCell without attrs should stay None, got: {:?}",
18284 cell.attrs
18285 );
18286 }
18287
18288 #[test]
18289 fn tablecell_nonempty_attrs_preserved_on_roundtrip() {
18290 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"}]}]}]}]}]}"##;
18292 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18293 let md = adf_to_markdown(&doc).unwrap();
18294 let round_tripped = markdown_to_adf(&md).unwrap();
18295 let rows = round_tripped.content[0].content.as_ref().unwrap();
18296 let cell = &rows[1].content.as_ref().unwrap()[0];
18297 let attrs = cell.attrs.as_ref().unwrap();
18298 assert_eq!(attrs["background"], "#DEEBFF");
18299 assert_eq!(attrs["colspan"], 2);
18300 }
18301
18302 #[test]
18303 fn pipe_table_not_used_when_caption_present() {
18304 let doc = AdfDocument {
18305 version: 1,
18306 doc_type: "doc".to_string(),
18307 content: vec![AdfNode::table(vec![
18308 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
18309 AdfNode::text("H"),
18310 ])])]),
18311 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18312 AdfNode::text("D"),
18313 ])])]),
18314 AdfNode::caption(vec![AdfNode::text("cap")]),
18315 ])],
18316 };
18317 let md = adf_to_markdown(&doc).unwrap();
18318 assert!(
18319 md.contains("::::table"),
18320 "pipe syntax should not be used when caption is present"
18321 );
18322 }
18323
18324 #[test]
18327 fn hardbreak_with_ordered_marker_in_bullet_item_roundtrips() {
18328 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18331 {"type":"listItem","content":[{"type":"paragraph","content":[
18332 {"type":"text","text":"1. First item"},
18333 {"type":"hardBreak"},
18334 {"type":"text","text":"2. Honouring existing commitments"}
18335 ]}]}
18336 ]}]}"#;
18337 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18338 let md = adf_to_markdown(&doc).unwrap();
18339
18340 assert!(
18342 md.contains(" 2. Honouring"),
18343 "Continuation line should be indented, got:\n{md}"
18344 );
18345
18346 let rt = markdown_to_adf(&md).unwrap();
18348 let list = &rt.content[0];
18349 assert_eq!(list.node_type, "bulletList");
18350 let items = list.content.as_ref().unwrap();
18351 assert_eq!(
18352 items.len(),
18353 1,
18354 "Should be one list item, got {}",
18355 items.len()
18356 );
18357
18358 let para = &items[0].content.as_ref().unwrap()[0];
18359 let inlines = para.content.as_ref().unwrap();
18360 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18361 assert_eq!(
18362 types,
18363 vec!["text", "hardBreak", "text"],
18364 "Expected text+hardBreak+text, got {types:?}"
18365 );
18366 assert_eq!(
18367 inlines[2].text.as_deref().unwrap(),
18368 "2. Honouring existing commitments"
18369 );
18370 }
18371
18372 #[test]
18373 fn hardbreak_with_ordered_marker_in_ordered_item_roundtrips() {
18374 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18376 {"type":"listItem","content":[{"type":"paragraph","content":[
18377 {"type":"text","text":"Introduction "},
18378 {"type":"hardBreak"},
18379 {"type":"text","text":"3. Third point"}
18380 ]}]}
18381 ]}]}"#;
18382 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18383 let md = adf_to_markdown(&doc).unwrap();
18384 let rt = markdown_to_adf(&md).unwrap();
18385
18386 let list = &rt.content[0];
18387 assert_eq!(list.node_type, "orderedList");
18388 let items = list.content.as_ref().unwrap();
18389 assert_eq!(items.len(), 1);
18390
18391 let para = &items[0].content.as_ref().unwrap()[0];
18392 let inlines = para.content.as_ref().unwrap();
18393 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18394 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18395 assert_eq!(inlines[2].text.as_deref().unwrap(), "3. Third point");
18396 }
18397
18398 #[test]
18399 fn hardbreak_with_bullet_marker_in_bullet_item_roundtrips() {
18400 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18402 {"type":"listItem","content":[{"type":"paragraph","content":[
18403 {"type":"text","text":"Header "},
18404 {"type":"hardBreak"},
18405 {"type":"text","text":"- not a sub-item"}
18406 ]}]}
18407 ]}]}"#;
18408 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18409 let md = adf_to_markdown(&doc).unwrap();
18410 let rt = markdown_to_adf(&md).unwrap();
18411
18412 let list = &rt.content[0];
18413 assert_eq!(list.node_type, "bulletList");
18414 let items = list.content.as_ref().unwrap();
18415 assert_eq!(
18416 items.len(),
18417 1,
18418 "Should be one list item, not {}",
18419 items.len()
18420 );
18421
18422 let para = &items[0].content.as_ref().unwrap()[0];
18423 let inlines = para.content.as_ref().unwrap();
18424 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18425 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18426 assert_eq!(inlines[2].text.as_deref().unwrap(), "- not a sub-item");
18427 }
18428
18429 #[test]
18430 fn hardbreak_continuation_followed_by_sub_list() {
18431 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18433 {"type":"listItem","content":[
18434 {"type":"paragraph","content":[
18435 {"type":"text","text":"Main item "},
18436 {"type":"hardBreak"},
18437 {"type":"text","text":"continued here"}
18438 ]},
18439 {"type":"bulletList","content":[
18440 {"type":"listItem","content":[{"type":"paragraph","content":[
18441 {"type":"text","text":"sub-item"}
18442 ]}]}
18443 ]}
18444 ]}
18445 ]}]}"#;
18446 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18447 let md = adf_to_markdown(&doc).unwrap();
18448 let rt = markdown_to_adf(&md).unwrap();
18449
18450 let list = &rt.content[0];
18451 let items = list.content.as_ref().unwrap();
18452 assert_eq!(items.len(), 1);
18453
18454 let item_content = items[0].content.as_ref().unwrap();
18455 assert_eq!(item_content.len(), 2, "Expected paragraph + nested list");
18456 assert_eq!(item_content[0].node_type, "paragraph");
18457 assert_eq!(item_content[1].node_type, "bulletList");
18458
18459 let inlines = item_content[0].content.as_ref().unwrap();
18461 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18462 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18463 }
18464
18465 #[test]
18466 fn multiple_hardbreaks_with_numbered_text_roundtrip() {
18467 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18469 {"type":"listItem","content":[{"type":"paragraph","content":[
18470 {"type":"text","text":"Preamble "},
18471 {"type":"hardBreak"},
18472 {"type":"text","text":"1. Alpha "},
18473 {"type":"hardBreak"},
18474 {"type":"text","text":"2. Bravo"}
18475 ]}]}
18476 ]}]}"#;
18477 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18478 let md = adf_to_markdown(&doc).unwrap();
18479 let rt = markdown_to_adf(&md).unwrap();
18480
18481 let items = rt.content[0].content.as_ref().unwrap();
18482 assert_eq!(items.len(), 1);
18483
18484 let inlines = items[0].content.as_ref().unwrap()[0]
18485 .content
18486 .as_ref()
18487 .unwrap();
18488 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18489 assert_eq!(
18490 types,
18491 vec!["text", "hardBreak", "text", "hardBreak", "text"]
18492 );
18493 }
18494
18495 #[test]
18496 fn trailing_hardbreak_in_bullet_item_roundtrips() {
18497 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18501 {"type":"listItem","content":[{"type":"paragraph","content":[
18502 {"type":"text","text":"ends with break"},
18503 {"type":"hardBreak"}
18504 ]}]}
18505 ]}]}"#;
18506 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18507 let md = adf_to_markdown(&doc).unwrap();
18508 let rt = markdown_to_adf(&md).unwrap();
18509
18510 let list = &rt.content[0];
18511 assert_eq!(list.node_type, "bulletList");
18512 let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18513 .content
18514 .as_ref()
18515 .unwrap();
18516 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18517 assert_eq!(types, vec!["text", "hardBreak"]);
18518 }
18519
18520 #[test]
18521 fn trailing_hardbreak_in_ordered_item_roundtrips() {
18522 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18525 {"type":"listItem","content":[{"type":"paragraph","content":[
18526 {"type":"text","text":"ends with break"},
18527 {"type":"hardBreak"}
18528 ]}]}
18529 ]}]}"#;
18530 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18531 let md = adf_to_markdown(&doc).unwrap();
18532 let rt = markdown_to_adf(&md).unwrap();
18533
18534 let list = &rt.content[0];
18535 assert_eq!(list.node_type, "orderedList");
18536 let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18537 .content
18538 .as_ref()
18539 .unwrap();
18540 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18541 assert_eq!(types, vec!["text", "hardBreak"]);
18542 }
18543
18544 #[test]
18545 fn trailing_space_hardbreak_continuation_in_bullet_item() {
18546 let md = "- first line \n 2. continued\n";
18550 let doc = markdown_to_adf(md).unwrap();
18551
18552 let list = &doc.content[0];
18553 assert_eq!(list.node_type, "bulletList");
18554 let items = list.content.as_ref().unwrap();
18555 assert_eq!(
18556 items.len(),
18557 1,
18558 "Should be one list item, got {}",
18559 items.len()
18560 );
18561
18562 let para = &items[0].content.as_ref().unwrap()[0];
18563 let inlines = para.content.as_ref().unwrap();
18564 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18565 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18566 assert_eq!(inlines[2].text.as_deref().unwrap(), "2. continued");
18567 }
18568
18569 #[test]
18570 fn trailing_space_hardbreak_continuation_in_ordered_item() {
18571 let md = "1. first line \n - continued\n";
18574 let doc = markdown_to_adf(md).unwrap();
18575
18576 let list = &doc.content[0];
18577 assert_eq!(list.node_type, "orderedList");
18578 let items = list.content.as_ref().unwrap();
18579 assert_eq!(
18580 items.len(),
18581 1,
18582 "Should be one list item, got {}",
18583 items.len()
18584 );
18585
18586 let para = &items[0].content.as_ref().unwrap()[0];
18587 let inlines = para.content.as_ref().unwrap();
18588 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18589 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18590 assert_eq!(inlines[2].text.as_deref().unwrap(), "- continued");
18591 }
18592
18593 #[test]
18594 fn multi_paragraph_list_item_with_ordered_marker_roundtrips() {
18595 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18598 {"type":"listItem","content":[
18599 {"type":"paragraph","content":[{"type":"text","text":"some preamble"}]},
18600 {"type":"paragraph","content":[{"type":"text","text":"2. Honouring existing commitments"}]}
18601 ]}
18602 ]}]}"#;
18603 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18604 let md = adf_to_markdown(&doc).unwrap();
18605 let rt = markdown_to_adf(&md).unwrap();
18606
18607 assert_eq!(rt.content.len(), 1, "Should be one top-level block");
18608 let list = &rt.content[0];
18609 assert_eq!(list.node_type, "bulletList");
18610 let items = list.content.as_ref().unwrap();
18611 assert_eq!(items.len(), 1);
18612 let item_content = items[0].content.as_ref().unwrap();
18613 assert_eq!(
18614 item_content.len(),
18615 2,
18616 "Expected 2 paragraphs inside the list item, got {}",
18617 item_content.len()
18618 );
18619 assert_eq!(item_content[0].node_type, "paragraph");
18620 assert_eq!(item_content[1].node_type, "paragraph");
18621 let text = item_content[1].content.as_ref().unwrap()[0]
18622 .text
18623 .as_deref()
18624 .unwrap();
18625 assert_eq!(text, "2. Honouring existing commitments");
18626 }
18627
18628 #[test]
18629 fn multi_paragraph_list_item_with_bullet_marker_roundtrips() {
18630 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18632 {"type":"listItem","content":[
18633 {"type":"paragraph","content":[{"type":"text","text":"preamble"}]},
18634 {"type":"paragraph","content":[{"type":"text","text":"- not a sub-item"}]}
18635 ]}
18636 ]}]}"#;
18637 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18638 let md = adf_to_markdown(&doc).unwrap();
18639 let rt = markdown_to_adf(&md).unwrap();
18640
18641 let items = rt.content[0].content.as_ref().unwrap();
18642 assert_eq!(items.len(), 1);
18643 let item_content = items[0].content.as_ref().unwrap();
18644 assert_eq!(item_content.len(), 2);
18645 assert_eq!(item_content[1].node_type, "paragraph");
18646 let text = item_content[1].content.as_ref().unwrap()[0]
18647 .text
18648 .as_deref()
18649 .unwrap();
18650 assert_eq!(text, "- not a sub-item");
18651 }
18652
18653 #[test]
18654 fn backslash_escape_in_inline_text() {
18655 let nodes = parse_inline(r"2\. text");
18657 assert_eq!(nodes.len(), 1, "Should be one text node");
18658 assert_eq!(nodes[0].text.as_deref().unwrap(), "2. text");
18659 }
18660
18661 #[test]
18662 fn escape_list_marker_ordered() {
18663 assert_eq!(escape_list_marker("2. text"), r"2\. text");
18664 assert_eq!(escape_list_marker("10. tenth"), r"10\. tenth");
18665 }
18666
18667 #[test]
18668 fn escape_list_marker_bullet() {
18669 assert_eq!(escape_list_marker("- text"), r"\- text");
18670 assert_eq!(escape_list_marker("* text"), r"\* text");
18671 assert_eq!(escape_list_marker("+ text"), r"\+ text");
18672 }
18673
18674 #[test]
18675 fn escape_list_marker_plain() {
18676 assert_eq!(escape_list_marker("plain text"), "plain text");
18677 assert_eq!(escape_list_marker("no. marker"), "no. marker");
18678 }
18679
18680 #[test]
18681 fn escape_emoji_shortcodes_basic() {
18682 assert_eq!(escape_emoji_shortcodes(":fire:"), r"\:fire:");
18683 assert_eq!(
18684 escape_emoji_shortcodes("hello :wave: world"),
18685 r"hello \:wave: world"
18686 );
18687 }
18688
18689 #[test]
18690 fn escape_emoji_shortcodes_double_colon() {
18691 assert_eq!(
18693 escape_emoji_shortcodes("Status::Active::Running"),
18694 r"Status:\:Active::Running"
18695 );
18696 }
18697
18698 #[test]
18699 fn escape_emoji_shortcodes_no_match() {
18700 assert_eq!(escape_emoji_shortcodes("Time is 10:30"), "Time is 10:30");
18702 assert_eq!(escape_emoji_shortcodes("no colons here"), "no colons here");
18703 assert_eq!(escape_emoji_shortcodes("trailing:"), "trailing:");
18704 assert_eq!(escape_emoji_shortcodes(":"), ":");
18705 }
18706
18707 #[test]
18708 fn escape_emoji_shortcodes_mixed() {
18709 assert_eq!(
18710 escape_emoji_shortcodes("Alert :fire: on pod:pod42"),
18711 r"Alert \:fire: on pod:pod42"
18712 );
18713 }
18714
18715 #[test]
18716 fn escape_emoji_shortcodes_unicode() {
18717 assert_eq!(escape_emoji_shortcodes(":Café:"), r"\:Café:");
18722 assert_eq!(escape_emoji_shortcodes(":über:"), r"\:über:");
18723 assert_eq!(escape_emoji_shortcodes(":配置:"), r"\:配置:");
18724 assert_eq!(
18725 escape_emoji_shortcodes("ZBC::配置::Production"),
18726 r"ZBC:\:配置::Production"
18727 );
18728 }
18729
18730 #[test]
18731 fn escape_emoji_shortcodes_mixed_script_name() {
18732 assert_eq!(escape_emoji_shortcodes(":abc配置:"), r"\:abc配置:");
18735 assert_eq!(escape_emoji_shortcodes(":配置abc:"), r"\:配置abc:");
18736 }
18737
18738 #[test]
18739 fn escape_emoji_shortcodes_unicode_followed_by_non_colon() {
18740 assert_eq!(escape_emoji_shortcodes(":Café world:"), ":Café world:");
18745 }
18746
18747 #[test]
18748 fn escape_emoji_shortcodes_name_runs_to_end() {
18749 assert_eq!(escape_emoji_shortcodes(":abc"), ":abc");
18755 assert_eq!(escape_emoji_shortcodes(":配置"), ":配置");
18756 }
18757
18758 #[test]
18759 fn unicode_shortcode_pattern_text_round_trips_as_text() {
18760 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18763 {"type":"text","text":"Visit :Café: today"}
18764 ]}]}"#;
18765 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18766
18767 let md = adf_to_markdown(&doc).unwrap();
18768 let round_tripped = markdown_to_adf(&md).unwrap();
18769 let content = round_tripped.content[0].content.as_ref().unwrap();
18770
18771 assert_eq!(
18772 content.len(),
18773 1,
18774 "should be a single text node, got: {content:?}"
18775 );
18776 assert_eq!(content[0].node_type, "text");
18777 assert_eq!(content[0].text.as_deref().unwrap(), "Visit :Café: today");
18778 }
18779
18780 #[test]
18781 fn unicode_double_colon_pattern_text_round_trips() {
18782 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18784 {"type":"text","text":"Use ZBC::配置::Production for prod"}
18785 ]}]}"#;
18786 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18787
18788 let md = adf_to_markdown(&doc).unwrap();
18789 let round_tripped = markdown_to_adf(&md).unwrap();
18790 let content = round_tripped.content[0].content.as_ref().unwrap();
18791
18792 assert_eq!(
18793 content.len(),
18794 1,
18795 "should be a single text node, got: {content:?}"
18796 );
18797 assert_eq!(
18798 content[0].text.as_deref().unwrap(),
18799 "Use ZBC::配置::Production for prod"
18800 );
18801 }
18802
18803 #[test]
18804 fn merge_adjacent_text_nodes() {
18805 let mut nodes = vec![AdfNode::text("a"), AdfNode::text("b"), AdfNode::text("c")];
18806 merge_adjacent_text(&mut nodes);
18807 assert_eq!(nodes.len(), 1);
18808 assert_eq!(nodes[0].text.as_deref().unwrap(), "abc");
18809 }
18810
18811 #[test]
18814 fn issue_455_paragraph_hardbreak_ordered_marker_roundtrips() {
18815 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18818 {"type":"text","text":"Introduction: "},
18819 {"type":"hardBreak"},
18820 {"type":"text","text":"1. This text follows a hardBreak"}
18821 ]}]}"#;
18822 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18823 let md = adf_to_markdown(&doc).unwrap();
18824 let rt = markdown_to_adf(&md).unwrap();
18825
18826 assert_eq!(rt.content.len(), 1, "Should remain one block");
18827 assert_eq!(rt.content[0].node_type, "paragraph");
18828 let inlines = rt.content[0].content.as_ref().unwrap();
18829 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18830 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18831 assert_eq!(
18832 inlines[2].text.as_deref(),
18833 Some("1. This text follows a hardBreak")
18834 );
18835 }
18836
18837 #[test]
18838 fn issue_455_paragraph_hardbreak_bullet_marker_roundtrips() {
18839 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18841 {"type":"text","text":"Intro"},
18842 {"type":"hardBreak"},
18843 {"type":"text","text":"- not a list item"}
18844 ]}]}"#;
18845 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18846 let md = adf_to_markdown(&doc).unwrap();
18847 let rt = markdown_to_adf(&md).unwrap();
18848
18849 assert_eq!(rt.content.len(), 1);
18850 assert_eq!(rt.content[0].node_type, "paragraph");
18851 let inlines = rt.content[0].content.as_ref().unwrap();
18852 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18853 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18854 assert_eq!(inlines[2].text.as_deref(), Some("- not a list item"));
18855 }
18856
18857 #[test]
18858 fn issue_455_paragraph_hardbreak_heading_marker_roundtrips() {
18859 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18861 {"type":"text","text":"Intro"},
18862 {"type":"hardBreak"},
18863 {"type":"text","text":"# not a heading"}
18864 ]}]}"##;
18865 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18866 let md = adf_to_markdown(&doc).unwrap();
18867 let rt = markdown_to_adf(&md).unwrap();
18868
18869 assert_eq!(rt.content.len(), 1);
18870 assert_eq!(rt.content[0].node_type, "paragraph");
18871 let inlines = rt.content[0].content.as_ref().unwrap();
18872 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18873 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18874 assert_eq!(inlines[2].text.as_deref(), Some("# not a heading"));
18875 }
18876
18877 #[test]
18878 fn issue_455_paragraph_hardbreak_blockquote_marker_roundtrips() {
18879 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18881 {"type":"text","text":"Intro"},
18882 {"type":"hardBreak"},
18883 {"type":"text","text":"> not a blockquote"}
18884 ]}]}"#;
18885 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18886 let md = adf_to_markdown(&doc).unwrap();
18887 let rt = markdown_to_adf(&md).unwrap();
18888
18889 assert_eq!(rt.content.len(), 1);
18890 assert_eq!(rt.content[0].node_type, "paragraph");
18891 let inlines = rt.content[0].content.as_ref().unwrap();
18892 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18893 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18894 assert_eq!(inlines[2].text.as_deref(), Some("> not a blockquote"));
18895 }
18896
18897 #[test]
18898 fn issue_455_paragraph_multiple_hardbreaks_with_ordered_markers() {
18899 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18901 {"type":"text","text":"Preamble"},
18902 {"type":"hardBreak"},
18903 {"type":"text","text":"1. First"},
18904 {"type":"hardBreak"},
18905 {"type":"text","text":"2. Second"},
18906 {"type":"hardBreak"},
18907 {"type":"text","text":"3. Third"}
18908 ]}]}"#;
18909 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18910 let md = adf_to_markdown(&doc).unwrap();
18911 let rt = markdown_to_adf(&md).unwrap();
18912
18913 assert_eq!(rt.content.len(), 1);
18914 assert_eq!(rt.content[0].node_type, "paragraph");
18915 let inlines = rt.content[0].content.as_ref().unwrap();
18916 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18917 assert_eq!(
18918 types,
18919 vec![
18920 "text",
18921 "hardBreak",
18922 "text",
18923 "hardBreak",
18924 "text",
18925 "hardBreak",
18926 "text"
18927 ]
18928 );
18929 assert_eq!(inlines[2].text.as_deref(), Some("1. First"));
18930 assert_eq!(inlines[4].text.as_deref(), Some("2. Second"));
18931 assert_eq!(inlines[6].text.as_deref(), Some("3. Third"));
18932 }
18933
18934 #[test]
18935 fn issue_455_paragraph_hardbreak_jfm_indentation() {
18936 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18938 {"type":"text","text":"Intro"},
18939 {"type":"hardBreak"},
18940 {"type":"text","text":"1. continued"}
18941 ]}]}"#;
18942 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18943 let md = adf_to_markdown(&doc).unwrap();
18944 assert!(
18945 md.contains("Intro\\\n 1. continued"),
18946 "Continuation should be 2-space-indented, got: {md:?}"
18947 );
18948 }
18949
18950 #[test]
18951 fn issue_455_paragraph_hardbreak_from_jfm() {
18952 let md = "Intro\\\n 1. This is continuation text\n";
18955 let doc = markdown_to_adf(md).unwrap();
18956
18957 assert_eq!(doc.content.len(), 1);
18958 assert_eq!(doc.content[0].node_type, "paragraph");
18959 let inlines = doc.content[0].content.as_ref().unwrap();
18960 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18961 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18962 assert_eq!(
18963 inlines[2].text.as_deref(),
18964 Some("1. This is continuation text")
18965 );
18966 }
18967
18968 #[test]
18969 fn issue_455_paragraph_starts_with_ordered_marker_and_hardbreak() {
18970 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18974 {"type":"text","text":"1. Starting with a number"},
18975 {"type":"hardBreak"},
18976 {"type":"text","text":"continuation after break"}
18977 ]}]}"#;
18978 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18979 let md = adf_to_markdown(&doc).unwrap();
18980 assert!(
18982 md.contains(r"1\. Starting with a number"),
18983 "First line should have escaped list marker, got: {md:?}"
18984 );
18985 let rt = markdown_to_adf(&md).unwrap();
18986
18987 assert_eq!(rt.content.len(), 1);
18988 assert_eq!(rt.content[0].node_type, "paragraph");
18989 let inlines = rt.content[0].content.as_ref().unwrap();
18990 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18991 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18992 assert_eq!(
18993 inlines[0].text.as_deref(),
18994 Some("1. Starting with a number")
18995 );
18996 assert_eq!(inlines[2].text.as_deref(), Some("continuation after break"));
18997 }
18998
18999 #[test]
19000 fn ordered_marker_paragraph_in_table_cell_roundtrips() {
19001 let adf_json = r#"{"version":1,"type":"doc","content":[{
19004 "type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},
19005 "content":[{"type":"tableRow","content":[{
19006 "type":"tableCell","attrs":{"colspan":1,"rowspan":1},
19007 "content":[{"type":"paragraph","content":[
19008 {"type":"text","text":"2. Honouring existing commitments"}
19009 ]}]
19010 }]}]
19011 }]}"#;
19012 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19013 let md = adf_to_markdown(&doc).unwrap();
19014 let rt = markdown_to_adf(&md).unwrap();
19015
19016 let table = &rt.content[0];
19017 let cell = &table.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0];
19018 let para = &cell.content.as_ref().unwrap()[0];
19019 assert_eq!(para.node_type, "paragraph");
19020 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
19021 assert_eq!(text, "2. Honouring existing commitments");
19022 }
19023
19024 #[test]
19025 fn bullet_marker_paragraph_standalone_roundtrips() {
19026 let adf_json = r#"{"version":1,"type":"doc","content":[
19029 {"type":"paragraph","content":[
19030 {"type":"text","text":"- not a list item"}
19031 ]}
19032 ]}"#;
19033 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19034 let md = adf_to_markdown(&doc).unwrap();
19035 assert!(
19036 md.contains(r"\- not a list item"),
19037 "Should escape the leading dash, got:\n{md}"
19038 );
19039 let rt = markdown_to_adf(&md).unwrap();
19040 assert_eq!(rt.content[0].node_type, "paragraph");
19041 let text = rt.content[0].content.as_ref().unwrap()[0]
19042 .text
19043 .as_deref()
19044 .unwrap();
19045 assert_eq!(text, "- not a list item");
19046 }
19047
19048 #[test]
19049 fn merge_adjacent_text_skips_non_text_nodes() {
19050 let mut nodes = vec![
19053 AdfNode::text("a"),
19054 AdfNode::hard_break(),
19055 AdfNode::text("b"),
19056 ];
19057 merge_adjacent_text(&mut nodes);
19058 assert_eq!(nodes.len(), 3);
19059 }
19060
19061 #[test]
19062 fn star_bullet_paragraph_roundtrips() {
19063 let adf_json = r#"{"version":1,"type":"doc","content":[
19066 {"type":"paragraph","content":[
19067 {"type":"text","text":"* starred"}
19068 ]}
19069 ]}"#;
19070 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19071 let md = adf_to_markdown(&doc).unwrap();
19072 let rt = markdown_to_adf(&md).unwrap();
19073 assert_eq!(rt.content[0].node_type, "paragraph");
19074 assert_eq!(
19075 rt.content[0].content.as_ref().unwrap()[0]
19076 .text
19077 .as_deref()
19078 .unwrap(),
19079 "* starred"
19080 );
19081 }
19082
19083 #[test]
19086 fn issue_388_ordered_list_with_strong_hardbreak_roundtrips() {
19087 let adf_json = r#"{"version":1,"type":"doc","content":[
19090 {"type":"orderedList","attrs":{"order":1},"content":[
19091 {"type":"listItem","content":[
19092 {"type":"paragraph","content":[
19093 {"type":"text","text":"Bold heading","marks":[{"type":"strong"}]},
19094 {"type":"hardBreak"},
19095 {"type":"text","text":"Content after break"}
19096 ]}
19097 ]},
19098 {"type":"listItem","content":[
19099 {"type":"paragraph","content":[
19100 {"type":"text","text":"Second item","marks":[{"type":"strong"}]},
19101 {"type":"hardBreak"},
19102 {"type":"text","text":"More content"}
19103 ]}
19104 ]}
19105 ]}
19106 ]}"#;
19107 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19108 let md = adf_to_markdown(&doc).unwrap();
19109 let rt = markdown_to_adf(&md).unwrap();
19110
19111 assert_eq!(
19113 rt.content.len(),
19114 1,
19115 "Should be 1 block (orderedList), got {}",
19116 rt.content.len()
19117 );
19118 assert_eq!(rt.content[0].node_type, "orderedList");
19119 let items = rt.content[0].content.as_ref().unwrap();
19120 assert_eq!(
19121 items.len(),
19122 2,
19123 "Should have 2 listItems, got {}",
19124 items.len()
19125 );
19126
19127 let p1 = items[0].content.as_ref().unwrap()[0]
19129 .content
19130 .as_ref()
19131 .unwrap();
19132 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19133 assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19134 assert_eq!(p1[0].text.as_deref(), Some("Bold heading"));
19135 assert_eq!(p1[2].text.as_deref(), Some("Content after break"));
19136
19137 let p2 = items[1].content.as_ref().unwrap()[0]
19139 .content
19140 .as_ref()
19141 .unwrap();
19142 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19143 assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19144 assert_eq!(p2[0].text.as_deref(), Some("Second item"));
19145 assert_eq!(p2[2].text.as_deref(), Some("More content"));
19146 }
19147
19148 #[test]
19149 fn issue_388_bullet_list_with_strong_hardbreak_roundtrips() {
19150 let adf_json = r#"{"version":1,"type":"doc","content":[
19152 {"type":"bulletList","content":[
19153 {"type":"listItem","content":[
19154 {"type":"paragraph","content":[
19155 {"type":"text","text":"First","marks":[{"type":"strong"}]},
19156 {"type":"hardBreak"},
19157 {"type":"text","text":"details"}
19158 ]}
19159 ]},
19160 {"type":"listItem","content":[
19161 {"type":"paragraph","content":[
19162 {"type":"text","text":"Second","marks":[{"type":"em"}]},
19163 {"type":"hardBreak"},
19164 {"type":"text","text":"more details"}
19165 ]}
19166 ]}
19167 ]}
19168 ]}"#;
19169 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19170 let md = adf_to_markdown(&doc).unwrap();
19171 let rt = markdown_to_adf(&md).unwrap();
19172
19173 assert_eq!(rt.content.len(), 1);
19174 assert_eq!(rt.content[0].node_type, "bulletList");
19175 let items = rt.content[0].content.as_ref().unwrap();
19176 assert_eq!(items.len(), 2);
19177
19178 let p1 = items[0].content.as_ref().unwrap()[0]
19179 .content
19180 .as_ref()
19181 .unwrap();
19182 assert_eq!(p1[0].text.as_deref(), Some("First"));
19183 assert_eq!(p1[2].text.as_deref(), Some("details"));
19184
19185 let p2 = items[1].content.as_ref().unwrap()[0]
19186 .content
19187 .as_ref()
19188 .unwrap();
19189 assert_eq!(p2[0].text.as_deref(), Some("Second"));
19190 assert_eq!(p2[2].text.as_deref(), Some("more details"));
19191 }
19192
19193 #[test]
19194 fn issue_388_ordered_list_hardbreak_jfm_indentation() {
19195 let adf_json = r#"{"version":1,"type":"doc","content":[
19197 {"type":"orderedList","attrs":{"order":1},"content":[
19198 {"type":"listItem","content":[
19199 {"type":"paragraph","content":[
19200 {"type":"text","text":"heading","marks":[{"type":"strong"}]},
19201 {"type":"hardBreak"},
19202 {"type":"text","text":"body"}
19203 ]}
19204 ]}
19205 ]}
19206 ]}"#;
19207 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19208 let md = adf_to_markdown(&doc).unwrap();
19209 assert!(
19210 md.contains("1. **heading**\\\n body"),
19211 "Continuation should be indented, got:\n{md}"
19212 );
19213 }
19214
19215 #[test]
19216 fn issue_388_ordered_list_hardbreak_from_jfm() {
19217 let md = "1. **bold**\\\n continued\n2. **also bold**\\\n also continued\n";
19219 let doc = markdown_to_adf(md).unwrap();
19220
19221 assert_eq!(doc.content.len(), 1);
19222 assert_eq!(doc.content[0].node_type, "orderedList");
19223 let items = doc.content[0].content.as_ref().unwrap();
19224 assert_eq!(items.len(), 2);
19225
19226 let p1 = items[0].content.as_ref().unwrap()[0]
19227 .content
19228 .as_ref()
19229 .unwrap();
19230 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19231 assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19232 assert_eq!(p1[0].text.as_deref(), Some("bold"));
19233 assert_eq!(p1[2].text.as_deref(), Some("continued"));
19234
19235 let p2 = items[1].content.as_ref().unwrap()[0]
19236 .content
19237 .as_ref()
19238 .unwrap();
19239 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19240 assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19241 }
19242
19243 #[test]
19244 fn issue_388_bullet_list_hardbreak_from_jfm() {
19245 let md = "- first\\\n second\n- third\\\n fourth\n";
19247 let doc = markdown_to_adf(md).unwrap();
19248
19249 assert_eq!(doc.content.len(), 1);
19250 assert_eq!(doc.content[0].node_type, "bulletList");
19251 let items = doc.content[0].content.as_ref().unwrap();
19252 assert_eq!(items.len(), 2);
19253
19254 for (i, expected) in [("first", "second"), ("third", "fourth")]
19255 .iter()
19256 .enumerate()
19257 {
19258 let p = items[i].content.as_ref().unwrap()[0]
19259 .content
19260 .as_ref()
19261 .unwrap();
19262 let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19263 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19264 assert_eq!(p[0].text.as_deref(), Some(expected.0));
19265 assert_eq!(p[2].text.as_deref(), Some(expected.1));
19266 }
19267 }
19268
19269 #[test]
19270 fn issue_433_heading_hardbreak_roundtrips() {
19271 let adf_json = r#"{"version":1,"type":"doc","content":[{
19273 "type":"heading",
19274 "attrs":{"level":1},
19275 "content":[
19276 {"type":"text","text":"Line one"},
19277 {"type":"hardBreak"},
19278 {"type":"text","text":"Line two"}
19279 ]
19280 }]}"#;
19281 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19282 let md = adf_to_markdown(&doc).unwrap();
19283 let rt = markdown_to_adf(&md).unwrap();
19284
19285 assert_eq!(
19286 rt.content.len(),
19287 1,
19288 "Should remain a single heading, got {} blocks",
19289 rt.content.len()
19290 );
19291 assert_eq!(rt.content[0].node_type, "heading");
19292 let inlines = rt.content[0].content.as_ref().unwrap();
19293 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19294 assert_eq!(
19295 types,
19296 vec!["text", "hardBreak", "text"],
19297 "hardBreak should be preserved, got: {types:?}"
19298 );
19299 assert_eq!(inlines[0].text.as_deref(), Some("Line one"));
19300 assert_eq!(inlines[2].text.as_deref(), Some("Line two"));
19301 }
19302
19303 #[test]
19304 fn issue_433_heading_hardbreak_jfm_indentation() {
19305 let adf_json = r#"{"version":1,"type":"doc","content":[{
19307 "type":"heading",
19308 "attrs":{"level":2},
19309 "content":[
19310 {"type":"text","text":"Title"},
19311 {"type":"hardBreak"},
19312 {"type":"text","text":"Subtitle"}
19313 ]
19314 }]}"#;
19315 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19316 let md = adf_to_markdown(&doc).unwrap();
19317 assert!(
19318 md.contains("## Title\\\n Subtitle"),
19319 "Continuation should be indented, got:\n{md}"
19320 );
19321 }
19322
19323 #[test]
19324 fn issue_433_heading_hardbreak_from_jfm() {
19325 let md = "# First\\\n Second\n";
19327 let doc = markdown_to_adf(md).unwrap();
19328
19329 assert_eq!(doc.content.len(), 1);
19330 assert_eq!(doc.content[0].node_type, "heading");
19331 let inlines = doc.content[0].content.as_ref().unwrap();
19332 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19333 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19334 assert_eq!(inlines[0].text.as_deref(), Some("First"));
19335 assert_eq!(inlines[2].text.as_deref(), Some("Second"));
19336 }
19337
19338 #[test]
19339 fn issue_433_heading_consecutive_hardbreaks_roundtrip() {
19340 let adf_json = r#"{"version":1,"type":"doc","content":[{
19342 "type":"heading",
19343 "attrs":{"level":3},
19344 "content":[
19345 {"type":"text","text":"A"},
19346 {"type":"hardBreak"},
19347 {"type":"hardBreak"},
19348 {"type":"text","text":"B"}
19349 ]
19350 }]}"#;
19351 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19352 let md = adf_to_markdown(&doc).unwrap();
19353 let rt = markdown_to_adf(&md).unwrap();
19354
19355 assert_eq!(rt.content.len(), 1, "Should remain a single heading");
19356 assert_eq!(rt.content[0].node_type, "heading");
19357 let inlines = rt.content[0].content.as_ref().unwrap();
19358 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19359 assert_eq!(types, vec!["text", "hardBreak", "hardBreak", "text"]);
19360 }
19361
19362 #[test]
19363 fn issue_433_heading_with_strong_and_hardbreak_roundtrips() {
19364 let adf_json = r#"{"version":1,"type":"doc","content":[{
19366 "type":"heading",
19367 "attrs":{"level":1},
19368 "content":[
19369 {"type":"text","text":"Bold title","marks":[{"type":"strong"}]},
19370 {"type":"hardBreak"},
19371 {"type":"text","text":"plain continuation"}
19372 ]
19373 }]}"#;
19374 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19375 let md = adf_to_markdown(&doc).unwrap();
19376 let rt = markdown_to_adf(&md).unwrap();
19377
19378 assert_eq!(rt.content.len(), 1);
19379 assert_eq!(rt.content[0].node_type, "heading");
19380 let inlines = rt.content[0].content.as_ref().unwrap();
19381 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19382 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19383 assert_eq!(inlines[0].text.as_deref(), Some("Bold title"));
19384 assert_eq!(inlines[2].text.as_deref(), Some("plain continuation"));
19385 }
19386
19387 #[test]
19388 fn issue_433_heading_with_link_and_hardbreak_roundtrips() {
19389 let adf_json = r#"{"version":1,"type":"doc","content":[{
19391 "type":"heading",
19392 "attrs":{"level":1},
19393 "content":[
19394 {"type":"text","text":"Click here","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
19395 {"type":"hardBreak"},
19396 {"type":"text","text":"Subtitle text"}
19397 ]
19398 }]}"#;
19399 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19400 let md = adf_to_markdown(&doc).unwrap();
19401 let rt = markdown_to_adf(&md).unwrap();
19402
19403 assert_eq!(rt.content.len(), 1);
19404 assert_eq!(rt.content[0].node_type, "heading");
19405 let inlines = rt.content[0].content.as_ref().unwrap();
19406 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19407 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19408 assert_eq!(inlines[2].text.as_deref(), Some("Subtitle text"));
19409 }
19410
19411 #[test]
19412 fn has_trailing_hard_break_backslash() {
19413 assert!(has_trailing_hard_break("text\\"));
19414 assert!(has_trailing_hard_break("**bold**\\"));
19415 }
19416
19417 #[test]
19418 fn has_trailing_hard_break_trailing_spaces() {
19419 assert!(has_trailing_hard_break("text "));
19420 assert!(has_trailing_hard_break("word "));
19421 }
19422
19423 #[test]
19424 fn has_trailing_hard_break_false() {
19425 assert!(!has_trailing_hard_break("plain text"));
19426 assert!(!has_trailing_hard_break("text "));
19427 assert!(!has_trailing_hard_break(""));
19428 }
19429
19430 #[test]
19431 fn collect_hardbreak_continuations_collects_indented() {
19432 let input = "first\\\n second\n third\n";
19435 let mut parser = MarkdownParser::new(input);
19436 parser.advance(); let mut text = "first\\".to_string();
19438 parser.collect_hardbreak_continuations(&mut text);
19439 assert_eq!(text, "first\\\nsecond");
19440 }
19441
19442 #[test]
19443 fn collect_hardbreak_continuations_stops_at_non_indented() {
19444 let input = "first\\\nnot indented\n";
19445 let mut parser = MarkdownParser::new(input);
19446 parser.advance();
19447 let mut text = "first\\".to_string();
19448 parser.collect_hardbreak_continuations(&mut text);
19449 assert_eq!(text, "first\\");
19451 }
19452
19453 #[test]
19454 fn collect_hardbreak_continuations_no_trailing_break() {
19455 let input = "plain\n indented\n";
19457 let mut parser = MarkdownParser::new(input);
19458 parser.advance();
19459 let mut text = "plain".to_string();
19460 parser.collect_hardbreak_continuations(&mut text);
19461 assert_eq!(text, "plain");
19462 }
19463
19464 #[test]
19465 fn collect_hardbreak_continuations_chained() {
19466 let input = "a\\\n b\\\n c\\\n d\n";
19468 let mut parser = MarkdownParser::new(input);
19469 parser.advance();
19470 let mut text = "a\\".to_string();
19471 parser.collect_hardbreak_continuations(&mut text);
19472 assert_eq!(text, "a\\\nb\\\nc\\\nd");
19473 }
19474
19475 #[test]
19476 fn collect_hardbreak_continuations_stops_before_image_line() {
19477 let input = "text\\\n {type=file id=x}\n";
19480 let mut parser = MarkdownParser::new(input);
19481 parser.advance(); let mut text = "text\\".to_string();
19483 parser.collect_hardbreak_continuations(&mut text);
19484 assert_eq!(text, "text\\");
19486 assert!(!parser.at_end());
19488 assert!(parser.current_line().contains(""));
19489 }
19490
19491 #[test]
19492 fn is_block_level_continuation_marker_positive_cases() {
19493 assert!(is_block_level_continuation_marker(""));
19495 assert!(is_block_level_continuation_marker("```ruby"));
19496 assert!(is_block_level_continuation_marker(":::panel{type=info}"));
19497 }
19498
19499 #[test]
19500 fn is_block_level_continuation_marker_negative_cases() {
19501 assert!(!is_block_level_continuation_marker("plain text"));
19503 assert!(!is_block_level_continuation_marker("- nested item"));
19504 assert!(!is_block_level_continuation_marker("continuation\\"));
19505 assert!(!is_block_level_continuation_marker(""));
19506 assert!(!is_block_level_continuation_marker("::partial"));
19508 assert!(!is_block_level_continuation_marker("`inline`"));
19510 }
19511
19512 #[test]
19513 fn collect_hardbreak_continuations_stops_before_code_fence() {
19514 let input = "text\\\n ```ruby\n Foo::Bar::Baz\n ```\n";
19518 let mut parser = MarkdownParser::new(input);
19519 parser.advance();
19520 let mut text = "text\\".to_string();
19521 parser.collect_hardbreak_continuations(&mut text);
19522 assert_eq!(text, "text\\");
19523 assert!(!parser.at_end());
19524 assert!(parser.current_line().starts_with(" ```"));
19525 }
19526
19527 #[test]
19528 fn collect_hardbreak_continuations_stops_before_container_directive() {
19529 let input = "text\\\n :::panel{type=info}\n body\n :::\n";
19533 let mut parser = MarkdownParser::new(input);
19534 parser.advance();
19535 let mut text = "text\\".to_string();
19536 parser.collect_hardbreak_continuations(&mut text);
19537 assert_eq!(text, "text\\");
19538 assert!(!parser.at_end());
19539 assert!(parser.current_line().contains(":::panel"));
19540 }
19541
19542 #[test]
19543 fn collect_hardbreak_continuations_stops_before_indented_code_fence() {
19544 let input = "text\\\n ```text\n :fire:\n ```\n";
19548 let mut parser = MarkdownParser::new(input);
19549 parser.advance();
19550 let mut text = "text\\".to_string();
19551 parser.collect_hardbreak_continuations(&mut text);
19552 assert_eq!(text, "text\\");
19553 assert!(!parser.at_end());
19554 assert!(parser.current_line().contains("```text"));
19555 }
19556
19557 #[test]
19558 fn ordered_list_with_sub_content_after_hardbreak() {
19559 let adf_json = r#"{"version":1,"type":"doc","content":[
19562 {"type":"orderedList","attrs":{"order":1},"content":[
19563 {"type":"listItem","content":[
19564 {"type":"paragraph","content":[
19565 {"type":"text","text":"parent"},
19566 {"type":"hardBreak"},
19567 {"type":"text","text":"continued"}
19568 ]},
19569 {"type":"bulletList","content":[
19570 {"type":"listItem","content":[
19571 {"type":"paragraph","content":[
19572 {"type":"text","text":"child"}
19573 ]}
19574 ]}
19575 ]}
19576 ]}
19577 ]}
19578 ]}"#;
19579 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19580 let md = adf_to_markdown(&doc).unwrap();
19581 let rt = markdown_to_adf(&md).unwrap();
19582
19583 assert_eq!(rt.content.len(), 1);
19584 assert_eq!(rt.content[0].node_type, "orderedList");
19585 let item_content = rt.content[0].content.as_ref().unwrap()[0]
19586 .content
19587 .as_ref()
19588 .unwrap();
19589 let p = item_content[0].content.as_ref().unwrap();
19591 let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19592 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19593 assert_eq!(p[0].text.as_deref(), Some("parent"));
19594 assert_eq!(p[2].text.as_deref(), Some("continued"));
19595 assert_eq!(item_content[1].node_type, "bulletList");
19597 }
19598
19599 #[test]
19600 fn render_list_item_content_no_content() {
19601 let item = AdfNode {
19603 node_type: "listItem".to_string(),
19604 attrs: None,
19605 content: None,
19606 text: None,
19607 marks: None,
19608 local_id: None,
19609 parameters: None,
19610 };
19611 let mut output = String::new();
19612 let opts = RenderOptions::default();
19613 render_list_item_content(&item, &mut output, &opts);
19614 assert_eq!(output, "\n");
19615 }
19616
19617 #[test]
19618 fn render_list_item_content_empty_content() {
19619 let item = AdfNode::list_item(vec![]);
19621 let mut output = String::new();
19622 let opts = RenderOptions::default();
19623 render_list_item_content(&item, &mut output, &opts);
19624 assert_eq!(output, "\n");
19625 }
19626
19627 #[test]
19628 fn plus_bullet_paragraph_roundtrips() {
19629 let adf_json = r#"{"version":1,"type":"doc","content":[
19632 {"type":"paragraph","content":[
19633 {"type":"text","text":"+ plus"}
19634 ]}
19635 ]}"#;
19636 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19637 let md = adf_to_markdown(&doc).unwrap();
19638 let rt = markdown_to_adf(&md).unwrap();
19639 assert_eq!(rt.content[0].node_type, "paragraph");
19640 assert_eq!(
19641 rt.content[0].content.as_ref().unwrap()[0]
19642 .text
19643 .as_deref()
19644 .unwrap(),
19645 "+ plus"
19646 );
19647 }
19648
19649 #[test]
19652 fn issue_430_file_media_in_bullet_list_roundtrip() {
19653 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19656 {"type":"listItem","content":[{
19657 "type":"mediaSingle",
19658 "attrs":{"layout":"center","width":1009,"widthType":"pixel"},
19659 "content":[{
19660 "type":"media",
19661 "attrs":{"collection":"contentId-123","height":576,"id":"00066e8e-554e-4d7e-af59-a0ef2888bdb6","type":"file","width":1009}
19662 }]
19663 }]}
19664 ]}]}"#;
19665 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19666 let md = adf_to_markdown(&doc).unwrap();
19667 let rt = markdown_to_adf(&md).unwrap();
19668
19669 let list = &rt.content[0];
19670 assert_eq!(list.node_type, "bulletList");
19671 let item = &list.content.as_ref().unwrap()[0];
19672 assert_eq!(item.node_type, "listItem");
19673 let ms = &item.content.as_ref().unwrap()[0];
19674 assert_eq!(ms.node_type, "mediaSingle");
19675 let ms_attrs = ms.attrs.as_ref().unwrap();
19676 assert_eq!(ms_attrs["layout"], "center");
19677 assert_eq!(ms_attrs["width"], 1009);
19678 assert_eq!(ms_attrs["widthType"], "pixel");
19679 let media = &ms.content.as_ref().unwrap()[0];
19680 assert_eq!(media.node_type, "media");
19681 let m_attrs = media.attrs.as_ref().unwrap();
19682 assert_eq!(m_attrs["type"], "file");
19683 assert_eq!(m_attrs["id"], "00066e8e-554e-4d7e-af59-a0ef2888bdb6");
19684 assert_eq!(m_attrs["collection"], "contentId-123");
19685 assert_eq!(m_attrs["height"], 576);
19686 assert_eq!(m_attrs["width"], 1009);
19687 }
19688
19689 #[test]
19690 fn issue_430_file_media_in_ordered_list_roundtrip() {
19691 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19693 {"type":"listItem","content":[{
19694 "type":"mediaSingle",
19695 "attrs":{"layout":"center"},
19696 "content":[{
19697 "type":"media",
19698 "attrs":{"type":"file","id":"abc-123","collection":"contentId-456","height":100,"width":200}
19699 }]
19700 }]}
19701 ]}]}"#;
19702 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19703 let md = adf_to_markdown(&doc).unwrap();
19704 let rt = markdown_to_adf(&md).unwrap();
19705
19706 let list = &rt.content[0];
19707 assert_eq!(list.node_type, "orderedList");
19708 let item = &list.content.as_ref().unwrap()[0];
19709 assert_eq!(item.node_type, "listItem");
19710 let ms = &item.content.as_ref().unwrap()[0];
19711 assert_eq!(ms.node_type, "mediaSingle");
19712 let media = &ms.content.as_ref().unwrap()[0];
19713 assert_eq!(media.node_type, "media");
19714 let m_attrs = media.attrs.as_ref().unwrap();
19715 assert_eq!(m_attrs["type"], "file");
19716 assert_eq!(m_attrs["id"], "abc-123");
19717 assert_eq!(m_attrs["collection"], "contentId-456");
19718 }
19719
19720 #[test]
19721 fn issue_430_external_media_in_bullet_list_roundtrip() {
19722 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19724 {"type":"listItem","content":[{
19725 "type":"mediaSingle",
19726 "attrs":{"layout":"center"},
19727 "content":[{
19728 "type":"media",
19729 "attrs":{"type":"external","url":"https://example.com/img.png","alt":"Photo"}
19730 }]
19731 }]}
19732 ]}]}"#;
19733 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19734 let md = adf_to_markdown(&doc).unwrap();
19735 let rt = markdown_to_adf(&md).unwrap();
19736
19737 let list = &rt.content[0];
19738 assert_eq!(list.node_type, "bulletList");
19739 let item = &list.content.as_ref().unwrap()[0];
19740 let ms = &item.content.as_ref().unwrap()[0];
19741 assert_eq!(ms.node_type, "mediaSingle");
19742 let media = &ms.content.as_ref().unwrap()[0];
19743 assert_eq!(media.node_type, "media");
19744 let m_attrs = media.attrs.as_ref().unwrap();
19745 assert_eq!(m_attrs["type"], "external");
19746 assert_eq!(m_attrs["url"], "https://example.com/img.png");
19747 }
19748
19749 #[test]
19750 fn issue_430_media_with_paragraph_siblings_in_list_item() {
19751 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19754 {"type":"listItem","content":[
19755 {"type":"paragraph","content":[{"type":"text","text":"Caption:"}]},
19756 {"type":"mediaSingle","attrs":{"layout":"center"},
19757 "content":[{"type":"media","attrs":{"type":"file","id":"img-001","collection":"col-1","height":50,"width":100}}]}
19758 ]}
19759 ]}]}"#;
19760 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19761 let md = adf_to_markdown(&doc).unwrap();
19762 let rt = markdown_to_adf(&md).unwrap();
19763
19764 let item = &rt.content[0].content.as_ref().unwrap()[0];
19765 let children = item.content.as_ref().unwrap();
19766 assert_eq!(children.len(), 2, "expected 2 children in listItem");
19767 assert_eq!(children[0].node_type, "paragraph");
19768 assert_eq!(children[1].node_type, "mediaSingle");
19769 let media = &children[1].content.as_ref().unwrap()[0];
19770 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-001");
19771 }
19772
19773 #[test]
19774 fn issue_430_multiple_media_in_list_items() {
19775 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19777 {"type":"listItem","content":[{
19778 "type":"mediaSingle","attrs":{"layout":"center"},
19779 "content":[{"type":"media","attrs":{"type":"file","id":"img-a","collection":"c1","height":10,"width":20}}]
19780 }]},
19781 {"type":"listItem","content":[{
19782 "type":"mediaSingle","attrs":{"layout":"center"},
19783 "content":[{"type":"media","attrs":{"type":"file","id":"img-b","collection":"c2","height":30,"width":40}}]
19784 }]}
19785 ]}]}"#;
19786 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19787 let md = adf_to_markdown(&doc).unwrap();
19788 let rt = markdown_to_adf(&md).unwrap();
19789
19790 let items = rt.content[0].content.as_ref().unwrap();
19791 assert_eq!(items.len(), 2);
19792 for (i, expected_id) in [("img-a", "c1"), ("img-b", "c2")].iter().enumerate() {
19793 let ms = &items[i].content.as_ref().unwrap()[0];
19794 assert_eq!(ms.node_type, "mediaSingle");
19795 let m_attrs = ms.content.as_ref().unwrap()[0].attrs.as_ref().unwrap();
19796 assert_eq!(m_attrs["id"], expected_id.0);
19797 assert_eq!(m_attrs["collection"], expected_id.1);
19798 }
19799 }
19800
19801 #[test]
19802 fn issue_430_jfm_to_adf_media_in_bullet_item() {
19803 let md = "- ![](){type=file id=test-id collection=col-1 height=100 width=200}\n";
19806 let doc = markdown_to_adf(md).unwrap();
19807
19808 let list = &doc.content[0];
19809 assert_eq!(list.node_type, "bulletList");
19810 let item = &list.content.as_ref().unwrap()[0];
19811 let ms = &item.content.as_ref().unwrap()[0];
19812 assert_eq!(
19813 ms.node_type, "mediaSingle",
19814 "expected mediaSingle, got {}",
19815 ms.node_type
19816 );
19817 let media = &ms.content.as_ref().unwrap()[0];
19818 assert_eq!(media.node_type, "media");
19819 let m_attrs = media.attrs.as_ref().unwrap();
19820 assert_eq!(m_attrs["type"], "file");
19821 assert_eq!(m_attrs["id"], "test-id");
19822 }
19823
19824 #[test]
19825 fn issue_430_jfm_to_adf_media_in_ordered_item() {
19826 let md = "1. \n";
19828 let doc = markdown_to_adf(md).unwrap();
19829
19830 let list = &doc.content[0];
19831 assert_eq!(list.node_type, "orderedList");
19832 let item = &list.content.as_ref().unwrap()[0];
19833 let ms = &item.content.as_ref().unwrap()[0];
19834 assert_eq!(
19835 ms.node_type, "mediaSingle",
19836 "expected mediaSingle, got {}",
19837 ms.node_type
19838 );
19839 }
19840
19841 #[test]
19842 fn issue_430_media_then_paragraph_in_bullet_list_roundtrip() {
19843 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19846 {"type":"listItem","content":[
19847 {"type":"mediaSingle","attrs":{"layout":"center"},
19848 "content":[{"type":"media","attrs":{"type":"file","id":"img-first","collection":"col-1","height":50,"width":100}}]},
19849 {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
19850 ]}
19851 ]}]}"#;
19852 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19853 let md = adf_to_markdown(&doc).unwrap();
19854 let rt = markdown_to_adf(&md).unwrap();
19855
19856 let item = &rt.content[0].content.as_ref().unwrap()[0];
19857 let children = item.content.as_ref().unwrap();
19858 assert_eq!(children.len(), 2, "expected 2 children in listItem");
19859 assert_eq!(children[0].node_type, "mediaSingle");
19860 let media = &children[0].content.as_ref().unwrap()[0];
19861 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-first");
19862 assert_eq!(children[1].node_type, "paragraph");
19863 }
19864
19865 #[test]
19866 fn issue_430_media_then_paragraph_in_ordered_list_roundtrip() {
19867 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19869 {"type":"listItem","content":[
19870 {"type":"mediaSingle","attrs":{"layout":"center"},
19871 "content":[{"type":"media","attrs":{"type":"file","id":"img-ord","collection":"col-2","height":60,"width":120}}]},
19872 {"type":"paragraph","content":[{"type":"text","text":"Description"}]}
19873 ]}
19874 ]}]}"#;
19875 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19876 let md = adf_to_markdown(&doc).unwrap();
19877 let rt = markdown_to_adf(&md).unwrap();
19878
19879 let item = &rt.content[0].content.as_ref().unwrap()[0];
19880 let children = item.content.as_ref().unwrap();
19881 assert_eq!(children.len(), 2, "expected 2 children in listItem");
19882 assert_eq!(children[0].node_type, "mediaSingle");
19883 assert_eq!(children[1].node_type, "paragraph");
19884 }
19885
19886 #[test]
19887 fn issue_430_external_media_with_width_type_roundtrip() {
19888 let adf_json = r#"{"version":1,"type":"doc","content":[{
19890 "type":"mediaSingle",
19891 "attrs":{"layout":"wide","width":800,"widthType":"pixel"},
19892 "content":[{
19893 "type":"media",
19894 "attrs":{"type":"external","url":"https://example.com/photo.png","alt":"wide photo"}
19895 }]
19896 }]}"#;
19897 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19898 let md = adf_to_markdown(&doc).unwrap();
19899 assert!(
19900 md.contains("widthType=pixel"),
19901 "expected widthType=pixel in markdown, got: {md}"
19902 );
19903 let rt = markdown_to_adf(&md).unwrap();
19904 let ms = &rt.content[0];
19905 assert_eq!(ms.node_type, "mediaSingle");
19906 let ms_attrs = ms.attrs.as_ref().unwrap();
19907 assert_eq!(ms_attrs["widthType"], "pixel");
19908 assert_eq!(ms_attrs["width"], 800);
19909 assert_eq!(ms_attrs["layout"], "wide");
19910 }
19911
19912 #[test]
19915 fn issue_490_paragraph_with_hardbreak_then_media_single_roundtrip() {
19916 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19919 {"type":"listItem","content":[
19920 {"type":"paragraph","content":[
19921 {"type":"text","text":"Item with image:"},
19922 {"type":"hardBreak"}
19923 ]},
19924 {"type":"mediaSingle","attrs":{"layout":"center","width":400,"widthType":"pixel"},
19925 "content":[{"type":"media","attrs":{
19926 "id":"aabbccdd-1234-5678-abcd-aabbccdd1234",
19927 "type":"file",
19928 "collection":"contentId-123456",
19929 "width":800,
19930 "height":600
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, "expected 2 children in listItem");
19941 assert_eq!(children[0].node_type, "paragraph");
19942 assert_eq!(
19943 children[1].node_type, "mediaSingle",
19944 "expected mediaSingle, got {:?}",
19945 children[1].node_type
19946 );
19947 let media = &children[1].content.as_ref().unwrap()[0];
19948 let m_attrs = media.attrs.as_ref().unwrap();
19949 assert_eq!(m_attrs["id"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
19950 assert_eq!(m_attrs["collection"], "contentId-123456");
19951 assert_eq!(m_attrs["height"], 600);
19952 assert_eq!(m_attrs["width"], 800);
19953 }
19954
19955 #[test]
19956 fn issue_490_paragraph_with_hardbreak_then_media_single_ordered_list() {
19957 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19959 {"type":"listItem","content":[
19960 {"type":"paragraph","content":[
19961 {"type":"text","text":"Step with screenshot:"},
19962 {"type":"hardBreak"}
19963 ]},
19964 {"type":"mediaSingle","attrs":{"layout":"center"},
19965 "content":[{"type":"media","attrs":{
19966 "id":"ord-media-id","type":"file","collection":"col-ord","width":640,"height":480
19967 }}]}
19968 ]}
19969 ]}]}"#;
19970 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19971 let md = adf_to_markdown(&doc).unwrap();
19972 let rt = markdown_to_adf(&md).unwrap();
19973
19974 let item = &rt.content[0].content.as_ref().unwrap()[0];
19975 let children = item.content.as_ref().unwrap();
19976 assert_eq!(children.len(), 2, "expected 2 children in listItem");
19977 assert_eq!(children[0].node_type, "paragraph");
19978 assert_eq!(children[1].node_type, "mediaSingle");
19979 let media = &children[1].content.as_ref().unwrap()[0];
19980 assert_eq!(media.attrs.as_ref().unwrap()["id"], "ord-media-id");
19981 }
19982
19983 #[test]
19984 fn issue_490_hardbreak_continuation_does_not_swallow_media_line() {
19985 let md = "- Item with image:\\\n ![](){type=file id=test-490 collection=col height=100 width=200}\n";
19988 let doc = markdown_to_adf(md).unwrap();
19989
19990 let item = &doc.content[0].content.as_ref().unwrap()[0];
19991 let children = item.content.as_ref().unwrap();
19992 assert_eq!(children.len(), 2, "expected 2 children in listItem");
19993 assert_eq!(children[0].node_type, "paragraph");
19994 assert_eq!(
19995 children[1].node_type, "mediaSingle",
19996 "expected mediaSingle as second child, got {:?}",
19997 children[1].node_type
19998 );
19999 let media = &children[1].content.as_ref().unwrap()[0];
20000 assert_eq!(media.attrs.as_ref().unwrap()["id"], "test-490");
20001 }
20002
20003 #[test]
20004 fn issue_490_hardbreak_continuation_still_works_for_text() {
20005 let md = "- first line\\\n second line\n";
20007 let doc = markdown_to_adf(md).unwrap();
20008
20009 let item = &doc.content[0].content.as_ref().unwrap()[0];
20010 let children = item.content.as_ref().unwrap();
20011 assert_eq!(
20012 children.len(),
20013 1,
20014 "expected 1 child (paragraph) in listItem"
20015 );
20016 assert_eq!(children[0].node_type, "paragraph");
20017 let inlines = children[0].content.as_ref().unwrap();
20018 assert_eq!(inlines.len(), 3);
20020 assert_eq!(inlines[0].node_type, "text");
20021 assert_eq!(inlines[1].node_type, "hardBreak");
20022 assert_eq!(inlines[2].node_type, "text");
20023 }
20024
20025 #[test]
20026 fn issue_490_external_media_after_hardbreak_roundtrip() {
20027 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20029 {"type":"listItem","content":[
20030 {"type":"paragraph","content":[
20031 {"type":"text","text":"See image:"},
20032 {"type":"hardBreak"}
20033 ]},
20034 {"type":"mediaSingle","attrs":{"layout":"center"},
20035 "content":[{"type":"media","attrs":{
20036 "type":"external","url":"https://example.com/photo.png","alt":"photo"
20037 }}]}
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 children = item.content.as_ref().unwrap();
20046 assert_eq!(children.len(), 2);
20047 assert_eq!(children[0].node_type, "paragraph");
20048 assert_eq!(children[1].node_type, "mediaSingle");
20049 let media = &children[1].content.as_ref().unwrap()[0];
20050 let m_attrs = media.attrs.as_ref().unwrap();
20051 assert_eq!(m_attrs["url"], "https://example.com/photo.png");
20052 }
20053
20054 #[test]
20055 fn issue_490_multiple_hardbreaks_then_media_single() {
20056 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20058 {"type":"listItem","content":[
20059 {"type":"paragraph","content":[
20060 {"type":"text","text":"line one"},
20061 {"type":"hardBreak"},
20062 {"type":"text","text":"line two"},
20063 {"type":"hardBreak"}
20064 ]},
20065 {"type":"mediaSingle","attrs":{"layout":"center"},
20066 "content":[{"type":"media","attrs":{
20067 "type":"file","id":"multi-hb","collection":"col-m","width":320,"height":240
20068 }}]}
20069 ]}
20070 ]}]}"#;
20071 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20072 let md = adf_to_markdown(&doc).unwrap();
20073 let rt = markdown_to_adf(&md).unwrap();
20074
20075 let item = &rt.content[0].content.as_ref().unwrap()[0];
20076 let children = item.content.as_ref().unwrap();
20077 assert_eq!(children.len(), 2, "expected paragraph + mediaSingle");
20078 assert_eq!(children[0].node_type, "paragraph");
20079 assert_eq!(children[1].node_type, "mediaSingle");
20080 let media = &children[1].content.as_ref().unwrap()[0];
20081 assert_eq!(media.attrs.as_ref().unwrap()["id"], "multi-hb");
20082 }
20083
20084 #[test]
20087 fn issue_525_listitem_localid_with_mediasingle_roundtrip() {
20088 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"}]}]}]}]}]}]}"#;
20091 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20092 let md = adf_to_markdown(&doc).unwrap();
20093 let rt = markdown_to_adf(&md).unwrap();
20094
20095 let list = &rt.content[0];
20096 assert_eq!(list.node_type, "bulletList");
20097 let item = &list.content.as_ref().unwrap()[0];
20098 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20100 assert_eq!(
20101 item_attrs["localId"], "aabbccdd-1234-5678-abcd-000000000001",
20102 "listItem localId must survive round-trip"
20103 );
20104 let children = item.content.as_ref().unwrap();
20105 assert_eq!(
20106 children.len(),
20107 3,
20108 "expected mediaSingle + paragraph + bulletList"
20109 );
20110 assert_eq!(children[0].node_type, "mediaSingle");
20111 assert_eq!(children[1].node_type, "paragraph");
20112 assert_eq!(children[2].node_type, "bulletList");
20113 }
20114
20115 #[test]
20116 fn issue_525_listitem_localid_with_mediasingle_only() {
20117 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20119 {"type":"listItem","attrs":{"localId":"li-media-only"},"content":[
20120 {"type":"mediaSingle","attrs":{"layout":"center"},
20121 "content":[{"type":"media","attrs":{"type":"file","id":"m-001","collection":"c1","height":50,"width":100}}]}
20122 ]}
20123 ]}]}"#;
20124 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20125 let md = adf_to_markdown(&doc).unwrap();
20126 let rt = markdown_to_adf(&md).unwrap();
20127
20128 let item = &rt.content[0].content.as_ref().unwrap()[0];
20129 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20130 assert_eq!(
20131 item_attrs["localId"], "li-media-only",
20132 "listItem localId must survive when sole child is mediaSingle"
20133 );
20134 assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20135 }
20136
20137 #[test]
20138 fn issue_525_listitem_localid_with_external_media() {
20139 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20141 {"type":"listItem","attrs":{"localId":"li-ext-media"},"content":[
20142 {"type":"mediaSingle","attrs":{"layout":"center"},
20143 "content":[{"type":"media","attrs":{"type":"external","url":"https://example.com/img.png","alt":"photo"}}]}
20144 ]}
20145 ]}]}"#;
20146 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20147 let md = adf_to_markdown(&doc).unwrap();
20148 let rt = markdown_to_adf(&md).unwrap();
20149
20150 let item = &rt.content[0].content.as_ref().unwrap()[0];
20151 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20152 assert_eq!(
20153 item_attrs["localId"], "li-ext-media",
20154 "listItem localId must survive with external mediaSingle"
20155 );
20156 }
20157
20158 #[test]
20159 fn issue_525_listitem_localid_with_mediasingle_in_ordered_list() {
20160 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20162 {"type":"listItem","attrs":{"localId":"li-ord-media"},"content":[
20163 {"type":"mediaSingle","attrs":{"layout":"center","width":200,"widthType":"pixel"},
20164 "content":[{"type":"media","attrs":{"type":"file","id":"ord-m-001","collection":"col-ord","height":80,"width":160}}]},
20165 {"type":"paragraph","content":[{"type":"text","text":"ordered item text"}]}
20166 ]}
20167 ]}]}"#;
20168 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20169 let md = adf_to_markdown(&doc).unwrap();
20170 let rt = markdown_to_adf(&md).unwrap();
20171
20172 let item = &rt.content[0].content.as_ref().unwrap()[0];
20173 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20174 assert_eq!(
20175 item_attrs["localId"], "li-ord-media",
20176 "listItem localId must survive in ordered list with mediaSingle"
20177 );
20178 let children = item.content.as_ref().unwrap();
20179 assert_eq!(children[0].node_type, "mediaSingle");
20180 assert_eq!(children[1].node_type, "paragraph");
20181 }
20182
20183 #[test]
20184 fn issue_525_jfm_localid_on_mediasingle_line_parses_correctly() {
20185 let md = "- ![](){type=file id=test-525 collection=col height=100 width=200 mediaWidth=100 widthType=pixel} {localId=li-jfm-525}\n";
20188 let doc = markdown_to_adf(md).unwrap();
20189
20190 let item = &doc.content[0].content.as_ref().unwrap()[0];
20191 let item_attrs = item
20192 .attrs
20193 .as_ref()
20194 .expect("listItem attrs must be present from JFM");
20195 assert_eq!(item_attrs["localId"], "li-jfm-525");
20196 assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20197 }
20198
20199 #[test]
20200 fn issue_525_encoding_emits_localid_on_mediasingle_line() {
20201 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20204 {"type":"listItem","attrs":{"localId":"li-emit-check"},"content":[
20205 {"type":"mediaSingle","attrs":{"layout":"center"},
20206 "content":[{"type":"media","attrs":{"type":"file","id":"m-emit","collection":"c-emit","height":10,"width":20}}]}
20207 ]}
20208 ]}]}"#;
20209 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20210 let md = adf_to_markdown(&doc).unwrap();
20211 assert!(
20212 md.contains("{localId=li-emit-check}"),
20213 "expected localId in JFM output, got: {md}"
20214 );
20215 for line in md.lines() {
20217 if line.contains("![") {
20218 assert!(
20219 line.contains("localId=li-emit-check"),
20220 "localId must be on the same line as the image: {line}"
20221 );
20222 }
20223 }
20224 }
20225
20226 #[test]
20229 fn adf_placeholder_to_markdown() {
20230 let doc = AdfDocument {
20231 version: 1,
20232 doc_type: "doc".to_string(),
20233 content: vec![AdfNode::paragraph(vec![AdfNode::placeholder(
20234 "Type something here",
20235 )])],
20236 };
20237 let md = adf_to_markdown(&doc).unwrap();
20238 assert!(
20239 md.contains(":placeholder[Type something here]"),
20240 "expected :placeholder directive, got: {md}"
20241 );
20242 }
20243
20244 #[test]
20245 fn markdown_placeholder_to_adf() {
20246 let doc = markdown_to_adf("Before :placeholder[Enter name] after").unwrap();
20247 let content = doc.content[0].content.as_ref().unwrap();
20248 assert_eq!(content[1].node_type, "placeholder");
20249 let attrs = content[1].attrs.as_ref().unwrap();
20250 assert_eq!(attrs["text"], "Enter name");
20251 }
20252
20253 #[test]
20254 fn placeholder_round_trip() {
20255 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"placeholder","attrs":{"text":"Type something here"}}]}]}"#;
20256 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20257 let md = adf_to_markdown(&doc).unwrap();
20258 let rt = markdown_to_adf(&md).unwrap();
20259 let content = rt.content[0].content.as_ref().unwrap();
20260 assert_eq!(content.len(), 1);
20261 assert_eq!(content[0].node_type, "placeholder");
20262 let attrs = content[0].attrs.as_ref().unwrap();
20263 assert_eq!(attrs["text"], "Type something here");
20264 }
20265
20266 #[test]
20267 fn placeholder_empty_text() {
20268 let doc = AdfDocument {
20269 version: 1,
20270 doc_type: "doc".to_string(),
20271 content: vec![AdfNode::paragraph(vec![AdfNode::placeholder("")])],
20272 };
20273 let md = adf_to_markdown(&doc).unwrap();
20274 assert!(
20275 md.contains(":placeholder[]"),
20276 "expected empty placeholder directive, got: {md}"
20277 );
20278 let rt = markdown_to_adf(&md).unwrap();
20279 let content = rt.content[0].content.as_ref().unwrap();
20280 assert_eq!(content[0].node_type, "placeholder");
20281 assert_eq!(content[0].attrs.as_ref().unwrap()["text"], "");
20282 }
20283
20284 #[test]
20285 fn placeholder_with_surrounding_text() {
20286 let md = "Click :placeholder[here] to continue\n";
20287 let doc = markdown_to_adf(md).unwrap();
20288 let content = doc.content[0].content.as_ref().unwrap();
20289 assert_eq!(content[0].text.as_deref(), Some("Click "));
20290 assert_eq!(content[1].node_type, "placeholder");
20291 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "here");
20292 assert_eq!(content[2].text.as_deref(), Some(" to continue"));
20293 }
20294
20295 #[test]
20296 fn placeholder_missing_attrs() {
20297 let doc = AdfDocument {
20299 version: 1,
20300 doc_type: "doc".to_string(),
20301 content: vec![AdfNode::paragraph(vec![AdfNode {
20302 node_type: "placeholder".to_string(),
20303 attrs: None,
20304 content: None,
20305 text: None,
20306 marks: None,
20307 local_id: None,
20308 parameters: None,
20309 }])],
20310 };
20311 let md = adf_to_markdown(&doc).unwrap();
20312 assert!(!md.contains("placeholder"));
20314 }
20315
20316 #[test]
20318 fn mention_in_table_bullet_list_preserves_id_and_local_id() {
20319 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":" "}]}]}]}]}]}]}]}"#;
20320 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20321 let md = adf_to_markdown(&doc).unwrap();
20322 let rt = markdown_to_adf(&md).unwrap();
20323
20324 let cell = &rt.content[0].content.as_ref().unwrap()[0]
20326 .content
20327 .as_ref()
20328 .unwrap()[0];
20329 let list = &cell.content.as_ref().unwrap()[0];
20330 let list_item = &list.content.as_ref().unwrap()[0];
20331
20332 assert!(
20334 list_item
20335 .attrs
20336 .as_ref()
20337 .and_then(|a| a.get("localId"))
20338 .is_none(),
20339 "localId should stay on the mention, not the listItem"
20340 );
20341
20342 let para = &list_item.content.as_ref().unwrap()[0];
20343 let inlines = para.content.as_ref().unwrap();
20344
20345 assert_eq!(inlines.len(), 3, "expected 3 inline nodes, got {inlines:?}");
20347
20348 assert_eq!(inlines[0].node_type, "text");
20349 assert_eq!(inlines[0].text.as_deref(), Some("prefix text "));
20350
20351 assert_eq!(inlines[1].node_type, "mention");
20352 let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20353 assert_eq!(
20354 mention_attrs["id"], "aabbccdd11223344aabbccdd",
20355 "mention id must be preserved"
20356 );
20357 assert_eq!(
20358 mention_attrs["localId"], "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
20359 "mention localId must be preserved"
20360 );
20361 assert_eq!(mention_attrs["text"], "@Alice Example");
20362
20363 assert_eq!(inlines[2].node_type, "text");
20364 assert_eq!(inlines[2].text.as_deref(), Some(" "));
20365 }
20366
20367 #[test]
20368 fn mention_in_bullet_list_preserves_id_and_local_id() {
20369 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":" "}]}]}]}]}"#;
20371 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20372 let md = adf_to_markdown(&doc).unwrap();
20373 let rt = markdown_to_adf(&md).unwrap();
20374
20375 let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20376 assert!(
20377 list_item
20378 .attrs
20379 .as_ref()
20380 .and_then(|a| a.get("localId"))
20381 .is_none(),
20382 "localId should stay on the mention, not the listItem"
20383 );
20384
20385 let para = &list_item.content.as_ref().unwrap()[0];
20386 let inlines = para.content.as_ref().unwrap();
20387 assert_eq!(inlines[0].node_type, "mention");
20388 let mention_attrs = inlines[0].attrs.as_ref().unwrap();
20389 assert_eq!(mention_attrs["id"], "user123");
20390 assert_eq!(
20391 mention_attrs["localId"],
20392 "11111111-2222-3333-4444-555555555555"
20393 );
20394 }
20395
20396 #[test]
20397 fn mention_in_ordered_list_preserves_id_and_local_id() {
20398 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"}}]}]}]}]}"#;
20399 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20400 let md = adf_to_markdown(&doc).unwrap();
20401 let rt = markdown_to_adf(&md).unwrap();
20402
20403 let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20404 assert!(
20405 list_item
20406 .attrs
20407 .as_ref()
20408 .and_then(|a| a.get("localId"))
20409 .is_none(),
20410 "localId should stay on the mention, not the listItem"
20411 );
20412
20413 let para = &list_item.content.as_ref().unwrap()[0];
20414 let inlines = para.content.as_ref().unwrap();
20415 assert_eq!(inlines[1].node_type, "mention");
20416 let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20417 assert_eq!(mention_attrs["id"], "xyz");
20418 assert_eq!(mention_attrs["localId"], "aaaa-bbbb");
20419 }
20420
20421 #[test]
20422 fn list_item_own_local_id_with_mention_both_preserved() {
20423 let md = "- hello :mention[@Eve]{id=e1 localId=mention-lid} {localId=item-lid}\n";
20426 let doc = markdown_to_adf(md).unwrap();
20427 let list_item = &doc.content[0].content.as_ref().unwrap()[0];
20428
20429 let item_attrs = list_item.attrs.as_ref().unwrap();
20431 assert_eq!(item_attrs["localId"], "item-lid");
20432
20433 let para = &list_item.content.as_ref().unwrap()[0];
20435 let inlines = para.content.as_ref().unwrap();
20436 let mention = inlines.iter().find(|n| n.node_type == "mention").unwrap();
20437 let mention_attrs = mention.attrs.as_ref().unwrap();
20438 assert_eq!(mention_attrs["id"], "e1");
20439 assert_eq!(mention_attrs["localId"], "mention-lid");
20440 }
20441
20442 #[test]
20443 fn extract_trailing_local_id_ignores_directive_attrs() {
20444 let line = "text :mention[@X]{id=abc localId=uuid}";
20447 let (text, lid, plid) = extract_trailing_local_id(line);
20448 assert_eq!(text, line, "text should be unchanged");
20449 assert!(
20450 lid.is_none(),
20451 "should not extract localId from directive attrs"
20452 );
20453 assert!(plid.is_none());
20454 }
20455
20456 #[test]
20457 fn extract_trailing_local_id_matches_standalone_block() {
20458 let line = "some text {localId=abc-123}";
20460 let (text, lid, plid) = extract_trailing_local_id(line);
20461 assert_eq!(text, "some text");
20462 assert_eq!(lid.as_deref(), Some("abc-123"));
20463 assert!(plid.is_none());
20464 }
20465
20466 #[test]
20469 fn newline_in_text_node_roundtrips_in_bullet_list() {
20470 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"}]}]}]}]}"#;
20474 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20475 let md = adf_to_markdown(&doc).unwrap();
20476 let rt = markdown_to_adf(&md).unwrap();
20477
20478 assert_eq!(rt.content.len(), 1);
20480 let list = &rt.content[0];
20481 assert_eq!(list.node_type, "bulletList");
20482 let items = list.content.as_ref().unwrap();
20483 assert_eq!(items.len(), 1);
20484
20485 let item_content = items[0].content.as_ref().unwrap();
20487 assert_eq!(
20488 item_content.len(),
20489 1,
20490 "listItem should have exactly one paragraph"
20491 );
20492 assert_eq!(item_content[0].node_type, "paragraph");
20493
20494 let inlines = item_content[0].content.as_ref().unwrap();
20496 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20497 assert_eq!(
20498 types,
20499 vec!["text", "hardBreak", "text"],
20500 "embedded newline should stay in a single text node, not produce extra hardBreaks"
20501 );
20502 assert_eq!(
20503 inlines[2].text.as_deref(),
20504 Some("first command\nsecond command")
20505 );
20506 }
20507
20508 #[test]
20509 fn newline_in_text_node_roundtrips_in_ordered_list() {
20510 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"}]}]}]}]}"#;
20512 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20513 let md = adf_to_markdown(&doc).unwrap();
20514 let rt = markdown_to_adf(&md).unwrap();
20515
20516 let list = &rt.content[0];
20517 assert_eq!(list.node_type, "orderedList");
20518 let items = list.content.as_ref().unwrap();
20519 assert_eq!(items.len(), 1);
20520
20521 let item_content = items[0].content.as_ref().unwrap();
20522 assert_eq!(item_content.len(), 1);
20523 assert_eq!(item_content[0].node_type, "paragraph");
20524
20525 let inlines = item_content[0].content.as_ref().unwrap();
20526 assert_eq!(inlines.len(), 1);
20527 assert_eq!(inlines[0].node_type, "text");
20528 assert_eq!(inlines[0].text.as_deref(), Some("first\nsecond"));
20529 }
20530
20531 #[test]
20532 fn newline_in_text_node_roundtrips_in_paragraph() {
20533 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello\nworld"}]}]}"#;
20536 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20537 let md = adf_to_markdown(&doc).unwrap();
20538 assert!(
20539 md.contains("hello\\nworld"),
20540 "newline in text node should render as escaped \\n: {md:?}"
20541 );
20542
20543 let rt = markdown_to_adf(&md).unwrap();
20544 let inlines = rt.content[0].content.as_ref().unwrap();
20545 assert_eq!(inlines.len(), 1);
20546 assert_eq!(inlines[0].text.as_deref(), Some("hello\nworld"));
20547 }
20548
20549 #[test]
20550 fn multiple_newlines_in_text_node_roundtrip() {
20551 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a\nb\nc"}]}]}]}]}"#;
20553 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20554 let md = adf_to_markdown(&doc).unwrap();
20555 let rt = markdown_to_adf(&md).unwrap();
20556
20557 let item_content = rt.content[0].content.as_ref().unwrap()[0]
20558 .content
20559 .as_ref()
20560 .unwrap();
20561 assert_eq!(item_content.len(), 1);
20562
20563 let inlines = item_content[0].content.as_ref().unwrap();
20564 assert_eq!(inlines.len(), 1);
20565 assert_eq!(inlines[0].text.as_deref(), Some("a\nb\nc"));
20566 }
20567
20568 #[test]
20569 fn newline_in_marked_text_node_roundtrips() {
20570 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold\ntext","marks":[{"type":"strong"}]}]}]}"#;
20573 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20574 let md = adf_to_markdown(&doc).unwrap();
20575 assert!(
20576 md.contains("**bold\\ntext**"),
20577 "bold text with embedded newline should stay in one marked run: {md:?}"
20578 );
20579
20580 let rt = markdown_to_adf(&md).unwrap();
20581 let inlines = rt.content[0].content.as_ref().unwrap();
20582 assert_eq!(inlines.len(), 1);
20583 assert_eq!(inlines[0].text.as_deref(), Some("bold\ntext"));
20584 assert!(inlines[0]
20585 .marks
20586 .as_ref()
20587 .unwrap()
20588 .iter()
20589 .any(|m| m.mark_type == "strong"));
20590 }
20591
20592 #[test]
20593 fn trailing_newline_in_text_node_roundtrips() {
20594 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"trailing\n"}]}]}"#;
20597 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20598 let md = adf_to_markdown(&doc).unwrap();
20599 assert!(
20600 md.contains("trailing\\n"),
20601 "trailing newline should be escaped: {md:?}"
20602 );
20603
20604 let rt = markdown_to_adf(&md).unwrap();
20605 let inlines = rt.content[0].content.as_ref().unwrap();
20606 assert_eq!(inlines.len(), 1);
20607 assert_eq!(inlines[0].text.as_deref(), Some("trailing\n"));
20608 }
20609
20610 #[test]
20611 fn hardbreak_and_embedded_newline_are_distinct() {
20612 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"}]}]}"#;
20615 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20616 let md = adf_to_markdown(&doc).unwrap();
20617 let rt = markdown_to_adf(&md).unwrap();
20618
20619 let inlines = rt.content[0].content.as_ref().unwrap();
20620 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20621 assert_eq!(
20622 types,
20623 vec!["text", "hardBreak", "text", "hardBreak", "text"]
20624 );
20625 assert_eq!(inlines[0].text.as_deref(), Some("before"));
20626 assert_eq!(inlines[2].text.as_deref(), Some("mid\ndle"));
20627 assert_eq!(inlines[4].text.as_deref(), Some("after"));
20628 }
20629
20630 #[test]
20633 fn issue_472_bullet_list_trailing_hardbreak_roundtrips() {
20634 let adf_json = r#"{"version":1,"type":"doc","content":[
20637 {"type":"bulletList","content":[
20638 {"type":"listItem","content":[
20639 {"type":"paragraph","content":[
20640 {"type":"text","text":"First item"},
20641 {"type":"hardBreak"}
20642 ]}
20643 ]},
20644 {"type":"listItem","content":[
20645 {"type":"paragraph","content":[
20646 {"type":"text","text":"Second item"}
20647 ]}
20648 ]}
20649 ]}
20650 ]}"#;
20651 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20652 let md = adf_to_markdown(&doc).unwrap();
20653 let rt = markdown_to_adf(&md).unwrap();
20654
20655 assert_eq!(
20657 rt.content.len(),
20658 1,
20659 "Should be 1 block (bulletList), got {}",
20660 rt.content.len()
20661 );
20662 assert_eq!(rt.content[0].node_type, "bulletList");
20663 let items = rt.content[0].content.as_ref().unwrap();
20664 assert_eq!(
20665 items.len(),
20666 2,
20667 "Should have 2 listItems, got {}",
20668 items.len()
20669 );
20670
20671 let p1 = items[0].content.as_ref().unwrap()[0]
20673 .content
20674 .as_ref()
20675 .unwrap();
20676 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20677 assert_eq!(types1, vec!["text", "hardBreak"]);
20678 assert_eq!(p1[0].text.as_deref(), Some("First item"));
20679
20680 let p2 = items[1].content.as_ref().unwrap()[0]
20682 .content
20683 .as_ref()
20684 .unwrap();
20685 assert_eq!(p2[0].text.as_deref(), Some("Second item"));
20686 }
20687
20688 #[test]
20689 fn issue_472_ordered_list_trailing_hardbreak_roundtrips() {
20690 let adf_json = r#"{"version":1,"type":"doc","content":[
20692 {"type":"orderedList","attrs":{"order":1},"content":[
20693 {"type":"listItem","content":[
20694 {"type":"paragraph","content":[
20695 {"type":"text","text":"Alpha"},
20696 {"type":"hardBreak"}
20697 ]}
20698 ]},
20699 {"type":"listItem","content":[
20700 {"type":"paragraph","content":[
20701 {"type":"text","text":"Beta"}
20702 ]}
20703 ]}
20704 ]}
20705 ]}"#;
20706 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20707 let md = adf_to_markdown(&doc).unwrap();
20708 let rt = markdown_to_adf(&md).unwrap();
20709
20710 assert_eq!(rt.content.len(), 1);
20711 assert_eq!(rt.content[0].node_type, "orderedList");
20712 let items = rt.content[0].content.as_ref().unwrap();
20713 assert_eq!(items.len(), 2);
20714
20715 let p1 = items[0].content.as_ref().unwrap()[0]
20716 .content
20717 .as_ref()
20718 .unwrap();
20719 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20720 assert_eq!(types1, vec!["text", "hardBreak"]);
20721 assert_eq!(p1[0].text.as_deref(), Some("Alpha"));
20722 }
20723
20724 #[test]
20725 fn issue_472_trailing_hardbreak_jfm_no_blank_line() {
20726 let adf_json = r#"{"version":1,"type":"doc","content":[
20729 {"type":"bulletList","content":[
20730 {"type":"listItem","content":[
20731 {"type":"paragraph","content":[
20732 {"type":"text","text":"Hello"},
20733 {"type":"hardBreak"}
20734 ]}
20735 ]},
20736 {"type":"listItem","content":[
20737 {"type":"paragraph","content":[
20738 {"type":"text","text":"World"}
20739 ]}
20740 ]}
20741 ]}
20742 ]}"#;
20743 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20744 let md = adf_to_markdown(&doc).unwrap();
20745
20746 assert_eq!(md, "- Hello\\\n- World\n");
20748 }
20749
20750 #[test]
20751 fn issue_472_multiple_trailing_hardbreaks_roundtrip() {
20752 let adf_json = r#"{"version":1,"type":"doc","content":[
20754 {"type":"bulletList","content":[
20755 {"type":"listItem","content":[
20756 {"type":"paragraph","content":[
20757 {"type":"text","text":"Item"},
20758 {"type":"hardBreak"},
20759 {"type":"hardBreak"}
20760 ]}
20761 ]},
20762 {"type":"listItem","content":[
20763 {"type":"paragraph","content":[
20764 {"type":"text","text":"Next"}
20765 ]}
20766 ]}
20767 ]}
20768 ]}"#;
20769 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20770 let md = adf_to_markdown(&doc).unwrap();
20771 let rt = markdown_to_adf(&md).unwrap();
20772
20773 assert_eq!(rt.content.len(), 1);
20775 assert_eq!(rt.content[0].node_type, "bulletList");
20776 let items = rt.content[0].content.as_ref().unwrap();
20777 assert_eq!(items.len(), 2);
20778
20779 let p1 = items[0].content.as_ref().unwrap()[0]
20781 .content
20782 .as_ref()
20783 .unwrap();
20784 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20785 assert_eq!(types1, vec!["text", "hardBreak", "hardBreak"]);
20786 }
20787
20788 #[test]
20789 fn issue_472_hardbreak_mid_and_trailing_roundtrip() {
20790 let adf_json = r#"{"version":1,"type":"doc","content":[
20792 {"type":"bulletList","content":[
20793 {"type":"listItem","content":[
20794 {"type":"paragraph","content":[
20795 {"type":"text","text":"Line one"},
20796 {"type":"hardBreak"},
20797 {"type":"text","text":"Line two"},
20798 {"type":"hardBreak"}
20799 ]}
20800 ]},
20801 {"type":"listItem","content":[
20802 {"type":"paragraph","content":[
20803 {"type":"text","text":"Other item"}
20804 ]}
20805 ]}
20806 ]}
20807 ]}"#;
20808 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20809 let md = adf_to_markdown(&doc).unwrap();
20810 let rt = markdown_to_adf(&md).unwrap();
20811
20812 assert_eq!(rt.content.len(), 1);
20813 assert_eq!(rt.content[0].node_type, "bulletList");
20814 let items = rt.content[0].content.as_ref().unwrap();
20815 assert_eq!(items.len(), 2);
20816
20817 let p1 = items[0].content.as_ref().unwrap()[0]
20818 .content
20819 .as_ref()
20820 .unwrap();
20821 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20822 assert_eq!(types1, vec!["text", "hardBreak", "text", "hardBreak"]);
20823 assert_eq!(p1[0].text.as_deref(), Some("Line one"));
20824 assert_eq!(p1[2].text.as_deref(), Some("Line two"));
20825 }
20826
20827 #[test]
20828 fn issue_472_only_hardbreak_in_listitem_paragraph() {
20829 let adf_json = r#"{"version":1,"type":"doc","content":[
20831 {"type":"bulletList","content":[
20832 {"type":"listItem","content":[
20833 {"type":"paragraph","content":[
20834 {"type":"hardBreak"}
20835 ]}
20836 ]},
20837 {"type":"listItem","content":[
20838 {"type":"paragraph","content":[
20839 {"type":"text","text":"After"}
20840 ]}
20841 ]}
20842 ]}
20843 ]}"#;
20844 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20845 let md = adf_to_markdown(&doc).unwrap();
20846 let rt = markdown_to_adf(&md).unwrap();
20847
20848 assert_eq!(rt.content.len(), 1);
20850 assert_eq!(rt.content[0].node_type, "bulletList");
20851 let items = rt.content[0].content.as_ref().unwrap();
20852 assert_eq!(items.len(), 2);
20853 }
20854
20855 #[test]
20856 fn issue_472_three_items_middle_has_trailing_hardbreak() {
20857 let adf_json = r#"{"version":1,"type":"doc","content":[
20859 {"type":"bulletList","content":[
20860 {"type":"listItem","content":[
20861 {"type":"paragraph","content":[
20862 {"type":"text","text":"First"}
20863 ]}
20864 ]},
20865 {"type":"listItem","content":[
20866 {"type":"paragraph","content":[
20867 {"type":"text","text":"Second"},
20868 {"type":"hardBreak"}
20869 ]}
20870 ]},
20871 {"type":"listItem","content":[
20872 {"type":"paragraph","content":[
20873 {"type":"text","text":"Third"}
20874 ]}
20875 ]}
20876 ]}
20877 ]}"#;
20878 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20879 let md = adf_to_markdown(&doc).unwrap();
20880 let rt = markdown_to_adf(&md).unwrap();
20881
20882 assert_eq!(rt.content.len(), 1);
20883 assert_eq!(rt.content[0].node_type, "bulletList");
20884 let items = rt.content[0].content.as_ref().unwrap();
20885 assert_eq!(items.len(), 3);
20886 assert_eq!(
20887 items[0].content.as_ref().unwrap()[0]
20888 .content
20889 .as_ref()
20890 .unwrap()[0]
20891 .text
20892 .as_deref(),
20893 Some("First")
20894 );
20895 assert_eq!(
20896 items[2].content.as_ref().unwrap()[0]
20897 .content
20898 .as_ref()
20899 .unwrap()[0]
20900 .text
20901 .as_deref(),
20902 Some("Third")
20903 );
20904 }
20905
20906 #[test]
20909 fn issue_494_space_after_hardbreak_roundtrip() {
20910 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20913 {"type":"text","text":"Some text"},
20914 {"type":"hardBreak"},
20915 {"type":"text","text":" "}
20916 ]}]}"#;
20917 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20918 let md = adf_to_markdown(&doc).unwrap();
20919 let rt = markdown_to_adf(&md).unwrap();
20920 let inlines = rt.content[0].content.as_ref().unwrap();
20921 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20922 assert_eq!(
20923 types,
20924 vec!["text", "hardBreak", "text"],
20925 "space-only text node after hardBreak should survive round-trip"
20926 );
20927 assert_eq!(inlines[2].text.as_deref(), Some(" "));
20928 }
20929
20930 #[test]
20931 fn issue_494_multiple_spaces_after_hardbreak_roundtrip() {
20932 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20934 {"type":"text","text":"Hello"},
20935 {"type":"hardBreak"},
20936 {"type":"text","text":" "}
20937 ]}]}"#;
20938 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20939 let md = adf_to_markdown(&doc).unwrap();
20940 let rt = markdown_to_adf(&md).unwrap();
20941 let inlines = rt.content[0].content.as_ref().unwrap();
20942 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20943 assert_eq!(
20944 types,
20945 vec!["text", "hardBreak", "text"],
20946 "multi-space text node after hardBreak should survive round-trip"
20947 );
20948 assert_eq!(inlines[2].text.as_deref(), Some(" "));
20949 }
20950
20951 #[test]
20952 fn issue_494_space_then_text_after_hardbreak_roundtrip() {
20953 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20956 {"type":"text","text":"Before"},
20957 {"type":"hardBreak"},
20958 {"type":"text","text":" After"}
20959 ]}]}"#;
20960 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20961 let md = adf_to_markdown(&doc).unwrap();
20962 let rt = markdown_to_adf(&md).unwrap();
20963 let inlines = rt.content[0].content.as_ref().unwrap();
20964 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20965 assert_eq!(types, vec!["text", "hardBreak", "text"]);
20966 assert_eq!(inlines[2].text.as_deref(), Some(" After"));
20967 }
20968
20969 #[test]
20970 fn issue_494_hardbreak_then_space_then_hardbreak_roundtrip() {
20971 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20973 {"type":"text","text":"A"},
20974 {"type":"hardBreak"},
20975 {"type":"text","text":" "},
20976 {"type":"hardBreak"},
20977 {"type":"text","text":"B"}
20978 ]}]}"#;
20979 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20980 let md = adf_to_markdown(&doc).unwrap();
20981 let rt = markdown_to_adf(&md).unwrap();
20982 let inlines = rt.content[0].content.as_ref().unwrap();
20983 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20984 assert_eq!(
20985 types,
20986 vec!["text", "hardBreak", "text", "hardBreak", "text"],
20987 "space between two hardBreaks should survive round-trip"
20988 );
20989 assert_eq!(inlines[2].text.as_deref(), Some(" "));
20990 assert_eq!(inlines[4].text.as_deref(), Some("B"));
20991 }
20992
20993 #[test]
20994 fn issue_494_trailing_space_hardbreak_style_not_confused() {
20995 let md = "first paragraph\n\nsecond paragraph\n";
20998 let doc = markdown_to_adf(md).unwrap();
20999 assert_eq!(
21000 doc.content.len(),
21001 2,
21002 "blank line should still separate paragraphs"
21003 );
21004 }
21005
21006 #[test]
21007 fn issue_494_space_after_trailing_space_hardbreak_roundtrip() {
21008 let md = "line one \n \n";
21011 let doc = markdown_to_adf(md).unwrap();
21015 let inlines = doc.content[0].content.as_ref().unwrap();
21016 let has_text_after_break = inlines.iter().any(|n| {
21017 n.node_type == "text"
21018 && n.text
21019 .as_deref()
21020 .is_some_and(|t| t.trim().is_empty() && !t.is_empty())
21021 });
21022 assert!(
21023 has_text_after_break || inlines.len() >= 2,
21024 "space-only line after trailing-space hardBreak should be preserved"
21025 );
21026 }
21027
21028 #[test]
21029 fn issue_494_space_after_hardbreak_in_list_item_roundtrip() {
21030 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21032 {"type":"listItem","content":[{"type":"paragraph","content":[
21033 {"type":"text","text":"item"},
21034 {"type":"hardBreak"},
21035 {"type":"text","text":" "}
21036 ]}]}
21037 ]}]}"#;
21038 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21039 let md = adf_to_markdown(&doc).unwrap();
21040 let rt = markdown_to_adf(&md).unwrap();
21041 let list = &rt.content[0];
21042 let item = &list.content.as_ref().unwrap()[0];
21043 let para = &item.content.as_ref().unwrap()[0];
21044 let inlines = para.content.as_ref().unwrap();
21045 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21046 assert_eq!(
21047 types,
21048 vec!["text", "hardBreak", "text"],
21049 "space after hardBreak in list item should survive round-trip"
21050 );
21051 assert_eq!(inlines[2].text.as_deref(), Some(" "));
21052 }
21053
21054 #[test]
21057 fn issue_510_trailing_double_space_paragraph_roundtrip() {
21058 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"}]}]}"#;
21061 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21062 let md = adf_to_markdown(&doc).unwrap();
21063 let rt = markdown_to_adf(&md).unwrap();
21064
21065 assert_eq!(
21067 rt.content.len(),
21068 2,
21069 "should produce two paragraphs, got: {}",
21070 rt.content.len()
21071 );
21072 assert_eq!(rt.content[0].node_type, "paragraph");
21073 assert_eq!(rt.content[1].node_type, "paragraph");
21074
21075 let p1 = rt.content[0].content.as_ref().unwrap();
21077 assert_eq!(
21078 p1[0].text.as_deref(),
21079 Some("first paragraph with trailing spaces "),
21080 "trailing spaces should be preserved in first paragraph"
21081 );
21082
21083 let p2 = rt.content[1].content.as_ref().unwrap();
21085 assert_eq!(p2[0].text.as_deref(), Some("second paragraph"));
21086
21087 let all_types: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21089 assert!(
21090 !all_types.contains(&"hardBreak"),
21091 "trailing spaces should not produce hardBreak, got: {all_types:?}"
21092 );
21093 }
21094
21095 #[test]
21096 fn issue_510_trailing_triple_space_roundtrip() {
21097 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"text "}]},{"type":"paragraph","content":[{"type":"text","text":"next"}]}]}"#;
21099 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21100 let md = adf_to_markdown(&doc).unwrap();
21101 let rt = markdown_to_adf(&md).unwrap();
21102
21103 assert_eq!(rt.content.len(), 2, "should still be two paragraphs");
21104 let p1 = rt.content[0].content.as_ref().unwrap();
21105 assert_eq!(
21106 p1[0].text.as_deref(),
21107 Some("text "),
21108 "three trailing spaces should be preserved"
21109 );
21110 }
21111
21112 #[test]
21113 fn issue_510_trailing_spaces_with_backslash_roundtrip() {
21114 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\ "}]}]}"#;
21116 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21117 let md = adf_to_markdown(&doc).unwrap();
21118 let rt = markdown_to_adf(&md).unwrap();
21119 let p = rt.content[0].content.as_ref().unwrap();
21120 assert_eq!(
21121 p[0].text.as_deref(),
21122 Some("end\\ "),
21123 "backslash + trailing spaces should both survive"
21124 );
21125 }
21126
21127 #[test]
21128 fn issue_510_jfm_contains_escaped_trailing_space() {
21129 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello "}]}]}"#;
21131 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21132 let md = adf_to_markdown(&doc).unwrap();
21133 assert!(
21134 md.contains(r"\ "),
21135 "JFM should contain backslash-space escape for trailing spaces, got: {md:?}"
21136 );
21137 for line in md.lines() {
21139 assert!(
21140 !line.ends_with(" "),
21141 "no JFM line should end with two plain spaces, got: {line:?}"
21142 );
21143 }
21144 }
21145
21146 #[test]
21147 fn issue_510_single_trailing_space_not_escaped() {
21148 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"word "}]}]}"#;
21150 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21151 let md = adf_to_markdown(&doc).unwrap();
21152 assert!(
21153 !md.contains('\\'),
21154 "single trailing space should not be escaped, got: {md:?}"
21155 );
21156 let rt = markdown_to_adf(&md).unwrap();
21157 let p = rt.content[0].content.as_ref().unwrap();
21158 assert_eq!(p[0].text.as_deref(), Some("word "));
21159 }
21160
21161 #[test]
21162 fn issue_510_trailing_spaces_in_heading_roundtrip() {
21163 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"heading "}]}]}"#;
21165 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21166 let md = adf_to_markdown(&doc).unwrap();
21167 let rt = markdown_to_adf(&md).unwrap();
21168 let h = rt.content[0].content.as_ref().unwrap();
21169 assert_eq!(
21170 h[0].text.as_deref(),
21171 Some("heading "),
21172 "trailing spaces in heading should be preserved"
21173 );
21174 }
21175
21176 #[test]
21177 fn issue_510_trailing_spaces_in_list_item_roundtrip() {
21178 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item "}]}]}]}]}"#;
21180 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21181 let md = adf_to_markdown(&doc).unwrap();
21182 let rt = markdown_to_adf(&md).unwrap();
21183 let list = &rt.content[0];
21184 let item = &list.content.as_ref().unwrap()[0];
21185 let para = &item.content.as_ref().unwrap()[0];
21186 let inlines = para.content.as_ref().unwrap();
21187 assert_eq!(
21188 inlines[0].text.as_deref(),
21189 Some("item "),
21190 "trailing spaces in list item should be preserved"
21191 );
21192 }
21193
21194 #[test]
21195 fn issue_510_trailing_spaces_with_bold_mark_roundtrip() {
21196 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold ","marks":[{"type":"strong"}]}]}]}"#;
21200 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21201 let md = adf_to_markdown(&doc).unwrap();
21202 let rt = markdown_to_adf(&md).unwrap();
21203 let p = rt.content[0].content.as_ref().unwrap();
21204 assert_eq!(
21205 p[0].text.as_deref(),
21206 Some("bold "),
21207 "trailing spaces in bold text should be preserved"
21208 );
21209 }
21210
21211 #[test]
21212 fn issue_510_hardbreak_between_paragraphs_still_works() {
21213 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"line one"},{"type":"hardBreak"},{"type":"text","text":"line two"}]}]}"#;
21215 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21216 let md = adf_to_markdown(&doc).unwrap();
21217 let rt = markdown_to_adf(&md).unwrap();
21218 let inlines = rt.content[0].content.as_ref().unwrap();
21219 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21220 assert_eq!(
21221 types,
21222 vec!["text", "hardBreak", "text"],
21223 "explicit hardBreak should still round-trip"
21224 );
21225 }
21226
21227 #[test]
21228 fn issue_510_all_spaces_text_node_roundtrip() {
21229 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":" "}]}]}"#;
21231 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21232 let md = adf_to_markdown(&doc).unwrap();
21233 let rt = markdown_to_adf(&md).unwrap();
21234 let p = rt.content[0].content.as_ref().unwrap();
21235 assert_eq!(
21236 p[0].text.as_deref(),
21237 Some(" "),
21238 "space-only text node should survive round-trip"
21239 );
21240 }
21241
21242 #[test]
21245 fn issue_522_listitem_hardbreak_then_two_paragraphs_roundtrips() {
21246 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"}]}]}]}]}"#;
21249 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21250 let md = adf_to_markdown(&doc).unwrap();
21251 let rt = markdown_to_adf(&md).unwrap();
21252
21253 let items = rt.content[0].content.as_ref().unwrap();
21254 assert_eq!(items.len(), 1);
21255 let children = items[0].content.as_ref().unwrap();
21256 assert_eq!(
21257 children.len(),
21258 3,
21259 "Expected 3 paragraphs in listItem, got {}",
21260 children.len()
21261 );
21262 assert_eq!(children[0].node_type, "paragraph");
21263 assert_eq!(children[1].node_type, "paragraph");
21264 assert_eq!(children[2].node_type, "paragraph");
21265
21266 let text1 = children[1].content.as_ref().unwrap()[0]
21268 .text
21269 .as_deref()
21270 .unwrap();
21271 assert_eq!(text1, "second paragraph");
21272 let text2 = children[2].content.as_ref().unwrap()[0]
21273 .text
21274 .as_deref()
21275 .unwrap();
21276 assert_eq!(text2, "third paragraph");
21277 }
21278
21279 #[test]
21280 fn issue_522_ordered_list_hardbreak_then_paragraphs_roundtrips() {
21281 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"}]}]}]}]}"#;
21283 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21284 let md = adf_to_markdown(&doc).unwrap();
21285 let rt = markdown_to_adf(&md).unwrap();
21286
21287 let items = rt.content[0].content.as_ref().unwrap();
21288 let children = items[0].content.as_ref().unwrap();
21289 assert_eq!(
21290 children.len(),
21291 3,
21292 "Expected 3 paragraphs in ordered listItem, got {}",
21293 children.len()
21294 );
21295 assert_eq!(children[1].node_type, "paragraph");
21296 assert_eq!(children[2].node_type, "paragraph");
21297 assert_eq!(
21298 children[1].content.as_ref().unwrap()[0]
21299 .text
21300 .as_deref()
21301 .unwrap(),
21302 "second para"
21303 );
21304 assert_eq!(
21305 children[2].content.as_ref().unwrap()[0]
21306 .text
21307 .as_deref()
21308 .unwrap(),
21309 "third para"
21310 );
21311 }
21312
21313 #[test]
21314 fn issue_522_two_paragraphs_without_hardbreak_roundtrips() {
21315 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"}]}]}]}]}"#;
21317 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21318 let md = adf_to_markdown(&doc).unwrap();
21319 let rt = markdown_to_adf(&md).unwrap();
21320
21321 let items = rt.content[0].content.as_ref().unwrap();
21322 let children = items[0].content.as_ref().unwrap();
21323 assert_eq!(
21324 children.len(),
21325 2,
21326 "Expected 2 paragraphs in listItem, got {}",
21327 children.len()
21328 );
21329 assert_eq!(children[0].node_type, "paragraph");
21330 assert_eq!(children[1].node_type, "paragraph");
21331 }
21332
21333 #[test]
21334 fn issue_522_paragraph_then_nested_list_no_spurious_blank() {
21335 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"}]}]}]}]}]}]}"#;
21338 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21339 let md = adf_to_markdown(&doc).unwrap();
21340 assert!(
21342 !md.contains(" \n -"),
21343 "No blank separator between paragraph and nested list"
21344 );
21345 let rt = markdown_to_adf(&md).unwrap();
21346
21347 let items = rt.content[0].content.as_ref().unwrap();
21348 let children = items[0].content.as_ref().unwrap();
21349 assert_eq!(children.len(), 2);
21350 assert_eq!(children[0].node_type, "paragraph");
21351 assert_eq!(children[1].node_type, "bulletList");
21352 }
21353
21354 #[test]
21355 fn issue_522_three_paragraphs_no_hardbreak_roundtrips() {
21356 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"}]}]}]}]}"#;
21358 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21359 let md = adf_to_markdown(&doc).unwrap();
21360 let rt = markdown_to_adf(&md).unwrap();
21361
21362 let items = rt.content[0].content.as_ref().unwrap();
21363 let children = items[0].content.as_ref().unwrap();
21364 assert_eq!(
21365 children.len(),
21366 3,
21367 "Expected 3 paragraphs, got {}",
21368 children.len()
21369 );
21370 for (i, child) in children.iter().enumerate() {
21371 assert_eq!(
21372 child.node_type, "paragraph",
21373 "Child {i} should be a paragraph"
21374 );
21375 }
21376 }
21377
21378 #[test]
21379 fn issue_522_multiple_list_items_each_with_paragraphs() {
21380 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"}]}]}]}]}"#;
21382 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21383 let md = adf_to_markdown(&doc).unwrap();
21384 let rt = markdown_to_adf(&md).unwrap();
21385
21386 let items = rt.content[0].content.as_ref().unwrap();
21387 assert_eq!(items.len(), 2, "Expected 2 list items");
21388
21389 let item1 = items[0].content.as_ref().unwrap();
21390 assert_eq!(item1.len(), 2, "Item 1 should have 2 paragraphs");
21391
21392 let item2 = items[1].content.as_ref().unwrap();
21393 assert_eq!(item2.len(), 2, "Item 2 should have 2 paragraphs");
21394 let item2_p1_inlines = item2[0].content.as_ref().unwrap();
21396 let types: Vec<&str> = item2_p1_inlines
21397 .iter()
21398 .map(|n| n.node_type.as_str())
21399 .collect();
21400 assert_eq!(types, vec!["text", "hardBreak", "text"]);
21401 }
21402
21403 #[test]
21404 fn issue_531_blockquote_hardbreak_then_two_paragraphs_roundtrips() {
21405 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"}]}]}]}"#;
21409 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21410 let md = adf_to_markdown(&doc).unwrap();
21411 let rt = markdown_to_adf(&md).unwrap();
21412
21413 let children = rt.content[0].content.as_ref().unwrap();
21414 assert_eq!(
21415 children.len(),
21416 3,
21417 "Expected 3 paragraphs in blockquote, got {}",
21418 children.len()
21419 );
21420 assert_eq!(children[0].node_type, "paragraph");
21421 assert_eq!(children[1].node_type, "paragraph");
21422 assert_eq!(children[2].node_type, "paragraph");
21423
21424 let text1 = children[1].content.as_ref().unwrap()[0]
21425 .text
21426 .as_deref()
21427 .unwrap();
21428 assert_eq!(text1, "second paragraph");
21429 let text2 = children[2].content.as_ref().unwrap()[0]
21430 .text
21431 .as_deref()
21432 .unwrap();
21433 assert_eq!(text2, "third paragraph");
21434 }
21435
21436 #[test]
21437 fn issue_531_blockquote_two_paragraphs_without_hardbreak_roundtrips() {
21438 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"}]}]}]}"#;
21440 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21441 let md = adf_to_markdown(&doc).unwrap();
21442 let rt = markdown_to_adf(&md).unwrap();
21443
21444 let children = rt.content[0].content.as_ref().unwrap();
21445 assert_eq!(
21446 children.len(),
21447 2,
21448 "Expected 2 paragraphs in blockquote, got {}",
21449 children.len()
21450 );
21451 assert_eq!(children[0].node_type, "paragraph");
21452 assert_eq!(children[1].node_type, "paragraph");
21453 }
21454
21455 #[test]
21456 fn issue_531_blockquote_three_paragraphs_no_hardbreak_roundtrips() {
21457 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"}]}]}]}"#;
21459 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21460 let md = adf_to_markdown(&doc).unwrap();
21461 let rt = markdown_to_adf(&md).unwrap();
21462
21463 let children = rt.content[0].content.as_ref().unwrap();
21464 assert_eq!(
21465 children.len(),
21466 3,
21467 "Expected 3 paragraphs in blockquote, got {}",
21468 children.len()
21469 );
21470 for child in children {
21471 assert_eq!(child.node_type, "paragraph");
21472 }
21473 }
21474
21475 #[test]
21476 fn issue_531_blockquote_paragraph_then_list_no_spurious_blank() {
21477 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"}]}]}]}]}]}"#;
21480 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21481 let md = adf_to_markdown(&doc).unwrap();
21482 let rt = markdown_to_adf(&md).unwrap();
21483
21484 let children = rt.content[0].content.as_ref().unwrap();
21485 assert_eq!(children[0].node_type, "paragraph");
21486 assert_eq!(children[1].node_type, "bulletList");
21487 }
21488
21489 #[test]
21490 fn issue_531_blockquote_single_paragraph_unchanged() {
21491 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"solo"}]}]}]}"#;
21493 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21494 let md = adf_to_markdown(&doc).unwrap();
21495 let rt = markdown_to_adf(&md).unwrap();
21496
21497 let children = rt.content[0].content.as_ref().unwrap();
21498 assert_eq!(children.len(), 1);
21499 assert_eq!(children[0].node_type, "paragraph");
21500 let text = children[0].content.as_ref().unwrap()[0]
21501 .text
21502 .as_deref()
21503 .unwrap();
21504 assert_eq!(text, "solo");
21505 }
21506
21507 fn assert_roundtrip_marks(adf_json: &str, expected_marks: &[&str]) {
21512 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21513 let md = adf_to_markdown(&doc).unwrap();
21514 let rt = markdown_to_adf(&md).unwrap();
21515 let node = &rt.content[0].content.as_ref().unwrap()[0];
21516 let mark_types: Vec<&str> = node
21517 .marks
21518 .as_ref()
21519 .expect("should have marks")
21520 .iter()
21521 .map(|m| m.mark_type.as_str())
21522 .collect();
21523 assert_eq!(
21524 mark_types, expected_marks,
21525 "mark order mismatch for md={md}"
21526 );
21527 }
21528
21529 #[test]
21530 fn issue_554_code_and_text_color_preserved() {
21531 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21532 {"type":"text","text":"x","marks":[
21533 {"type":"textColor","attrs":{"color":"#008000"}},
21534 {"type":"code"}
21535 ]}
21536 ]}]}"##;
21537 assert_roundtrip_marks(adf_json, &["textColor", "code"]);
21538 }
21539
21540 #[test]
21541 fn issue_554_code_and_bg_color_preserved() {
21542 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21543 {"type":"text","text":"x","marks":[
21544 {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21545 {"type":"code"}
21546 ]}
21547 ]}]}"##;
21548 assert_roundtrip_marks(adf_json, &["backgroundColor", "code"]);
21549 }
21550
21551 #[test]
21552 fn issue_554_code_and_subsup_preserved() {
21553 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21554 {"type":"text","text":"x","marks":[
21555 {"type":"subsup","attrs":{"type":"sub"}},
21556 {"type":"code"}
21557 ]}
21558 ]}]}"#;
21559 assert_roundtrip_marks(adf_json, &["subsup", "code"]);
21560 }
21561
21562 #[test]
21563 fn issue_554_code_and_underline_preserved() {
21564 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21565 {"type":"text","text":"x","marks":[
21566 {"type":"underline"},
21567 {"type":"code"}
21568 ]}
21569 ]}]}"#;
21570 assert_roundtrip_marks(adf_json, &["underline", "code"]);
21571 }
21572
21573 #[test]
21574 fn issue_554_code_textcolor_and_underline_preserved() {
21575 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21576 {"type":"text","text":"x","marks":[
21577 {"type":"textColor","attrs":{"color":"#008000"}},
21578 {"type":"underline"},
21579 {"type":"code"}
21580 ]}
21581 ]}]}"##;
21582 assert_roundtrip_marks(adf_json, &["textColor", "underline", "code"]);
21583 }
21584
21585 #[test]
21586 fn issue_554_textcolor_and_underline_preserved() {
21587 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21588 {"type":"text","text":"x","marks":[
21589 {"type":"textColor","attrs":{"color":"#008000"}},
21590 {"type":"underline"}
21591 ]}
21592 ]}]}"##;
21593 assert_roundtrip_marks(adf_json, &["textColor", "underline"]);
21594 }
21595
21596 #[test]
21597 fn issue_554_underline_and_textcolor_preserved_order_swapped() {
21598 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21599 {"type":"text","text":"x","marks":[
21600 {"type":"underline"},
21601 {"type":"textColor","attrs":{"color":"#008000"}}
21602 ]}
21603 ]}]}"##;
21604 assert_roundtrip_marks(adf_json, &["underline", "textColor"]);
21606 }
21607
21608 #[test]
21609 fn issue_554_textcolor_and_annotation_preserved() {
21610 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21611 {"type":"text","text":"x","marks":[
21612 {"type":"textColor","attrs":{"color":"#008000"}},
21613 {"type":"annotation","attrs":{"id":"abc-123","annotationType":"inlineComment"}}
21614 ]}
21615 ]}]}"##;
21616 assert_roundtrip_marks(adf_json, &["textColor", "annotation"]);
21617 }
21618
21619 #[test]
21620 fn issue_554_bgcolor_and_underline_preserved() {
21621 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21622 {"type":"text","text":"x","marks":[
21623 {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21624 {"type":"underline"}
21625 ]}
21626 ]}]}"##;
21627 assert_roundtrip_marks(adf_json, &["backgroundColor", "underline"]);
21628 }
21629
21630 #[test]
21631 fn issue_554_subsup_and_underline_preserved() {
21632 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21633 {"type":"text","text":"x","marks":[
21634 {"type":"subsup","attrs":{"type":"sub"}},
21635 {"type":"underline"}
21636 ]}
21637 ]}]}"#;
21638 assert_roundtrip_marks(adf_json, &["subsup", "underline"]);
21639 }
21640
21641 #[test]
21642 fn issue_554_exact_reproducer_full_match() {
21643 let adf_json = r##"{
21646 "version": 1,
21647 "type": "doc",
21648 "content": [
21649 {
21650 "type": "paragraph",
21651 "content": [
21652 {"type":"text","text":"Status: ","marks":[{"type":"strong"}]},
21653 {"type":"text","text":"Approved","marks":[
21654 {"type":"textColor","attrs":{"color":"#008000"}}
21655 ]},
21656 {"type":"text","text":" — ready to proceed"}
21657 ]
21658 }
21659 ]
21660 }"##;
21661 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21662 let md = adf_to_markdown(&doc).unwrap();
21663 assert!(
21664 md.contains(":span[Approved]{color=#008000}"),
21665 "JFM should contain green span: {md}"
21666 );
21667 let rt = markdown_to_adf(&md).unwrap();
21668 let approved = rt.content[0]
21670 .content
21671 .as_ref()
21672 .unwrap()
21673 .iter()
21674 .find(|n| n.text.as_deref() == Some("Approved"))
21675 .expect("Approved text node");
21676 let marks = approved.marks.as_ref().expect("should have marks");
21677 let color_mark = marks
21678 .iter()
21679 .find(|m| m.mark_type == "textColor")
21680 .expect("textColor mark must be preserved");
21681 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
21682 }
21683
21684 #[test]
21685 fn issue_554_textcolor_with_code_renders_span_around_code() {
21686 let doc = AdfDocument {
21689 version: 1,
21690 doc_type: "doc".to_string(),
21691 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
21692 "fn main",
21693 vec![
21694 AdfMark::text_color("#008000"),
21695 AdfMark {
21696 mark_type: "code".to_string(),
21697 attrs: None,
21698 },
21699 ],
21700 )])],
21701 };
21702 let md = adf_to_markdown(&doc).unwrap();
21703 assert!(
21704 md.contains(":span[`fn main`]{color=#008000}"),
21705 "expected span-wrapped code, got: {md}"
21706 );
21707 }
21708
21709 #[test]
21710 fn issue_554_underline_with_code_renders_bracketed_around_code() {
21711 let doc = AdfDocument {
21712 version: 1,
21713 doc_type: "doc".to_string(),
21714 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
21715 "fn main",
21716 vec![
21717 AdfMark::underline(),
21718 AdfMark {
21719 mark_type: "code".to_string(),
21720 attrs: None,
21721 },
21722 ],
21723 )])],
21724 };
21725 let md = adf_to_markdown(&doc).unwrap();
21726 assert!(
21727 md.contains("[`fn main`]{underline}"),
21728 "expected bracketed-span around code, got: {md}"
21729 );
21730 }
21731
21732 #[test]
21735 fn issue_554_underscore_adjacent_to_textcolor_span_roundtrip() {
21736 let adf_json = r##"{
21741 "version": 1,
21742 "type": "doc",
21743 "content": [
21744 {
21745 "type": "paragraph",
21746 "content": [
21747 {"type":"text","text":"_ "},
21748 {"type":"text","text":"_Action:*","marks":[
21749 {"type":"textColor","attrs":{"color":"#008000"}}
21750 ]},
21751 {"type":"text","text":" Complete the setup process."}
21752 ]
21753 }
21754 ]
21755 }"##;
21756 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21757 let md = adf_to_markdown(&doc).unwrap();
21758 assert!(
21761 md.contains(r"\_ ") && md.contains(r":span[\_Action"),
21762 "underscores at node boundaries should be escaped: {md}"
21763 );
21764 let rt = markdown_to_adf(&md).unwrap();
21765 let para_content = rt.content[0].content.as_ref().unwrap();
21766 let colored = para_content
21768 .iter()
21769 .find(|n| {
21770 n.marks
21771 .as_deref()
21772 .is_some_and(|ms| ms.iter().any(|m| m.mark_type == "textColor"))
21773 })
21774 .expect("textColor node must be preserved");
21775 assert_eq!(colored.text.as_deref(), Some("_Action:*"));
21776 let color_mark = colored
21777 .marks
21778 .as_ref()
21779 .unwrap()
21780 .iter()
21781 .find(|m| m.mark_type == "textColor")
21782 .unwrap();
21783 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
21784 for n in para_content {
21786 if let Some(ms) = n.marks.as_deref() {
21787 assert!(
21788 !ms.iter().any(|m| m.mark_type == "em"),
21789 "no em mark should appear, got marks {:?}",
21790 ms.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
21791 );
21792 }
21793 }
21794 }
21795
21796 #[test]
21797 fn issue_554_underscore_intraword_left_unescaped() {
21798 let doc = AdfDocument {
21802 version: 1,
21803 doc_type: "doc".to_string(),
21804 content: vec![AdfNode::paragraph(vec![AdfNode::text(
21805 "call do_something_useful now",
21806 )])],
21807 };
21808 let md = adf_to_markdown(&doc).unwrap();
21809 assert!(
21810 md.contains("do_something_useful") && !md.contains(r"do\_something\_useful"),
21811 "intraword underscores should not be escaped: {md}"
21812 );
21813 }
21814
21815 #[test]
21816 fn issue_554_code_underline_then_textcolor_bracketed_outer() {
21817 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21820 {"type":"text","text":"x","marks":[
21821 {"type":"underline"},
21822 {"type":"textColor","attrs":{"color":"#008000"}},
21823 {"type":"code"}
21824 ]}
21825 ]}]}"##;
21826 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21827 let md = adf_to_markdown(&doc).unwrap();
21828 assert!(
21830 md.starts_with('[') && md.contains("underline}"),
21831 "bracketed-span should wrap the span, got: {md}"
21832 );
21833 let rt = markdown_to_adf(&md).unwrap();
21834 let node = &rt.content[0].content.as_ref().unwrap()[0];
21835 let mark_types: Vec<&str> = node
21836 .marks
21837 .as_ref()
21838 .unwrap()
21839 .iter()
21840 .map(|m| m.mark_type.as_str())
21841 .collect();
21842 assert_eq!(mark_types, vec!["underline", "textColor", "code"]);
21843 }
21844
21845 #[test]
21846 fn issue_554_textcolor_underline_link_all_preserved() {
21847 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21851 {"type":"text","text":"linked","marks":[
21852 {"type":"textColor","attrs":{"color":"#008000"}},
21853 {"type":"underline"},
21854 {"type":"link","attrs":{"href":"https://example.com"}}
21855 ]}
21856 ]}]}"##;
21857 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21858 let md = adf_to_markdown(&doc).unwrap();
21859 let rt = markdown_to_adf(&md).unwrap();
21860 let node = &rt.content[0].content.as_ref().unwrap()[0];
21861 let mark_types: Vec<&str> = node
21862 .marks
21863 .as_ref()
21864 .unwrap()
21865 .iter()
21866 .map(|m| m.mark_type.as_str())
21867 .collect();
21868 assert_eq!(mark_types, vec!["textColor", "underline", "link"]);
21869 }
21870
21871 #[test]
21872 fn issue_554_underline_textcolor_link_bracketed_outer_link_last() {
21873 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21876 {"type":"text","text":"linked","marks":[
21877 {"type":"underline"},
21878 {"type":"textColor","attrs":{"color":"#008000"}},
21879 {"type":"link","attrs":{"href":"https://example.com"}}
21880 ]}
21881 ]}]}"##;
21882 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21883 let md = adf_to_markdown(&doc).unwrap();
21884 let rt = markdown_to_adf(&md).unwrap();
21885 let node = &rt.content[0].content.as_ref().unwrap()[0];
21886 let mark_types: Vec<&str> = node
21887 .marks
21888 .as_ref()
21889 .unwrap()
21890 .iter()
21891 .map(|m| m.mark_type.as_str())
21892 .collect();
21893 assert_eq!(mark_types, vec!["underline", "textColor", "link"]);
21894 }
21895
21896 #[test]
21897 fn issue_554_link_underline_textcolor_link_outer() {
21898 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21902 {"type":"text","text":"linked","marks":[
21903 {"type":"link","attrs":{"href":"https://example.com"}},
21904 {"type":"underline"},
21905 {"type":"textColor","attrs":{"color":"#008000"}}
21906 ]}
21907 ]}]}"##;
21908 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21909 let md = adf_to_markdown(&doc).unwrap();
21910 assert!(
21911 md.starts_with('[') && md.contains("](https://example.com)"),
21912 "link should be outermost, got: {md}"
21913 );
21914 let rt = markdown_to_adf(&md).unwrap();
21915 let node = &rt.content[0].content.as_ref().unwrap()[0];
21916 let mark_types: Vec<&str> = node
21917 .marks
21918 .as_ref()
21919 .unwrap()
21920 .iter()
21921 .map(|m| m.mark_type.as_str())
21922 .collect();
21923 assert_eq!(mark_types, vec!["link", "underline", "textColor"]);
21924 }
21925
21926 #[test]
21927 fn issue_554_trailing_underscore_then_leading_underscore_round_trip() {
21928 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21932 {"type":"text","text":"end_"},
21933 {"type":"text","text":"_start"}
21934 ]}]}"#;
21935 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21936 let md = adf_to_markdown(&doc).unwrap();
21937 let rt = markdown_to_adf(&md).unwrap();
21938 let combined: String = rt.content[0]
21940 .content
21941 .as_ref()
21942 .unwrap()
21943 .iter()
21944 .filter_map(|n| n.text.as_deref())
21945 .collect();
21946 assert_eq!(combined, "end__start");
21947 for n in rt.content[0].content.as_ref().unwrap() {
21949 if let Some(ms) = n.marks.as_deref() {
21950 assert!(!ms.iter().any(|m| m.mark_type == "em"));
21951 }
21952 }
21953 }
21954}