1use anyhow::Result;
8use chrono::NaiveDate;
9use tracing::{debug, warn};
10
11use crate::atlassian::adf::{AdfDocument, AdfMark, AdfNode};
12use crate::atlassian::attrs::parse_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 let next = self.current_line();
73 if let Some(stripped) = next
76 .strip_prefix(" ")
77 .filter(|s| !s.trim_start().starts_with("!["))
78 {
79 full_text.push('\n');
80 full_text.push_str(stripped);
81 self.advance();
82 continue;
83 }
84 break;
85 }
86 }
87
88 fn parse_blocks(&mut self) -> Result<Vec<AdfNode>> {
89 let mut blocks = Vec::new();
90
91 while !self.at_end() {
92 let line = self.current_line();
93
94 if line.trim().is_empty() {
95 self.advance();
96 continue;
97 }
98
99 let mut node = if let Some(node) = self.try_heading() {
100 node
101 } else if let Some(node) = self.try_horizontal_rule() {
102 node
103 } else if let Some(node) = self.try_container_directive()? {
104 node
105 } else if let Some(node) = self.try_code_block()? {
106 node
107 } else if let Some(node) = self.try_table()? {
108 node
109 } else if let Some(node) = self.try_blockquote()? {
110 node
111 } else if let Some(node) = self.try_list()? {
112 node
113 } else if let Some(node) = self.try_leaf_directive() {
114 node
115 } else if let Some(node) = self.try_image() {
116 node
117 } else {
118 self.parse_paragraph()?
119 };
120
121 self.try_apply_block_attrs(&mut node);
123 blocks.push(node);
124 }
125
126 Ok(blocks)
127 }
128
129 fn try_heading(&mut self) -> Option<AdfNode> {
130 let line = self.current_line();
131 let trimmed = line.trim_start();
132
133 if !trimmed.starts_with('#') {
134 return None;
135 }
136
137 let level = trimmed.chars().take_while(|&c| c == '#').count();
138 if !(1..=6).contains(&level) || !trimmed[level..].starts_with(' ') {
139 return None;
140 }
141
142 let mut full_text = trimmed[level + 1..].to_string();
143 self.advance();
144 self.collect_hardbreak_continuations(&mut full_text);
146 let inline_nodes = parse_inline(&full_text);
147
148 #[allow(clippy::cast_possible_truncation)]
149 Some(AdfNode::heading(level as u8, inline_nodes))
150 }
151
152 fn try_horizontal_rule(&mut self) -> Option<AdfNode> {
153 let line = self.current_line().trim();
154 let is_rule = (line.starts_with("---") && line.chars().all(|c| c == '-'))
155 || (line.starts_with("***") && line.chars().all(|c| c == '*'))
156 || (line.starts_with("___") && line.chars().all(|c| c == '_'));
157
158 if is_rule && line.len() >= 3 {
159 self.advance();
160 Some(AdfNode::rule())
161 } else {
162 None
163 }
164 }
165
166 fn try_code_block(&mut self) -> Result<Option<AdfNode>> {
167 let line = self.current_line();
168 if !line.starts_with("```") {
169 return Ok(None);
170 }
171
172 let language = line[3..].trim();
173 let language = if language == "\"\"" {
174 Some(String::new())
176 } else if language.is_empty() {
177 None
178 } else {
179 Some(language.to_string())
180 };
181
182 self.advance();
183 let mut code_lines = Vec::new();
184
185 while !self.at_end() {
186 let line = self.current_line();
187 if line.starts_with("```") {
188 self.advance();
189 break;
190 }
191 code_lines.push(line);
192 self.advance();
193 }
194
195 let code_text = code_lines.join("\n");
196
197 if language.as_deref() == Some("adf-unsupported") {
199 if let Ok(node) = serde_json::from_str::<AdfNode>(&code_text) {
200 return Ok(Some(node));
201 }
202 }
203
204 Ok(Some(AdfNode::code_block(language.as_deref(), &code_text)))
205 }
206
207 fn try_blockquote(&mut self) -> Result<Option<AdfNode>> {
208 let line = self.current_line();
209 if !line.starts_with('>') {
210 return Ok(None);
211 }
212
213 let mut quote_lines = Vec::new();
214 while !self.at_end() {
215 let line = self.current_line();
216 if let Some(rest) = line.strip_prefix("> ") {
217 quote_lines.push(rest);
218 self.advance();
219 } else if let Some(rest) = line.strip_prefix('>') {
220 quote_lines.push(rest);
221 self.advance();
222 } else {
223 break;
224 }
225 }
226
227 let quote_text = quote_lines.join("\n");
228 let mut inner_parser = MarkdownParser::new("e_text);
229 let inner_blocks = inner_parser.parse_blocks()?;
230
231 Ok(Some(AdfNode::blockquote(inner_blocks)))
232 }
233
234 fn try_list(&mut self) -> Result<Option<AdfNode>> {
235 let line = self.current_line();
236 let trimmed = line.trim_start();
237
238 let is_bullet =
239 trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ");
240 let ordered_match = parse_ordered_list_marker(trimmed);
241
242 if !is_bullet && ordered_match.is_none() {
243 return Ok(None);
244 }
245
246 if is_bullet {
247 self.parse_bullet_list()
248 } else {
249 let start = ordered_match.map_or(1, |(n, _)| n);
250 self.parse_ordered_list(start)
251 }
252 }
253
254 fn parse_bullet_list(&mut self) -> Result<Option<AdfNode>> {
255 let mut items = Vec::new();
256 let mut is_task_list = false;
257
258 while !self.at_end() {
259 let line = self.current_line();
260 let trimmed = line.trim_start();
261
262 if !(trimmed.starts_with("- ")
263 || trimmed.starts_with("* ")
264 || trimmed.starts_with("+ "))
265 {
266 break;
267 }
268
269 let after_marker = trimmed[2..].trim_start();
270
271 if let Some((state, text)) = try_parse_task_marker(after_marker) {
273 is_task_list = true;
274 self.advance();
275 let mut full_text = text.to_string();
279 self.collect_hardbreak_continuations(&mut full_text);
280 let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
281 let inline_nodes = parse_inline(item_text);
282 let content = if let Some(ref plid) = para_local_id {
286 let mut para = AdfNode::paragraph(inline_nodes);
287 if plid != "_" {
288 para.attrs = Some(serde_json::json!({"localId": plid}));
289 }
290 vec![para]
291 } else {
292 inline_nodes
293 };
294 let mut task = AdfNode::task_item(state, content);
295 if let Some(id) = local_id {
297 if let Some(ref mut attrs) = task.attrs {
298 attrs["localId"] = serde_json::Value::String(id);
299 }
300 }
301 let mut sub_lines: Vec<String> = Vec::new();
305 while !self.at_end() && self.current_line().starts_with(" ") {
306 let stripped = &self.current_line()[2..];
307 sub_lines.push(stripped.to_string());
308 self.advance();
309 }
310 if !sub_lines.is_empty() {
311 let sub_text = sub_lines.join("\n");
312 let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
313 let is_empty = task.content.as_ref().map_or(true, Vec::is_empty);
321 if is_empty && nested.len() == 1 && nested[0].node_type == "taskList" {
322 if let Some(task_items) = nested.remove(0).content {
323 task.content = Some(task_items);
324 }
325 if let Some(ref mut attrs) = task.attrs {
326 if let Some(obj) = attrs.as_object_mut() {
327 obj.remove("state");
328 }
329 }
330 items.push(task);
331 } else {
332 let mut sibling_task_lists = Vec::new();
338 let mut child_nodes = Vec::new();
339 for n in nested {
340 if n.node_type == "taskList" {
341 sibling_task_lists.push(n);
342 } else {
343 child_nodes.push(n);
344 }
345 }
346 if !child_nodes.is_empty() {
347 match task.content {
348 Some(ref mut content) => content.append(&mut child_nodes),
349 None => task.content = Some(child_nodes),
350 }
351 }
352 items.push(task);
353 items.append(&mut sibling_task_lists);
354 }
355 } else {
356 items.push(task);
357 }
358 } else {
359 let first_line = &trimmed[2..];
360 self.advance();
361 let mut full_text = first_line.to_string();
362 self.collect_hardbreak_continuations(&mut full_text);
363 let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
364 let mut sub_lines: Vec<String> = Vec::new();
368 while !self.at_end() {
369 let next = self.current_line();
370 if let Some(stripped) = next.strip_prefix(" ") {
371 sub_lines.push(stripped.to_string());
372 self.advance();
373 continue;
374 }
375 break;
376 }
377 let item_content =
378 parse_list_item_first_line(item_text, sub_lines, local_id, para_local_id)?;
379 items.push(item_content);
380 }
381 }
382
383 if items.is_empty() {
384 Ok(None)
385 } else if is_task_list {
386 Ok(Some(AdfNode::task_list(items)))
387 } else {
388 Ok(Some(AdfNode::bullet_list(items)))
389 }
390 }
391
392 fn parse_ordered_list(&mut self, start: u32) -> Result<Option<AdfNode>> {
393 let mut items = Vec::new();
394
395 while !self.at_end() {
396 let line = self.current_line();
397 let trimmed = line.trim_start();
398
399 if let Some((_, rest)) = parse_ordered_list_marker(trimmed) {
400 let first_line = rest.trim_start_matches(|c: char| c.is_ascii_whitespace());
401 self.advance();
402 let mut full_text = first_line.to_string();
403 self.collect_hardbreak_continuations(&mut full_text);
404 let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
405 let mut sub_lines: Vec<String> = Vec::new();
407 while !self.at_end() {
408 let next = self.current_line();
409 if let Some(stripped) = next.strip_prefix(" ") {
410 sub_lines.push(stripped.to_string());
411 self.advance();
412 continue;
413 }
414 break;
415 }
416 let item_content =
417 parse_list_item_first_line(item_text, sub_lines, local_id, para_local_id)?;
418 items.push(item_content);
419 } else {
420 break;
421 }
422 }
423
424 if items.is_empty() {
425 Ok(None)
426 } else {
427 Ok(Some(AdfNode::ordered_list(items, Some(start))))
428 }
429 }
430
431 fn try_apply_block_attrs(&mut self, node: &mut AdfNode) {
432 if self.at_end() {
433 return;
434 }
435 let line = self.current_line().trim();
436 if !line.starts_with('{') {
437 return;
438 }
439 let Some((_, attrs)) = parse_attrs(line, 0) else {
440 return;
441 };
442
443 let mut marks = Vec::new();
444 if let Some(align) = attrs.get("align") {
445 marks.push(AdfMark::alignment(align));
446 }
447 if let Some(indent) = attrs.get("indent") {
448 if let Ok(level) = indent.parse::<u32>() {
449 marks.push(AdfMark::indentation(level));
450 }
451 }
452 if let Some(mode) = attrs.get("breakout") {
453 let width = attrs
454 .get("breakoutWidth")
455 .and_then(|w| w.parse::<u32>().ok());
456 marks.push(AdfMark::breakout(mode, width));
457 }
458
459 let local_id = attrs.get("localId").map(str::to_string);
461
462 let has_attrs = !marks.is_empty() || local_id.is_some();
463 if has_attrs {
464 if !marks.is_empty() {
465 let existing = node.marks.get_or_insert_with(Vec::new);
466 existing.extend(marks);
467 }
468 if let Some(id) = local_id {
469 let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
470 node_attrs["localId"] = serde_json::Value::String(id);
471 }
472 self.advance(); }
474 }
475
476 fn try_container_directive(&mut self) -> Result<Option<AdfNode>> {
477 let line = self.current_line();
478 let Some((d, colon_count)) = try_parse_container_open(line) else {
479 return Ok(None);
480 };
481 self.advance(); let mut inner_lines = Vec::new();
485 let mut depth: usize = 0;
486 while !self.at_end() {
487 let current = self.current_line();
488 if try_parse_container_open(current).is_some() {
489 depth += 1;
490 } else if depth == 0 && is_container_close(current, colon_count) {
491 self.advance(); break;
493 } else if depth > 0 && is_container_close(current, 3) {
494 depth -= 1;
495 }
496 inner_lines.push(current.to_string());
497 self.advance();
498 }
499
500 let inner_text = inner_lines.join("\n");
501
502 let node = match d.name.as_str() {
503 "panel" => {
504 let panel_type = d
505 .attrs
506 .as_ref()
507 .and_then(|a| a.get("type"))
508 .unwrap_or("info");
509 let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
510 let mut node = AdfNode::panel(panel_type, inner_blocks);
511 if let Some(ref attrs) = d.attrs {
513 if let Some(ref mut node_attrs) = node.attrs {
514 if let Some(icon) = attrs.get("icon") {
515 node_attrs["panelIcon"] = serde_json::Value::String(icon.to_string());
516 }
517 if let Some(color) = attrs.get("color") {
518 node_attrs["panelColor"] = serde_json::Value::String(color.to_string());
519 }
520 }
521 }
522 node
523 }
524 "expand" => {
525 let title = d.attrs.as_ref().and_then(|a| a.get("title"));
526 let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
527 let mut node = AdfNode::expand(title, inner_blocks);
528 pass_through_expand_params(&d.attrs, &mut node);
529 node
530 }
531 "nested-expand" => {
532 let title = d.attrs.as_ref().and_then(|a| a.get("title"));
533 let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
534 let mut node = AdfNode::nested_expand(title, inner_blocks);
535 pass_through_expand_params(&d.attrs, &mut node);
536 node
537 }
538 "layout" => {
539 let columns = self.parse_layout_columns(&inner_text)?;
541 AdfNode::layout_section(columns)
542 }
543 "decisions" => {
544 let items = parse_decision_items(&inner_text);
545 AdfNode::decision_list(items)
546 }
547 "table" => {
548 let rows = self.parse_directive_table_rows(&inner_text)?;
549 let mut table_attrs = serde_json::json!({});
550 if let Some(ref attrs) = d.attrs {
551 if let Some(layout) = attrs.get("layout") {
552 table_attrs["layout"] = serde_json::Value::String(layout.to_string());
553 }
554 if attrs.has_flag("numbered") {
555 table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
556 } else if attrs.get("numbered") == Some("false") {
557 table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
558 }
559 if let Some(tw) = attrs.get("width") {
560 if let Ok(w) = tw.parse::<f64>() {
561 table_attrs["width"] = serde_json::json!(w);
562 }
563 }
564 if let Some(local_id) = attrs.get("localId") {
565 table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
566 }
567 }
568 if table_attrs == serde_json::json!({}) {
569 AdfNode::table(rows)
570 } else {
571 AdfNode::table_with_attrs(rows, table_attrs)
572 }
573 }
574 "extension" => {
575 let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
576 let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
577 let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
578 let mut node = AdfNode::bodied_extension(ext_type, ext_key, inner_blocks);
579 if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
580 {
581 if let Some(layout) = dir_attrs.get("layout") {
582 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
583 }
584 if let Some(local_id) = dir_attrs.get("localId") {
585 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
586 }
587 if let Some(params_str) = dir_attrs.get("params") {
588 if let Ok(params_val) =
589 serde_json::from_str::<serde_json::Value>(params_str)
590 {
591 node_attrs["parameters"] = params_val;
592 }
593 }
594 }
595 node
596 }
597 _ => return Ok(None),
598 };
599
600 Ok(Some(node))
601 }
602
603 fn parse_layout_columns(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
604 let mut columns = Vec::new();
605 let mut current_column_lines: Vec<String> = Vec::new();
606 let mut current_width: f64 = 50.0;
607 let mut current_dir_attrs: Option<crate::atlassian::attrs::Attrs> = None;
608 let mut in_column = false;
609 let mut depth: usize = 0;
610
611 let lines: Vec<&str> = inner_text.lines().collect();
612 let mut i = 0;
613
614 while i < lines.len() {
615 let line = lines[i];
616 if let Some((col_d, _)) = try_parse_container_open(line) {
617 if col_d.name == "column" && depth == 0 {
618 if in_column && !current_column_lines.is_empty() {
620 let col_text = current_column_lines.join("\n");
621 let blocks = MarkdownParser::new(&col_text).parse_blocks()?;
622 let mut col = AdfNode::layout_column(current_width, blocks);
623 pass_through_local_id(¤t_dir_attrs, &mut col);
624 columns.push(col);
625 current_column_lines.clear();
626 }
627 current_width = col_d
628 .attrs
629 .as_ref()
630 .and_then(|a| a.get("width"))
631 .and_then(|w| w.parse::<f64>().ok())
632 .unwrap_or(50.0);
633 current_dir_attrs = col_d.attrs;
634 in_column = true;
635 i += 1;
636 continue;
637 }
638 if in_column {
639 depth += 1;
640 }
641 }
642 if in_column && is_container_close(line, 3) {
643 if depth > 0 {
644 depth -= 1;
645 current_column_lines.push(line.to_string());
646 i += 1;
647 continue;
648 }
649 let col_text = current_column_lines.join("\n");
651 let blocks = MarkdownParser::new(&col_text).parse_blocks()?;
652 let mut col = AdfNode::layout_column(current_width, blocks);
653 pass_through_local_id(¤t_dir_attrs, &mut col);
654 columns.push(col);
655 current_column_lines.clear();
656 current_dir_attrs = None;
657 in_column = false;
658 i += 1;
659 continue;
660 }
661 if in_column {
662 current_column_lines.push(line.to_string());
663 }
664 i += 1;
665 }
666
667 if in_column && !current_column_lines.is_empty() {
669 let col_text = current_column_lines.join("\n");
670 let blocks = MarkdownParser::new(&col_text).parse_blocks()?;
671 let mut col = AdfNode::layout_column(current_width, blocks);
672 pass_through_local_id(¤t_dir_attrs, &mut col);
673 columns.push(col);
674 }
675
676 Ok(columns)
677 }
678
679 fn parse_directive_table_rows(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
681 debug!(
682 "parse_directive_table_rows: {} lines of inner text",
683 inner_text.lines().count()
684 );
685 let mut rows = Vec::new();
686 let lines: Vec<&str> = inner_text.lines().collect();
687 let mut i = 0;
688
689 while i < lines.len() {
690 let line = lines[i];
691 if let Some((d, _)) = try_parse_container_open(line) {
692 if d.name == "tr" {
693 let tr_attrs = d.attrs.clone();
694 i += 1;
695 let (mut row, next_i) = self.parse_directive_table_row(&lines, i)?;
696 if let Some(ref attrs) = tr_attrs {
698 if let Some(local_id) = attrs.get("localId") {
699 let row_attrs = row.attrs.get_or_insert_with(|| serde_json::json!({}));
700 row_attrs["localId"] = serde_json::Value::String(local_id.to_string());
701 }
702 }
703 rows.push(row);
704 i = next_i;
705 continue;
706 }
707 if d.name == "caption" {
708 let dir_attrs = d.attrs.clone();
709 i += 1;
710 let mut caption_lines = Vec::new();
711 while i < lines.len() {
712 if is_container_close(lines[i], 3) {
713 i += 1;
714 break;
715 }
716 caption_lines.push(lines[i]);
717 i += 1;
718 }
719 let caption_text = caption_lines.join("\n");
720 let inline_nodes = parse_inline(&caption_text);
721 let mut caption = AdfNode::caption(inline_nodes);
722 pass_through_local_id(&dir_attrs, &mut caption);
723 rows.push(caption);
724 continue;
725 }
726 }
727 i += 1;
728 }
729
730 Ok(rows)
731 }
732
733 fn parse_directive_table_row(&self, lines: &[&str], start: usize) -> Result<(AdfNode, usize)> {
735 let mut cells = Vec::new();
736 let mut i = start;
737 let mut depth: usize = 0;
738
739 while i < lines.len() {
740 let line = lines[i];
741 if is_container_close(line, 3) {
742 if depth == 0 {
743 i += 1;
745 break;
746 }
747 depth -= 1;
748 i += 1;
749 continue;
750 }
751 if let Some((d, _)) = try_parse_container_open(line) {
752 if depth == 0 && (d.name == "th" || d.name == "td") {
753 let is_header = d.name == "th";
754 let cell_attrs = d.attrs.clone();
755 i += 1;
756 let (cell, next_i) =
757 self.parse_directive_table_cell(lines, i, is_header, cell_attrs)?;
758 cells.push(cell);
759 i = next_i;
760 continue;
761 }
762 depth += 1;
763 }
764 i += 1;
765 }
766
767 if cells.is_empty() {
768 let context = lines[start.saturating_sub(1)..lines.len().min(start + 3)].to_vec();
769 warn!(
770 "Directive table row at line {start} has no cells — \
771 Confluence requires at least one. Nearby lines: {context:?}"
772 );
773 }
774 debug!("Parsed directive table row: {} cells", cells.len());
775
776 Ok((AdfNode::table_row(cells), i))
777 }
778
779 fn parse_directive_table_cell(
781 &self,
782 lines: &[&str],
783 start: usize,
784 is_header: bool,
785 cell_attrs: Option<crate::atlassian::attrs::Attrs>,
786 ) -> Result<(AdfNode, usize)> {
787 let mut cell_lines = Vec::new();
788 let mut i = start;
789 let mut depth: usize = 0;
790
791 while i < lines.len() {
792 let line = lines[i];
793 if try_parse_container_open(line).is_some() {
794 depth += 1;
795 } else if is_container_close(line, 3) {
796 if depth == 0 {
797 i += 1;
798 break;
799 }
800 depth -= 1;
801 }
802 cell_lines.push(line.to_string());
803 i += 1;
804 }
805
806 let cell_text = cell_lines.join("\n");
807 let blocks = MarkdownParser::new(&cell_text).parse_blocks()?;
808
809 let adf_attrs = cell_attrs.as_ref().map(build_cell_attrs);
810 let cell_marks = cell_attrs
811 .as_ref()
812 .map(build_border_marks)
813 .unwrap_or_default();
814
815 let cell = if cell_marks.is_empty() {
816 if is_header {
817 if let Some(attrs) = adf_attrs {
818 AdfNode::table_header_with_attrs(blocks, attrs)
819 } else {
820 AdfNode::table_header(blocks)
821 }
822 } else if let Some(attrs) = adf_attrs {
823 AdfNode::table_cell_with_attrs(blocks, attrs)
824 } else {
825 AdfNode::table_cell(blocks)
826 }
827 } else if is_header {
828 AdfNode::table_header_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
829 } else {
830 AdfNode::table_cell_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
831 };
832
833 Ok((cell, i))
834 }
835
836 fn try_leaf_directive(&mut self) -> Option<AdfNode> {
837 let line = self.current_line();
838 let d = try_parse_leaf_directive(line)?;
839
840 let node = match d.name.as_str() {
841 "card" => {
842 let url = d.content.as_deref().unwrap_or("");
843 let mut node = AdfNode::block_card(url);
844 if let Some(ref attrs) = d.attrs {
846 if let Some(ref mut node_attrs) = node.attrs {
847 if let Some(layout) = attrs.get("layout") {
848 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
849 }
850 if let Some(width) = attrs.get("width") {
851 if let Ok(w) = width.parse::<u64>() {
852 node_attrs["width"] = serde_json::json!(w);
853 }
854 }
855 }
856 }
857 node
858 }
859 "embed" => {
860 let url = d.content.as_deref().unwrap_or("");
861 let layout = d.attrs.as_ref().and_then(|a| a.get("layout"));
862 let original_height = d
863 .attrs
864 .as_ref()
865 .and_then(|a| a.get("originalHeight"))
866 .and_then(|v| v.parse::<f64>().ok());
867 let width = d
868 .attrs
869 .as_ref()
870 .and_then(|a| a.get("width"))
871 .and_then(|w| w.parse::<f64>().ok());
872 AdfNode::embed_card(url, layout, original_height, width)
873 }
874 "extension" => {
875 let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
876 let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
877 let params = d
878 .attrs
879 .as_ref()
880 .and_then(|a| a.get("params"))
881 .and_then(|p| serde_json::from_str(p).ok());
882 let mut node = AdfNode::extension(ext_type, ext_key, params);
883 if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
884 {
885 if let Some(layout) = dir_attrs.get("layout") {
886 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
887 }
888 if let Some(local_id) = dir_attrs.get("localId") {
889 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
890 }
891 }
892 node
893 }
894 "paragraph" => {
895 let mut node = if let Some(ref text) = d.content {
896 AdfNode::paragraph(parse_inline(text))
897 } else {
898 AdfNode::paragraph(vec![])
899 };
900 pass_through_local_id(&d.attrs, &mut node);
901 node
902 }
903 _ => return None,
904 };
905
906 self.advance();
907 Some(node)
908 }
909
910 fn try_image(&mut self) -> Option<AdfNode> {
911 let line = self.current_line().trim();
912 let mut node = try_parse_media_single_from_line(line)?;
913 self.advance();
914
915 if !self.at_end() {
917 if let Some((d, _)) = try_parse_container_open(self.current_line()) {
918 if d.name == "caption" {
919 let dir_attrs = d.attrs;
920 self.advance(); let mut caption_lines = Vec::new();
922 while !self.at_end() {
923 if is_container_close(self.current_line(), 3) {
924 self.advance(); break;
926 }
927 caption_lines.push(self.current_line());
928 self.advance();
929 }
930 let caption_text = caption_lines.join("\n");
931 let inline_nodes = parse_inline(&caption_text);
932 let mut caption = AdfNode::caption(inline_nodes);
933 pass_through_local_id(&dir_attrs, &mut caption);
934 if let Some(ref mut content) = node.content {
935 content.push(caption);
936 }
937 }
938 }
939 }
940
941 Some(node)
942 }
943
944 fn try_table(&mut self) -> Result<Option<AdfNode>> {
945 let line = self.current_line();
946 if !line.contains('|') || !line.trim_start().starts_with('|') {
947 return Ok(None);
948 }
949
950 if self.pos + 1 >= self.lines.len() {
952 return Ok(None);
953 }
954 let next_line = self.lines[self.pos + 1];
955 if !is_table_separator(next_line) {
956 return Ok(None);
957 }
958
959 let header_cells = parse_table_row(line);
961 self.advance(); let sep_line = self.current_line();
965 let alignments = parse_table_alignments(sep_line);
966 self.advance(); let mut rows = Vec::new();
969
970 let header_adf_cells: Vec<AdfNode> = header_cells
972 .iter()
973 .enumerate()
974 .map(|(col_idx, cell)| {
975 let (cell_text, cell_attrs) = extract_cell_attrs(cell);
976 let mut para = AdfNode::paragraph(parse_inline(&cell_text));
977 apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
978 if let Some(attrs) = cell_attrs {
979 AdfNode::table_header_with_attrs(vec![para], attrs)
980 } else {
981 AdfNode::table_header(vec![para])
982 }
983 })
984 .collect();
985 if header_adf_cells.is_empty() {
986 warn!(
987 "Pipe table header row at line {} has no cells",
988 self.pos - 1
989 );
990 }
991 rows.push(AdfNode::table_row(header_adf_cells));
992
993 while !self.at_end() {
995 let line = self.current_line();
996 if !line.contains('|') || line.trim().is_empty() {
997 break;
998 }
999
1000 let cells = parse_table_row(line);
1001 let adf_cells: Vec<AdfNode> = cells
1002 .iter()
1003 .enumerate()
1004 .map(|(col_idx, cell)| {
1005 let (cell_text, cell_attrs) = extract_cell_attrs(cell);
1006 let mut para = AdfNode::paragraph(parse_inline(&cell_text));
1007 apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
1008 if let Some(attrs) = cell_attrs {
1009 AdfNode::table_cell_with_attrs(vec![para], attrs)
1010 } else {
1011 AdfNode::table_cell(vec![para])
1012 }
1013 })
1014 .collect();
1015 if adf_cells.is_empty() {
1016 warn!("Pipe table body row at line {} has no cells", self.pos);
1017 }
1018 rows.push(AdfNode::table_row(adf_cells));
1019 self.advance();
1020 }
1021
1022 debug!("Parsed pipe table with {} rows", rows.len());
1023 let mut table = AdfNode::table(rows);
1024
1025 if !self.at_end() {
1027 let next = self.current_line().trim();
1028 if next.starts_with('{') {
1029 if let Some((_, attrs)) = parse_attrs(next, 0) {
1030 let mut table_attrs = serde_json::json!({});
1031 if let Some(layout) = attrs.get("layout") {
1032 table_attrs["layout"] = serde_json::Value::String(layout.to_string());
1033 }
1034 if attrs.has_flag("numbered") {
1035 table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
1036 } else if attrs.get("numbered") == Some("false") {
1037 table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
1038 }
1039 if let Some(tw) = attrs.get("width") {
1040 if let Ok(w) = tw.parse::<f64>() {
1041 table_attrs["width"] = serde_json::json!(w);
1042 }
1043 }
1044 if let Some(local_id) = attrs.get("localId") {
1045 table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1046 }
1047 if table_attrs != serde_json::json!({}) {
1048 table.attrs = Some(table_attrs);
1049 self.advance(); }
1051 }
1052 }
1053 }
1054
1055 Ok(Some(table))
1056 }
1057
1058 fn parse_paragraph(&mut self) -> Result<AdfNode> {
1059 let mut lines: Vec<&str> = Vec::new();
1060
1061 while !self.at_end() {
1062 let line = self.current_line();
1063 if (line.trim().is_empty()
1072 && !lines
1073 .last()
1074 .is_some_and(|prev| has_trailing_hard_break(prev)))
1075 || line.starts_with("```")
1076 || (is_horizontal_rule(line) && !lines.is_empty())
1077 {
1078 break;
1079 }
1080 let is_hardbreak_cont = !lines.is_empty()
1083 && line.starts_with(" ")
1084 && lines
1085 .last()
1086 .is_some_and(|prev| has_trailing_hard_break(prev));
1087 if is_hardbreak_cont {
1088 lines.push(&line[2..]);
1089 self.advance();
1090 continue;
1091 }
1092 if !lines.is_empty()
1093 && (line.starts_with('#') || line.starts_with('>') || is_list_start(line))
1094 {
1095 break;
1096 }
1097 if !lines.is_empty() && is_block_attrs_line(line) {
1099 break;
1100 }
1101 lines.push(line);
1102 self.advance();
1103 }
1104
1105 let text = lines.join("\n");
1106 let inline_nodes = parse_inline(&text);
1107 Ok(AdfNode::paragraph(inline_nodes))
1108 }
1109}
1110
1111fn build_cell_attrs(attrs: &crate::atlassian::attrs::Attrs) -> serde_json::Value {
1114 let mut adf = serde_json::json!({});
1115 if let Some(bg) = attrs.get("bg") {
1116 adf["background"] = serde_json::Value::String(bg.to_string());
1117 }
1118 if let Some(colspan) = attrs.get("colspan") {
1119 if let Ok(n) = colspan.parse::<u32>() {
1120 adf["colspan"] = serde_json::json!(n);
1121 }
1122 }
1123 if let Some(rowspan) = attrs.get("rowspan") {
1124 if let Ok(n) = rowspan.parse::<u32>() {
1125 adf["rowspan"] = serde_json::json!(n);
1126 }
1127 }
1128 if let Some(colwidth) = attrs.get("colwidth") {
1129 let widths: Vec<serde_json::Value> = colwidth
1130 .split(',')
1131 .filter_map(|s| {
1132 let s = s.trim();
1133 if s.contains('.') {
1136 s.parse::<f64>().ok().map(|n| serde_json::json!(n))
1137 } else {
1138 s.parse::<u64>().ok().map(|n| serde_json::json!(n))
1139 }
1140 })
1141 .collect();
1142 if !widths.is_empty() {
1143 adf["colwidth"] = serde_json::Value::Array(widths);
1144 }
1145 }
1146 if let Some(local_id) = attrs.get("localId") {
1147 adf["localId"] = serde_json::Value::String(local_id.to_string());
1148 }
1149 adf
1150}
1151
1152fn build_border_marks(attrs: &crate::atlassian::attrs::Attrs) -> Vec<AdfMark> {
1154 let mut marks = Vec::new();
1155 let border_color = attrs.get("border-color");
1156 let border_size = attrs.get("border-size");
1157 if border_color.is_some() || border_size.is_some() {
1158 let color = border_color.unwrap_or("#000000");
1159 let size = border_size.and_then(|s| s.parse::<u32>().ok()).unwrap_or(1);
1160 marks.push(AdfMark::border(color, size));
1161 }
1162 marks
1163}
1164
1165fn iso_date_to_epoch_ms(date_str: &str) -> String {
1168 if date_str.chars().all(|c| c.is_ascii_digit()) {
1170 return date_str.to_string();
1171 }
1172 if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
1173 let epoch_ms = date
1174 .and_hms_opt(0, 0, 0)
1175 .map_or(0, |dt| dt.and_utc().timestamp_millis());
1176 epoch_ms.to_string()
1177 } else {
1178 date_str.to_string()
1180 }
1181}
1182
1183fn epoch_ms_to_iso_date(timestamp: &str) -> String {
1186 if timestamp.contains('-') {
1188 return timestamp.to_string();
1189 }
1190 if let Ok(ms) = timestamp.parse::<i64>() {
1191 let secs = ms / 1000;
1192 if let Some(dt) = chrono::DateTime::from_timestamp(secs, 0) {
1193 return dt.format("%Y-%m-%d").to_string();
1194 }
1195 }
1196 timestamp.to_string()
1198}
1199
1200fn is_block_attrs_line(line: &str) -> bool {
1202 let trimmed = line.trim();
1203 if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
1204 return false;
1205 }
1206 if let Some((_, attrs)) = parse_attrs(trimmed, 0) {
1207 attrs.get("align").is_some()
1209 || attrs.get("indent").is_some()
1210 || attrs.get("breakout").is_some()
1211 || attrs.get("breakoutWidth").is_some()
1212 || attrs.get("localId").is_some()
1213 } else {
1214 false
1215 }
1216}
1217
1218fn parse_decision_items(text: &str) -> Vec<AdfNode> {
1221 let mut items = Vec::new();
1222 for line in text.lines() {
1223 let trimmed = line.trim();
1224 if let Some(rest) = trimmed.strip_prefix("- <> ") {
1225 let inline_nodes = parse_inline(rest);
1226 items.push(AdfNode::decision_item(
1227 "DECIDED",
1228 vec![AdfNode::paragraph(inline_nodes)],
1229 ));
1230 }
1231 }
1232 items
1233}
1234
1235fn try_parse_task_marker(text: &str) -> Option<(&str, &str)> {
1238 if let Some(rest) = text.strip_prefix("[ ] ") {
1239 Some(("TODO", rest))
1240 } else if let Some(rest) = text
1241 .strip_prefix("[x] ")
1242 .or_else(|| text.strip_prefix("[X] "))
1243 {
1244 Some(("DONE", rest))
1245 } else {
1246 None
1247 }
1248}
1249
1250fn parse_ordered_list_marker(line: &str) -> Option<(u32, &str)> {
1252 let digit_end = line.find(|c: char| !c.is_ascii_digit())?;
1253 if digit_end == 0 {
1254 return None;
1255 }
1256 let rest = &line[digit_end..];
1257 let after_marker = rest.strip_prefix(". ")?;
1258 let num: u32 = line[..digit_end].parse().ok()?;
1259 Some((num, after_marker))
1260}
1261
1262fn has_trailing_hard_break(line: &str) -> bool {
1265 line.ends_with('\\') || line.ends_with(" ")
1266}
1267
1268fn is_list_start(line: &str) -> bool {
1270 let trimmed = line.trim_start();
1271 trimmed.starts_with("- ")
1272 || trimmed.starts_with("* ")
1273 || trimmed.starts_with("+ ")
1274 || parse_ordered_list_marker(trimmed).is_some()
1275}
1276
1277fn escape_emphasis_markers(text: &str) -> String {
1285 let mut out = String::with_capacity(text.len());
1286 for ch in text.chars() {
1287 if ch == '*' {
1288 out.push('\\');
1289 }
1290 out.push(ch);
1291 }
1292 out
1293}
1294
1295fn escape_backticks(text: &str) -> String {
1301 let mut out = String::with_capacity(text.len());
1302 for ch in text.chars() {
1303 if ch == '`' {
1304 out.push('\\');
1305 }
1306 out.push(ch);
1307 }
1308 out
1309}
1310
1311fn escape_link_brackets(text: &str) -> String {
1316 let mut out = String::with_capacity(text.len());
1317 for ch in text.chars() {
1318 if ch == '[' || ch == ']' {
1319 out.push('\\');
1320 }
1321 out.push(ch);
1322 }
1323 out
1324}
1325
1326fn escape_bare_urls(text: &str) -> String {
1332 let mut result = String::with_capacity(text.len());
1333 for (i, ch) in text.char_indices() {
1334 if ch == 'h' {
1335 let rest = &text[i..];
1336 if rest.starts_with("http://") || rest.starts_with("https://") {
1337 result.push('\\');
1338 }
1339 }
1340 result.push(ch);
1341 }
1342 result
1343}
1344
1345fn escape_emoji_shortcodes(text: &str) -> String {
1350 let mut result = String::with_capacity(text.len());
1351
1352 for (i, ch) in text.char_indices() {
1353 if ch == ':' {
1354 let after = i + 1;
1356 if after < text.len() {
1357 let name_end = text[after..]
1358 .find(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '+' && c != '-')
1359 .map_or(text[after..].len(), |pos| pos);
1360 if name_end > 0
1361 && after + name_end < text.len()
1362 && text.as_bytes()[after + name_end] == b':'
1363 {
1364 result.push('\\');
1366 }
1367 }
1368 }
1369 result.push(ch);
1370 }
1371
1372 result
1373}
1374
1375fn escape_list_marker(line: &str) -> String {
1379 if let Some(dot_pos) = line.find(". ") {
1380 if parse_ordered_list_marker(line).is_some() {
1381 let mut s = String::with_capacity(line.len() + 1);
1382 s.push_str(&line[..dot_pos]);
1383 s.push('\\');
1384 s.push_str(&line[dot_pos..]);
1385 return s;
1386 }
1387 }
1388 for prefix in &["- ", "* ", "+ "] {
1389 if line.starts_with(prefix) {
1390 let mut s = String::with_capacity(line.len() + 1);
1391 s.push('\\');
1392 s.push_str(line);
1393 return s;
1394 }
1395 }
1396 line.to_string()
1397}
1398
1399fn is_horizontal_rule(line: &str) -> bool {
1401 let trimmed = line.trim();
1402 trimmed.len() >= 3
1403 && ((trimmed.starts_with("---") && trimmed.chars().all(|c| c == '-'))
1404 || (trimmed.starts_with("***") && trimmed.chars().all(|c| c == '*'))
1405 || (trimmed.starts_with("___") && trimmed.chars().all(|c| c == '_')))
1406}
1407
1408fn is_table_separator(line: &str) -> bool {
1410 let trimmed = line.trim();
1411 trimmed.contains('|')
1412 && trimmed
1413 .chars()
1414 .all(|c| c == '|' || c == '-' || c == ':' || c == ' ')
1415}
1416
1417fn parse_table_row(line: &str) -> Vec<String> {
1419 let trimmed = line.trim();
1420 let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1421 let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1422
1423 trimmed
1424 .split('|')
1425 .map(|s| {
1426 let s = s.strip_prefix(' ').unwrap_or(s);
1429 let s = s.strip_suffix(' ').unwrap_or(s);
1430 s.to_string()
1431 })
1432 .collect()
1433}
1434
1435fn parse_table_alignments(separator_line: &str) -> Vec<Option<&'static str>> {
1438 let trimmed = separator_line.trim();
1439 let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1440 let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1441
1442 trimmed
1443 .split('|')
1444 .map(|cell| {
1445 let cell = cell.trim();
1446 let starts_colon = cell.starts_with(':');
1447 let ends_colon = cell.ends_with(':');
1448 match (starts_colon, ends_colon) {
1449 (true, true) => Some("center"),
1450 (false, true) => Some("end"),
1451 _ => None, }
1453 })
1454 .collect()
1455}
1456
1457fn apply_column_alignment(para: &mut AdfNode, alignment: Option<&str>) {
1459 if let Some(align) = alignment {
1460 para.marks = Some(vec![AdfMark::alignment(align)]);
1461 }
1462}
1463
1464fn extract_cell_attrs(cell_text: &str) -> (String, Option<serde_json::Value>) {
1467 let trimmed = cell_text.trim_start();
1468 if !trimmed.starts_with('{') {
1469 return (cell_text.to_string(), None);
1470 }
1471 if let Some((end_pos, attrs)) = parse_attrs(trimmed, 0) {
1472 let remaining = trimmed[end_pos..].trim_start().to_string();
1473 let adf_attrs = build_cell_attrs(&attrs);
1474 (remaining, Some(adf_attrs))
1475 } else {
1476 (cell_text.to_string(), None)
1477 }
1478}
1479
1480fn try_parse_media_single_from_line(line: &str) -> Option<AdfNode> {
1483 let line = line.trim();
1484 if !line.starts_with("? + 1; let img_end = find_closing_paren(line, paren_open)? + 1;
1493 let after_img = line[img_end..].trim_start();
1494
1495 if after_img.starts_with('{') {
1496 if let Some((_, attrs)) = parse_attrs(after_img, 0) {
1497 if attrs.get("type") == Some("file") || attrs.get("id").is_some() {
1499 let mut media_attrs = serde_json::json!({"type": "file"});
1500 if let Some(id) = attrs.get("id") {
1501 media_attrs["id"] = serde_json::Value::String(id.to_string());
1502 }
1503 if let Some(collection) = attrs.get("collection") {
1504 media_attrs["collection"] = serde_json::Value::String(collection.to_string());
1505 }
1506 if let Some(height) = attrs.get("height") {
1507 if let Ok(h) = height.parse::<u64>() {
1508 media_attrs["height"] = serde_json::json!(h);
1509 }
1510 }
1511 if let Some(width) = attrs.get("width") {
1512 if let Ok(w) = width.parse::<u64>() {
1513 media_attrs["width"] = serde_json::json!(w);
1514 }
1515 }
1516 if let Some(alt_text) = alt_opt {
1517 media_attrs["alt"] = serde_json::Value::String(alt_text.to_string());
1518 }
1519 if let Some(local_id) = attrs.get("localId") {
1520 media_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1521 }
1522 let mut ms_attrs = serde_json::json!({"layout": "center"});
1523 if let Some(layout) = attrs.get("layout") {
1524 ms_attrs["layout"] = serde_json::Value::String(layout.to_string());
1525 }
1526 if let Some(ms_width) = attrs.get("mediaWidth") {
1527 if let Ok(w) = ms_width.parse::<u64>() {
1528 ms_attrs["width"] = serde_json::json!(w);
1529 }
1530 }
1531 if let Some(wt) = attrs.get("widthType") {
1532 ms_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1533 }
1534 if let Some(mode) = attrs.get("mode") {
1535 ms_attrs["mode"] = serde_json::Value::String(mode.to_string());
1536 }
1537 let border_marks = build_border_marks(&attrs);
1538 let media_marks = if border_marks.is_empty() {
1539 None
1540 } else {
1541 Some(border_marks)
1542 };
1543 return Some(AdfNode {
1544 node_type: "mediaSingle".to_string(),
1545 attrs: Some(ms_attrs),
1546 content: Some(vec![AdfNode {
1547 node_type: "media".to_string(),
1548 attrs: Some(media_attrs),
1549 content: None,
1550 text: None,
1551 marks: media_marks,
1552 local_id: None,
1553 parameters: None,
1554 }]),
1555 text: None,
1556 marks: None,
1557 local_id: None,
1558 parameters: None,
1559 });
1560 }
1561
1562 let mut node = AdfNode::media_single(url, alt_opt);
1564 if let Some(ref mut node_attrs) = node.attrs {
1565 if let Some(layout) = attrs.get("layout") {
1566 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
1567 }
1568 if let Some(width) = attrs.get("width") {
1569 if let Ok(w) = width.parse::<u64>() {
1570 node_attrs["width"] = serde_json::json!(w);
1571 }
1572 }
1573 if let Some(wt) = attrs.get("widthType") {
1574 node_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1575 }
1576 if let Some(mode) = attrs.get("mode") {
1577 node_attrs["mode"] = serde_json::Value::String(mode.to_string());
1578 }
1579 }
1580 if let Some(ref mut content) = node.content {
1581 if let Some(media) = content.first_mut() {
1582 if let Some(local_id) = attrs.get("localId") {
1583 if let Some(ref mut media_attrs) = media.attrs {
1584 media_attrs["localId"] =
1585 serde_json::Value::String(local_id.to_string());
1586 }
1587 }
1588 let border_marks = build_border_marks(&attrs);
1589 if !border_marks.is_empty() {
1590 media.marks = Some(border_marks);
1591 }
1592 }
1593 }
1594 return Some(node);
1595 }
1596 }
1597
1598 Some(AdfNode::media_single(url, alt_opt))
1599}
1600
1601fn parse_image_syntax(line: &str) -> Option<(&str, &str)> {
1603 let line = line.trim();
1604 if !line.starts_with("?;
1609 let alt = &line[2..alt_end];
1610 let paren_start = alt_end + 1; let url_end = find_closing_paren(line, paren_start)?;
1612 let url = &line[paren_start + 1..url_end];
1613
1614 Some((alt, url))
1615}
1616
1617fn parse_inline(text: &str) -> Vec<AdfNode> {
1621 let mut nodes = Vec::new();
1622 let mut chars = text.char_indices().peekable();
1623 let mut plain_start = 0;
1624
1625 while let Some(&(i, ch)) = chars.peek() {
1626 match ch {
1627 '*' | '_' => {
1628 if let Some((end, content, is_bold)) = try_parse_emphasis(text, i) {
1629 flush_plain(text, plain_start, i, &mut nodes);
1630 let mark = if is_bold {
1631 AdfMark::strong()
1632 } else {
1633 AdfMark::em()
1634 };
1635 let inner = parse_inline(content);
1636 for mut node in inner {
1637 prepend_mark(&mut node, mark.clone());
1638 nodes.push(node);
1639 }
1640 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1642 chars.next();
1643 }
1644 plain_start = end;
1645 continue;
1646 }
1647 if ch == '_' {
1652 while chars.peek().is_some_and(|&(_, c)| c == '_') {
1653 chars.next();
1654 }
1655 } else {
1656 chars.next();
1657 }
1658 }
1659 '~' => {
1660 if let Some((end, content)) = try_parse_strikethrough(text, i) {
1661 flush_plain(text, plain_start, i, &mut nodes);
1662 let inner = parse_inline(content);
1663 for mut node in inner {
1664 prepend_mark(&mut node, AdfMark::strike());
1665 nodes.push(node);
1666 }
1667 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1668 chars.next();
1669 }
1670 plain_start = end;
1671 continue;
1672 }
1673 chars.next();
1674 }
1675 '`' => {
1676 if let Some((end, content)) = try_parse_inline_code(text, i) {
1677 flush_plain(text, plain_start, i, &mut nodes);
1678 nodes.push(AdfNode::text_with_marks(content, vec![AdfMark::code()]));
1679 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1680 chars.next();
1681 }
1682 plain_start = end;
1683 continue;
1684 }
1685 chars.next();
1686 }
1687 '[' => {
1688 if let Some((end, link_text, href)) = try_parse_link(text, i) {
1689 flush_plain(text, plain_start, i, &mut nodes);
1690 if link_text.starts_with("http://") || link_text.starts_with("https://") {
1691 nodes.push(AdfNode::text_with_marks(
1696 link_text,
1697 vec![AdfMark::link(href)],
1698 ));
1699 } else {
1700 let inner = parse_inline(link_text);
1701 for mut node in inner {
1702 prepend_mark(&mut node, AdfMark::link(href));
1703 nodes.push(node);
1704 }
1705 }
1706 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1707 chars.next();
1708 }
1709 plain_start = end;
1710 continue;
1711 }
1712 if let Some((end, span_nodes)) = try_parse_bracketed_span(text, i) {
1714 flush_plain(text, plain_start, i, &mut nodes);
1715 nodes.extend(span_nodes);
1716 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1717 chars.next();
1718 }
1719 plain_start = end;
1720 continue;
1721 }
1722 chars.next();
1723 }
1724 ':' => {
1725 if let Some(node) = try_dispatch_inline_directive(text, i) {
1727 flush_plain(text, plain_start, i, &mut nodes);
1728 let end = node.1;
1729 nodes.push(node.0);
1730 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1731 chars.next();
1732 }
1733 plain_start = end;
1734 continue;
1735 }
1736 if let Some((end, short_name)) = try_parse_emoji_shortcode(text, i) {
1738 flush_plain(text, plain_start, i, &mut nodes);
1739 let (final_end, emoji_node) = parse_emoji_with_attrs(text, end, short_name);
1740 nodes.push(emoji_node);
1741 while chars.peek().is_some_and(|&(idx, _)| idx < final_end) {
1742 chars.next();
1743 }
1744 plain_start = final_end;
1745 continue;
1746 }
1747 chars.next();
1748 }
1749 ' ' if text[i..].starts_with(" \n") => {
1750 flush_plain(text, plain_start, i, &mut nodes);
1753 nodes.push(AdfNode::hard_break());
1754 while chars.peek().is_some_and(|&(_, c)| c == ' ') {
1756 chars.next();
1757 }
1758 if chars.peek().is_some_and(|&(_, c)| c == '\n') {
1760 chars.next();
1761 }
1762 plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
1763 }
1764 '!' if text[i..].starts_with("![") => {
1765 chars.next();
1769 }
1770 'h' if text[i..].starts_with("http://") || text[i..].starts_with("https://") => {
1771 if let Some((end, url)) = try_parse_bare_url(text, i) {
1772 flush_plain(text, plain_start, i, &mut nodes);
1773 nodes.push(AdfNode::inline_card(url));
1774 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1775 chars.next();
1776 }
1777 plain_start = end;
1778 continue;
1779 }
1780 chars.next();
1781 }
1782 '\\' if text.as_bytes().get(i + 1) == Some(&b'n')
1783 && text.as_bytes().get(i + 2) != Some(&b'\n') =>
1784 {
1785 flush_plain(text, plain_start, i, &mut nodes);
1789 nodes.push(AdfNode::text("\n"));
1790 chars.next(); chars.next(); plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
1793 }
1794 '\\' if i + 1 < text.len() && !text[i..].starts_with("\\\n") => {
1795 flush_plain(text, plain_start, i, &mut nodes);
1800 chars.next(); plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
1805 chars.next(); }
1807 '\\' if text[i..].starts_with("\\\n") => {
1808 flush_plain(text, plain_start, i, &mut nodes);
1810 nodes.push(AdfNode::hard_break());
1811 chars.next(); if chars.peek().is_some_and(|&(_, c)| c == '\n') {
1814 chars.next();
1815 }
1816 plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
1817 }
1818 '\\' if i + 1 == text.len() => {
1819 flush_plain(text, plain_start, i, &mut nodes);
1821 nodes.push(AdfNode::hard_break());
1822 chars.next(); plain_start = text.len();
1824 }
1825 _ => {
1826 chars.next();
1827 }
1828 }
1829 }
1830
1831 if plain_start < text.len() {
1833 let remaining = &text[plain_start..];
1834 if !remaining.is_empty() {
1835 nodes.push(AdfNode::text(remaining));
1836 }
1837 }
1838
1839 merge_adjacent_text(&mut nodes);
1842
1843 nodes
1844}
1845
1846fn merge_adjacent_text(nodes: &mut Vec<AdfNode>) {
1848 let mut i = 0;
1849 while i + 1 < nodes.len() {
1850 if nodes[i].node_type == "text"
1851 && nodes[i + 1].node_type == "text"
1852 && nodes[i].marks.is_none()
1853 && nodes[i + 1].marks.is_none()
1854 {
1855 let next_text = nodes[i + 1].text.clone().unwrap_or_default();
1856 if let Some(ref mut t) = nodes[i].text {
1857 t.push_str(&next_text);
1858 }
1859 nodes.remove(i + 1);
1860 } else {
1861 i += 1;
1862 }
1863 }
1864}
1865
1866fn flush_plain(text: &str, start: usize, end: usize, nodes: &mut Vec<AdfNode>) {
1868 if start < end {
1869 let plain = &text[start..end];
1870 if !plain.is_empty() {
1871 nodes.push(AdfNode::text(plain));
1872 }
1873 }
1874}
1875
1876#[cfg(test)]
1878fn add_mark(node: &mut AdfNode, mark: AdfMark) {
1879 if let Some(ref mut marks) = node.marks {
1880 marks.push(mark);
1881 } else {
1882 node.marks = Some(vec![mark]);
1883 }
1884}
1885
1886fn prepend_mark(node: &mut AdfNode, mark: AdfMark) {
1888 if let Some(ref mut marks) = node.marks {
1889 marks.insert(0, mark);
1890 } else {
1891 node.marks = Some(vec![mark]);
1892 }
1893}
1894
1895fn is_intraword_underscore(text: &str, delim_pos: usize, len: usize) -> bool {
1900 let before = text[..delim_pos]
1901 .chars()
1902 .next_back()
1903 .is_some_and(char::is_alphanumeric);
1904 let after = text[delim_pos + len..]
1905 .chars()
1906 .next()
1907 .is_some_and(char::is_alphanumeric);
1908 before && after
1909}
1910
1911fn find_unescaped(haystack: &str, needle: &str) -> Option<usize> {
1915 let needle_bytes = needle.as_bytes();
1916 let hay_bytes = haystack.as_bytes();
1917 let mut i = 0;
1918 while i < hay_bytes.len() {
1919 if hay_bytes[i] == b'\\' {
1920 i += 2; continue;
1922 }
1923 if hay_bytes[i..].starts_with(needle_bytes) {
1924 return Some(i);
1925 }
1926 i += 1;
1927 }
1928 None
1929}
1930
1931fn find_unescaped_char(haystack: &str, ch: u8) -> Option<usize> {
1934 let hay_bytes = haystack.as_bytes();
1935 let mut i = 0;
1936 while i < hay_bytes.len() {
1937 if hay_bytes[i] == b'\\' {
1938 i += 2;
1939 continue;
1940 }
1941 if hay_bytes[i] == ch {
1942 return Some(i);
1943 }
1944 i += 1;
1945 }
1946 None
1947}
1948
1949fn try_parse_emphasis(text: &str, i: usize) -> Option<(usize, &str, bool)> {
1959 let rest = &text[i..];
1960
1961 if rest.starts_with("***") || rest.starts_with("___") {
1965 let is_underscore = rest.starts_with("___");
1966 if is_underscore && is_intraword_underscore(text, i, 3) {
1967 return None;
1968 }
1969 let triple = &rest[..3];
1970 let after = &rest[3..];
1971 if let Some(close) = find_unescaped(after, triple) {
1972 if close > 0 {
1973 let close_pos = i + 3 + close;
1974 if is_underscore && is_intraword_underscore(text, close_pos, 3) {
1975 return None;
1976 }
1977 let content = &rest[2..=3 + close];
1981 let end = i + 3 + close + 3;
1982 return Some((end, content, true));
1983 }
1984 }
1985 }
1986
1987 if rest.starts_with("**") || rest.starts_with("__") {
1989 let is_underscore = rest.starts_with("__");
1990 if is_underscore && is_intraword_underscore(text, i, 2) {
1991 return None;
1992 }
1993 let delimiter = &rest[..2];
1994 let after = &rest[2..];
1995 let close = find_unescaped(after, delimiter)?;
1996 if close == 0 {
1997 return None;
1998 }
1999 let close_pos = i + 2 + close;
2000 if is_underscore && is_intraword_underscore(text, close_pos, 2) {
2001 return None;
2002 }
2003 let content = &after[..close];
2004 let end = i + 2 + close + 2;
2005 return Some((end, content, true));
2006 }
2007
2008 if rest.starts_with('*') || rest.starts_with('_') {
2010 let delim_char = rest.as_bytes()[0];
2011 let is_underscore = delim_char == b'_';
2012 if is_underscore && is_intraword_underscore(text, i, 1) {
2013 return None;
2014 }
2015 let after = &rest[1..];
2016 let close = find_unescaped_char(after, delim_char)?;
2017 if close == 0 {
2018 return None;
2019 }
2020 let close_pos = i + 1 + close;
2021 if is_underscore && is_intraword_underscore(text, close_pos, 1) {
2022 return None;
2023 }
2024 let content = &after[..close];
2025 let end = i + 1 + close + 1;
2026 return Some((end, content, false));
2027 }
2028
2029 None
2030}
2031
2032fn try_parse_strikethrough(text: &str, i: usize) -> Option<(usize, &str)> {
2034 let rest = &text[i..];
2035 if !rest.starts_with("~~") {
2036 return None;
2037 }
2038 let after = &rest[2..];
2039 let close = after.find("~~")?;
2040 if close == 0 {
2041 return None;
2042 }
2043 let content = &after[..close];
2044 Some((i + 2 + close + 2, content))
2045}
2046
2047fn try_parse_inline_code(text: &str, i: usize) -> Option<(usize, &str)> {
2049 let rest = &text[i..];
2050 if !rest.starts_with('`') {
2051 return None;
2052 }
2053 let after = &rest[1..];
2054 let close = after.find('`')?;
2055 let content = &after[..close];
2056 Some((i + 1 + close + 1, content))
2057}
2058
2059fn try_parse_bracketed_span(text: &str, i: usize) -> Option<(usize, Vec<AdfNode>)> {
2062 let rest = &text[i..];
2063 if !rest.starts_with('[') {
2064 return None;
2065 }
2066
2067 let mut depth: usize = 0;
2071 let mut bracket_close = None;
2072 let bs_bytes = rest.as_bytes();
2073 for (j, ch) in rest.char_indices() {
2074 match ch {
2075 '\\' if j + 1 < bs_bytes.len()
2076 && (bs_bytes[j + 1] == b'[' || bs_bytes[j + 1] == b']') => {}
2077 '[' if j == 0 || bs_bytes[j - 1] != b'\\' => depth += 1,
2078 ']' if j == 0 || bs_bytes[j - 1] != b'\\' => {
2079 depth -= 1;
2080 if depth == 0 {
2081 bracket_close = Some(j);
2082 break;
2083 }
2084 }
2085 _ => {}
2086 }
2087 }
2088 let bracket_close = bracket_close?;
2089 let after_bracket = &rest[bracket_close + 1..];
2091 if !after_bracket.starts_with('{') {
2092 return None;
2093 }
2094
2095 let span_text = &rest[1..bracket_close];
2096 let attrs_start = i + bracket_close + 1;
2097 let (attrs_end, attrs) = parse_attrs(text, attrs_start)?;
2098
2099 let mut marks = Vec::new();
2100 if attrs.has_flag("underline") {
2101 marks.push(AdfMark::underline());
2102 }
2103 let ann_ids = attrs.get_all("annotation-id");
2104 let ann_types = attrs.get_all("annotation-type");
2105 for (idx, ann_id) in ann_ids.iter().enumerate() {
2106 let ann_type = ann_types.get(idx).copied().unwrap_or("inlineComment");
2107 marks.push(AdfMark::annotation(ann_id, ann_type));
2108 }
2109
2110 if marks.is_empty() {
2111 return None; }
2113
2114 let inner = parse_inline(span_text);
2115 let result: Vec<AdfNode> = inner
2116 .into_iter()
2117 .map(|mut node| {
2118 let mut combined = marks.clone();
2121 if let Some(ref existing) = node.marks {
2122 combined.extend(existing.iter().cloned());
2123 }
2124 node.marks = Some(combined);
2125 node
2126 })
2127 .collect();
2128
2129 Some((attrs_end, result))
2130}
2131
2132fn try_dispatch_inline_directive(text: &str, pos: usize) -> Option<(AdfNode, usize)> {
2135 let d = try_parse_inline_directive(text, pos)?;
2136 let content = d.content.as_deref().unwrap_or("");
2137
2138 let node = match d.name.as_str() {
2139 "card" => {
2140 let mut node = AdfNode::inline_card(content);
2141 pass_through_local_id(&d.attrs, &mut node);
2142 node
2143 }
2144 "status" => {
2145 let color = d
2146 .attrs
2147 .as_ref()
2148 .and_then(|a| a.get("color"))
2149 .unwrap_or("neutral");
2150 let mut node = AdfNode::status(content, color);
2151 if let Some(ref attrs) = d.attrs {
2153 if let Some(ref mut node_attrs) = node.attrs {
2154 if let Some(style) = attrs.get("style") {
2155 node_attrs["style"] = serde_json::Value::String(style.to_string());
2156 }
2157 if let Some(local_id) = attrs.get("localId") {
2158 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2159 }
2160 }
2161 }
2162 node
2163 }
2164 "date" => {
2165 let timestamp = d
2166 .attrs
2167 .as_ref()
2168 .and_then(|a| a.get("timestamp"))
2169 .map_or_else(|| iso_date_to_epoch_ms(content), ToString::to_string);
2170 let mut node = AdfNode::date(×tamp);
2171 pass_through_local_id(&d.attrs, &mut node);
2172 node
2173 }
2174 "mention" => {
2175 let id = d.attrs.as_ref().and_then(|a| a.get("id")).unwrap_or("");
2176 let mut node = AdfNode::mention(id, content);
2177 if let Some(ref attrs) = d.attrs {
2179 if let (Some(ref mut node_attrs), true) = (
2180 &mut node.attrs,
2181 attrs.get("userType").is_some() || attrs.get("accessLevel").is_some(),
2182 ) {
2183 if let Some(ut) = attrs.get("userType") {
2184 node_attrs["userType"] = serde_json::Value::String(ut.to_string());
2185 }
2186 if let Some(al) = attrs.get("accessLevel") {
2187 node_attrs["accessLevel"] = serde_json::Value::String(al.to_string());
2188 }
2189 }
2190 }
2191 pass_through_local_id(&d.attrs, &mut node);
2192 node
2193 }
2194 "span" => {
2195 let mut marks = Vec::new();
2196 if let Some(ref attrs) = d.attrs {
2197 if let Some(color) = attrs.get("color") {
2198 marks.push(AdfMark::text_color(color));
2199 }
2200 if let Some(bg) = attrs.get("bg") {
2201 marks.push(AdfMark::background_color(bg));
2202 }
2203 if attrs.has_flag("sub") {
2204 marks.push(AdfMark::subsup("sub"));
2205 }
2206 if attrs.has_flag("sup") {
2207 marks.push(AdfMark::subsup("sup"));
2208 }
2209 }
2210 if marks.is_empty() {
2211 AdfNode::text(content)
2212 } else {
2213 let inner = parse_inline(content);
2216 let mut nodes: Vec<AdfNode> = inner
2217 .into_iter()
2218 .map(|mut node| {
2219 let mut combined = marks.clone();
2220 if let Some(ref existing) = node.marks {
2221 combined.extend(existing.iter().cloned());
2222 }
2223 node.marks = Some(combined);
2224 node
2225 })
2226 .collect();
2227 nodes.remove(0)
2229 }
2230 }
2231 "placeholder" => AdfNode::placeholder(content),
2232 "media-inline" => {
2233 let mut json_attrs = serde_json::Map::new();
2234 if let Some(ref attrs) = d.attrs {
2235 for key in &["type", "id", "collection", "url", "alt", "width", "height"] {
2236 if let Some(val) = attrs.get(key) {
2237 if *key == "width" || *key == "height" {
2238 if let Ok(n) = val.parse::<u64>() {
2239 json_attrs.insert(
2240 (*key).to_string(),
2241 serde_json::Value::Number(n.into()),
2242 );
2243 continue;
2244 }
2245 }
2246 json_attrs.insert(
2247 (*key).to_string(),
2248 serde_json::Value::String(val.to_string()),
2249 );
2250 }
2251 }
2252 if let Some(local_id) = attrs.get("localId") {
2253 json_attrs.insert(
2254 "localId".to_string(),
2255 serde_json::Value::String(local_id.to_string()),
2256 );
2257 }
2258 }
2259 AdfNode::media_inline(serde_json::Value::Object(json_attrs))
2260 }
2261 "extension" => {
2262 let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
2263 let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
2264 AdfNode::inline_extension(ext_type, ext_key, Some(content))
2265 }
2266 _ => return None, };
2268
2269 Some((node, d.end_pos))
2270}
2271
2272fn try_parse_bare_url(text: &str, i: usize) -> Option<(usize, &str)> {
2275 let rest = &text[i..];
2276 if !rest.starts_with("http://") && !rest.starts_with("https://") {
2277 return None;
2278 }
2279 let end = rest
2281 .find(|c: char| c.is_whitespace() || c == ')' || c == ']' || c == '>')
2282 .unwrap_or(rest.len());
2283 let url = rest[..end].trim_end_matches(['.', ',', ';', '!', '?']);
2285 if url.len() <= "https://".len() {
2286 return None; }
2288 Some((i + url.len(), url))
2289}
2290
2291fn try_parse_emoji_shortcode(text: &str, i: usize) -> Option<(usize, &str)> {
2294 let rest = &text[i..];
2295 if !rest.starts_with(':') {
2296 return None;
2297 }
2298 let after = &rest[1..];
2299 let name_end =
2300 after.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')?;
2301 if name_end == 0 {
2302 return None;
2303 }
2304 if after.as_bytes().get(name_end) != Some(&b':') {
2305 return None;
2306 }
2307 let name = &after[..name_end];
2308 Some((i + 1 + name_end + 1, name))
2309}
2310
2311fn parse_emoji_with_attrs(text: &str, shortcode_end: usize, short_name: &str) -> (usize, AdfNode) {
2314 if let Some((attr_end, attrs)) = parse_attrs(text, shortcode_end) {
2315 let resolved_name = attrs
2318 .get("shortName")
2319 .map_or_else(|| format!(":{short_name}:"), str::to_string);
2320 let mut emoji_attrs = serde_json::json!({"shortName": resolved_name});
2321 if let Some(id) = attrs.get("id") {
2322 emoji_attrs["id"] = serde_json::Value::String(id.to_string());
2323 }
2324 if let Some(t) = attrs.get("text") {
2325 emoji_attrs["text"] = serde_json::Value::String(t.to_string());
2326 }
2327 if let Some(lid) = attrs.get("localId") {
2328 emoji_attrs["localId"] = serde_json::Value::String(lid.to_string());
2329 }
2330 (
2331 attr_end,
2332 AdfNode {
2333 node_type: "emoji".to_string(),
2334 attrs: Some(emoji_attrs),
2335 content: None,
2336 text: None,
2337 marks: None,
2338 local_id: None,
2339 parameters: None,
2340 },
2341 )
2342 } else {
2343 (shortcode_end, AdfNode::emoji(&format!(":{short_name}:")))
2344 }
2345}
2346
2347fn find_closing_paren(s: &str, open: usize) -> Option<usize> {
2352 let mut depth: usize = 0;
2353 for (j, ch) in s[open..].char_indices() {
2354 match ch {
2355 '(' => depth += 1,
2356 ')' => {
2357 depth -= 1;
2358 if depth == 0 {
2359 return Some(open + j);
2360 }
2361 }
2362 _ => {}
2363 }
2364 }
2365 None
2366}
2367
2368fn try_parse_link(text: &str, i: usize) -> Option<(usize, &str, &str)> {
2374 let rest = &text[i..];
2375 if !rest.starts_with('[') {
2376 return None;
2377 }
2378
2379 let mut depth: usize = 0;
2381 let mut text_end = None;
2382 let bytes = rest.as_bytes();
2383 for (j, ch) in rest.char_indices() {
2384 match ch {
2385 '\\' if j + 1 < bytes.len() && (bytes[j + 1] == b'[' || bytes[j + 1] == b']') => {
2386 }
2388 '[' if j == 0 || bytes[j - 1] != b'\\' => depth += 1,
2389 ']' if j == 0 || bytes[j - 1] != b'\\' => {
2390 depth -= 1;
2391 if depth == 0 {
2392 text_end = Some(j);
2393 break;
2394 }
2395 }
2396 _ => {}
2397 }
2398 }
2399
2400 let text_end = text_end?;
2401 let link_text = &rest[1..text_end];
2402 let after_bracket = &rest[text_end + 1..];
2404 if !after_bracket.starts_with('(') {
2405 return None;
2406 }
2407 let url_start = text_end + 1; let url_end = find_closing_paren(rest, url_start)?;
2409 let href = &rest[url_start + 1..url_end];
2410
2411 Some((i + url_end + 1, link_text, href))
2412}
2413
2414#[derive(Debug, Clone, Default)]
2418pub struct RenderOptions {
2419 pub strip_local_ids: bool,
2421}
2422
2423pub fn adf_to_markdown(doc: &AdfDocument) -> Result<String> {
2425 adf_to_markdown_with_options(doc, &RenderOptions::default())
2426}
2427
2428pub fn adf_to_markdown_with_options(doc: &AdfDocument, opts: &RenderOptions) -> Result<String> {
2430 let mut output = String::new();
2431
2432 for (i, node) in doc.content.iter().enumerate() {
2433 if i > 0 {
2434 output.push('\n');
2435 }
2436 render_block_node(node, &mut output, opts);
2437 }
2438
2439 Ok(output)
2440}
2441
2442fn pass_through_local_id(dir_attrs: &Option<crate::atlassian::attrs::Attrs>, node: &mut AdfNode) {
2446 if let Some(ref attrs) = dir_attrs {
2447 if let Some(local_id) = attrs.get("localId") {
2448 if let Some(ref mut node_attrs) = node.attrs {
2449 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2450 } else {
2451 node.attrs = Some(serde_json::json!({"localId": local_id}));
2452 }
2453 }
2454 }
2455}
2456
2457fn pass_through_expand_params(
2460 dir_attrs: &Option<crate::atlassian::attrs::Attrs>,
2461 node: &mut AdfNode,
2462) {
2463 if let Some(ref attrs) = dir_attrs {
2464 if let Some(local_id) = attrs.get("localId") {
2465 node.local_id = Some(local_id.to_string());
2466 }
2467 if let Some(params_str) = attrs.get("params") {
2468 if let Ok(params) = serde_json::from_str(params_str) {
2469 node.parameters = Some(params);
2470 }
2471 }
2472 }
2473}
2474
2475fn extract_trailing_local_id(text: &str) -> (&str, Option<String>, Option<String>) {
2485 let trimmed = text.trim_end();
2486 if !trimmed.ends_with('}') {
2487 return (text, None, None);
2488 }
2489 if let Some(brace_pos) = trimmed.rfind('{') {
2494 if brace_pos > 0 && !trimmed.as_bytes()[brace_pos - 1].is_ascii_whitespace() {
2495 return (text, None, None);
2496 }
2497 let attr_str = &trimmed[brace_pos..];
2498 if let Some((_, attrs)) = parse_attrs(attr_str, 0) {
2499 let local_id = attrs.get("localId").map(str::to_string);
2500 let para_local_id = attrs.get("paraLocalId").map(str::to_string);
2501 if local_id.is_some() || para_local_id.is_some() {
2502 let before = trimmed[..brace_pos]
2503 .strip_suffix(' ')
2504 .unwrap_or(&trimmed[..brace_pos]);
2505 return (before, local_id, para_local_id);
2506 }
2507 }
2508 }
2509 (text, None, None)
2510}
2511
2512fn parse_list_item_first_line(
2519 item_text: &str,
2520 sub_lines: Vec<String>,
2521 local_id: Option<String>,
2522 para_local_id: Option<String>,
2523) -> Result<AdfNode> {
2524 if item_text.starts_with("```") {
2525 let mut all_lines = vec![item_text.to_string()];
2527 all_lines.extend(sub_lines);
2528 let combined = all_lines.join("\n");
2529 let nested = MarkdownParser::new(&combined).parse_blocks()?;
2530 Ok(list_item_with_local_id(nested, local_id, para_local_id))
2531 } else if let Some(media) = try_parse_media_single_from_line(item_text) {
2532 if sub_lines.is_empty() {
2534 Ok(list_item_with_local_id(
2535 vec![media],
2536 local_id,
2537 para_local_id,
2538 ))
2539 } else {
2540 let sub_text = sub_lines.join("\n");
2541 let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
2542 let mut content = vec![media];
2543 content.append(&mut nested);
2544 Ok(list_item_with_local_id(content, local_id, para_local_id))
2545 }
2546 } else {
2547 let first_node = AdfNode::paragraph(parse_inline(item_text));
2548 if sub_lines.is_empty() {
2549 Ok(list_item_with_local_id(
2550 vec![first_node],
2551 local_id,
2552 para_local_id,
2553 ))
2554 } else {
2555 let sub_text = sub_lines.join("\n");
2556 let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
2557 let mut content = vec![first_node];
2558 content.append(&mut nested);
2559 Ok(list_item_with_local_id(content, local_id, para_local_id))
2560 }
2561 }
2562}
2563
2564fn list_item_with_local_id(
2565 mut content: Vec<AdfNode>,
2566 local_id: Option<String>,
2567 para_local_id: Option<String>,
2568) -> AdfNode {
2569 if let Some(id) = ¶_local_id {
2570 if let Some(first) = content.first_mut() {
2571 if first.node_type == "paragraph" {
2572 let node_attrs = first.attrs.get_or_insert_with(|| serde_json::json!({}));
2573 node_attrs["localId"] = serde_json::Value::String(id.clone());
2574 }
2575 }
2576 }
2577 let mut item = AdfNode::list_item(content);
2578 if let Some(id) = local_id {
2579 item.attrs = Some(serde_json::json!({"localId": id}));
2580 }
2581 item
2582}
2583
2584fn maybe_push_local_id(attrs: &serde_json::Value, parts: &mut Vec<String>, opts: &RenderOptions) {
2585 if opts.strip_local_ids {
2586 return;
2587 }
2588 if let Some(local_id) = attrs.get("localId").and_then(serde_json::Value::as_str) {
2589 if !local_id.is_empty() && local_id != "00000000-0000-0000-0000-000000000000" {
2590 parts.push(format!("localId={local_id}"));
2591 }
2592 }
2593}
2594
2595fn render_block_children(children: &[AdfNode], output: &mut String, opts: &RenderOptions) {
2597 for (i, child) in children.iter().enumerate() {
2598 if i > 0 {
2599 output.push('\n');
2600 }
2601 render_block_node(child, output, opts);
2602 }
2603}
2604
2605fn fmt_f64_attr(v: f64) -> String {
2608 if v.fract() == 0.0 {
2609 format!("{}", v as i64)
2610 } else {
2611 v.to_string()
2612 }
2613}
2614
2615fn render_block_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
2617 match node.node_type.as_str() {
2618 "paragraph" => {
2619 let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
2620 let dir_attrs = {
2622 let mut parts = Vec::new();
2623 if let Some(ref attrs) = node.attrs {
2624 maybe_push_local_id(attrs, &mut parts, opts);
2625 }
2626 if parts.is_empty() {
2627 String::new()
2628 } else {
2629 format!("{{{}}}", parts.join(" "))
2630 }
2631 };
2632 if is_empty {
2633 output.push_str(&format!("::paragraph{dir_attrs}\n"));
2634 } else {
2635 let mut buf = String::new();
2637 render_inline_content(node, &mut buf, opts);
2638 if buf.trim().is_empty() && !buf.is_empty() {
2639 output.push_str(&format!("::paragraph[{buf}]{dir_attrs}\n"));
2642 } else {
2643 let mut is_first_line = true;
2648 for line in buf.split('\n') {
2649 if is_first_line {
2650 if is_list_start(line) {
2651 output.push_str(&escape_list_marker(line));
2652 } else {
2653 output.push_str(line);
2654 }
2655 is_first_line = false;
2656 } else {
2657 output.push('\n');
2658 if !line.is_empty() {
2659 output.push_str(" ");
2660 }
2661 output.push_str(line);
2662 }
2663 }
2664 output.push('\n');
2665 }
2666 }
2667 }
2668 "heading" => {
2669 let level = node
2670 .attrs
2671 .as_ref()
2672 .and_then(|a| a.get("level"))
2673 .and_then(serde_json::Value::as_u64)
2674 .unwrap_or(1);
2675 for _ in 0..level {
2676 output.push('#');
2677 }
2678 output.push(' ');
2679 let mut buf = String::new();
2680 render_inline_content(node, &mut buf, opts);
2681 let mut is_first_line = true;
2684 for line in buf.split('\n') {
2685 if is_first_line {
2686 output.push_str(line);
2687 is_first_line = false;
2688 } else {
2689 output.push('\n');
2690 if !line.is_empty() {
2691 output.push_str(" ");
2692 }
2693 output.push_str(line);
2694 }
2695 }
2696 output.push('\n');
2697 }
2698 "codeBlock" => {
2699 let language_value = node.attrs.as_ref().and_then(|a| a.get("language"));
2700 let language = language_value
2701 .and_then(serde_json::Value::as_str)
2702 .unwrap_or("");
2703 output.push_str("```");
2704 if language.is_empty() && language_value.is_some() {
2705 output.push_str("\"\"");
2708 } else {
2709 output.push_str(language);
2710 }
2711 output.push('\n');
2712 if let Some(ref content) = node.content {
2713 for child in content {
2714 if let Some(ref text) = child.text {
2715 output.push_str(text);
2716 }
2717 }
2718 }
2719 output.push_str("\n```\n");
2720 }
2721 "blockquote" => {
2722 if let Some(ref content) = node.content {
2723 for (i, child) in content.iter().enumerate() {
2724 if i > 0
2728 && child.node_type == "paragraph"
2729 && content[i - 1].node_type == "paragraph"
2730 {
2731 output.push_str(">\n");
2732 }
2733 let mut inner = String::new();
2734 render_block_node(child, &mut inner, opts);
2735 for line in inner.lines() {
2736 output.push_str("> ");
2737 output.push_str(line);
2738 output.push('\n');
2739 }
2740 }
2741 }
2742 }
2743 "bulletList" => {
2744 if let Some(ref items) = node.content {
2745 for item in items {
2746 output.push_str("- ");
2747 render_list_item_content(item, output, opts);
2748 }
2749 }
2750 }
2751 "orderedList" => {
2752 let start = node
2753 .attrs
2754 .as_ref()
2755 .and_then(|a| a.get("order"))
2756 .and_then(serde_json::Value::as_u64)
2757 .unwrap_or(1);
2758 if let Some(ref items) = node.content {
2759 for (i, item) in items.iter().enumerate() {
2760 let num = start + i as u64;
2761 output.push_str(&format!("{num}. "));
2762 render_list_item_content(item, output, opts);
2763 }
2764 }
2765 }
2766 "taskList" => {
2767 if let Some(ref items) = node.content {
2768 for item in items {
2769 if item.node_type == "taskList" {
2770 let mut nested = String::new();
2774 render_block_node(item, &mut nested, opts);
2775 for line in nested.lines() {
2776 output.push_str(" ");
2777 output.push_str(line);
2778 output.push('\n');
2779 }
2780 } else {
2781 let state = item
2782 .attrs
2783 .as_ref()
2784 .and_then(|a| a.get("state"))
2785 .and_then(serde_json::Value::as_str)
2786 .unwrap_or("TODO");
2787 if state == "DONE" {
2788 output.push_str("- [x] ");
2789 } else {
2790 output.push_str("- [ ] ");
2791 }
2792 render_list_item_content(item, output, opts);
2793 }
2794 }
2795 }
2796 }
2797 "rule" => {
2798 output.push_str("---\n");
2799 }
2800 "table" => {
2801 render_table(node, output, opts);
2802 }
2803 "mediaSingle" => {
2804 if let Some(ref content) = node.content {
2805 for child in content {
2806 if child.node_type == "media" {
2807 render_media(child, node.attrs.as_ref(), output, opts);
2808 }
2809 }
2810 for child in content {
2811 if child.node_type == "caption" {
2812 let mut cap_parts = Vec::new();
2813 if let Some(ref attrs) = child.attrs {
2814 maybe_push_local_id(attrs, &mut cap_parts, opts);
2815 }
2816 if cap_parts.is_empty() {
2817 output.push_str(":::caption\n");
2818 } else {
2819 output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
2820 }
2821 if let Some(ref caption_content) = child.content {
2822 for inline in caption_content {
2823 render_inline_node(inline, output, opts);
2824 }
2825 output.push('\n');
2826 }
2827 output.push_str(":::\n");
2828 }
2829 }
2830 }
2831 }
2832 "blockCard" => {
2833 if let Some(ref attrs) = node.attrs {
2834 let url = attrs
2835 .get("url")
2836 .and_then(serde_json::Value::as_str)
2837 .unwrap_or("");
2838 output.push_str(&format!("::card[{url}]"));
2839 let mut attr_parts = Vec::new();
2840 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
2841 attr_parts.push(format!("layout={layout}"));
2842 }
2843 if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
2844 attr_parts.push(format!("width={width}"));
2845 }
2846 if !attr_parts.is_empty() {
2847 output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
2848 }
2849 output.push('\n');
2850 }
2851 }
2852 "embedCard" => {
2853 if let Some(ref attrs) = node.attrs {
2854 let url = attrs
2855 .get("url")
2856 .and_then(serde_json::Value::as_str)
2857 .unwrap_or("");
2858 output.push_str(&format!("::embed[{url}]"));
2859 let mut attr_parts = Vec::new();
2860 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
2861 attr_parts.push(format!("layout={layout}"));
2862 }
2863 if let Some(h) = attrs
2864 .get("originalHeight")
2865 .and_then(serde_json::Value::as_f64)
2866 {
2867 attr_parts.push(format!("originalHeight={}", fmt_f64_attr(h)));
2868 }
2869 if let Some(w) = attrs.get("width").and_then(serde_json::Value::as_f64) {
2870 attr_parts.push(format!("width={}", fmt_f64_attr(w)));
2871 }
2872 if !attr_parts.is_empty() {
2873 output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
2874 }
2875 output.push('\n');
2876 }
2877 }
2878 "extension" => {
2879 if let Some(ref attrs) = node.attrs {
2880 let ext_type = attrs
2881 .get("extensionType")
2882 .and_then(serde_json::Value::as_str)
2883 .unwrap_or("");
2884 let ext_key = attrs
2885 .get("extensionKey")
2886 .and_then(serde_json::Value::as_str)
2887 .unwrap_or("");
2888 let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
2889 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
2890 attr_parts.push(format!("layout={layout}"));
2891 }
2892 if let Some(params) = attrs.get("parameters") {
2893 if let Ok(json_str) = serde_json::to_string(params) {
2894 attr_parts.push(format!("params='{json_str}'"));
2895 }
2896 }
2897 maybe_push_local_id(attrs, &mut attr_parts, opts);
2898 output.push_str(&format!("::extension{{{}}}\n", attr_parts.join(" ")));
2899 }
2900 }
2901 "panel" => {
2902 let panel_type = node
2903 .attrs
2904 .as_ref()
2905 .and_then(|a| a.get("panelType"))
2906 .and_then(serde_json::Value::as_str)
2907 .unwrap_or("info");
2908 let mut attr_parts = vec![format!("type={panel_type}")];
2909 if let Some(ref attrs) = node.attrs {
2910 if let Some(icon) = attrs.get("panelIcon").and_then(serde_json::Value::as_str) {
2911 attr_parts.push(format!("icon=\"{icon}\""));
2912 }
2913 if let Some(color) = attrs.get("panelColor").and_then(serde_json::Value::as_str) {
2914 attr_parts.push(format!("color=\"{color}\""));
2915 }
2916 }
2917 output.push_str(&format!(":::panel{{{}}}\n", attr_parts.join(" ")));
2918 if let Some(ref content) = node.content {
2919 render_block_children(content, output, opts);
2920 }
2921 output.push_str(":::\n");
2922 }
2923 "expand" | "nestedExpand" => {
2924 let directive_name = if node.node_type == "nestedExpand" {
2925 "nested-expand"
2926 } else {
2927 "expand"
2928 };
2929 let mut attr_parts = Vec::new();
2930 if let Some(t) = node
2931 .attrs
2932 .as_ref()
2933 .and_then(|a| a.get("title"))
2934 .and_then(serde_json::Value::as_str)
2935 {
2936 attr_parts.push(format!("title=\"{t}\""));
2937 }
2938 if let Some(ref lid) = node.local_id {
2940 if !opts.strip_local_ids && lid != "00000000-0000-0000-0000-000000000000" {
2941 attr_parts.push(format!("localId={lid}"));
2942 }
2943 } else if let Some(ref attrs) = node.attrs {
2944 maybe_push_local_id(attrs, &mut attr_parts, opts);
2945 }
2946 if let Some(ref params) = node.parameters {
2948 if let Ok(json_str) = serde_json::to_string(params) {
2949 attr_parts.push(format!("params='{json_str}'"));
2950 }
2951 }
2952 if attr_parts.is_empty() {
2953 output.push_str(&format!(":::{directive_name}\n"));
2954 } else {
2955 output.push_str(&format!(
2956 ":::{directive_name}{{{}}}\n",
2957 attr_parts.join(" ")
2958 ));
2959 }
2960 if let Some(ref content) = node.content {
2961 render_block_children(content, output, opts);
2962 }
2963 output.push_str(":::\n");
2964 }
2965 "layoutSection" => {
2966 output.push_str("::::layout\n");
2967 if let Some(ref content) = node.content {
2968 for child in content {
2969 if child.node_type == "layoutColumn" {
2970 let width = child
2971 .attrs
2972 .as_ref()
2973 .and_then(|a| a.get("width"))
2974 .and_then(serde_json::Value::as_f64)
2975 .unwrap_or(50.0);
2976 let mut parts = vec![format!("width={width}")];
2977 if let Some(ref attrs) = child.attrs {
2978 maybe_push_local_id(attrs, &mut parts, opts);
2979 }
2980 output.push_str(&format!(":::column{{{}}}\n", parts.join(" ")));
2981 if let Some(ref col_content) = child.content {
2982 render_block_children(col_content, output, opts);
2983 }
2984 output.push_str(":::\n");
2985 }
2986 }
2987 }
2988 output.push_str("::::\n");
2989 }
2990 "decisionList" => {
2991 output.push_str(":::decisions\n");
2992 if let Some(ref content) = node.content {
2993 for item in content {
2994 output.push_str("- <> ");
2995 render_list_item_content(item, output, opts);
2996 }
2997 }
2998 output.push_str(":::\n");
2999 }
3000 "bodiedExtension" => {
3001 if let Some(ref attrs) = node.attrs {
3002 let ext_type = attrs
3003 .get("extensionType")
3004 .and_then(serde_json::Value::as_str)
3005 .unwrap_or("");
3006 let ext_key = attrs
3007 .get("extensionKey")
3008 .and_then(serde_json::Value::as_str)
3009 .unwrap_or("");
3010 let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3011 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3012 attr_parts.push(format!("layout={layout}"));
3013 }
3014 if let Some(params) = attrs.get("parameters") {
3015 if let Ok(json_str) = serde_json::to_string(params) {
3016 attr_parts.push(format!("params='{json_str}'"));
3017 }
3018 }
3019 maybe_push_local_id(attrs, &mut attr_parts, opts);
3020 output.push_str(&format!(":::extension{{{}}}\n", attr_parts.join(" ")));
3021 if let Some(ref content) = node.content {
3022 render_block_children(content, output, opts);
3023 }
3024 output.push_str(":::\n");
3025 }
3026 }
3027 _ => {
3028 if let Ok(json) = serde_json::to_string_pretty(node) {
3030 output.push_str("```adf-unsupported\n");
3031 output.push_str(&json);
3032 output.push_str("\n```\n");
3033 }
3034 }
3035 }
3036
3037 let mut parts = Vec::new();
3039 if let Some(ref marks) = node.marks {
3040 for mark in marks {
3041 match mark.mark_type.as_str() {
3042 "alignment" => {
3043 if let Some(align) = mark
3044 .attrs
3045 .as_ref()
3046 .and_then(|a| a.get("align"))
3047 .and_then(serde_json::Value::as_str)
3048 {
3049 parts.push(format!("align={align}"));
3050 }
3051 }
3052 "indentation" => {
3053 if let Some(level) = mark
3054 .attrs
3055 .as_ref()
3056 .and_then(|a| a.get("level"))
3057 .and_then(serde_json::Value::as_u64)
3058 {
3059 parts.push(format!("indent={level}"));
3060 }
3061 }
3062 "breakout" => {
3063 if let Some(mode) = mark
3064 .attrs
3065 .as_ref()
3066 .and_then(|a| a.get("mode"))
3067 .and_then(serde_json::Value::as_str)
3068 {
3069 parts.push(format!("breakout={mode}"));
3070 }
3071 if let Some(width) = mark
3072 .attrs
3073 .as_ref()
3074 .and_then(|a| a.get("width"))
3075 .and_then(serde_json::Value::as_u64)
3076 {
3077 parts.push(format!("breakoutWidth={width}"));
3078 }
3079 }
3080 _ => {}
3081 }
3082 }
3083 }
3084 let para_used_directive = node.node_type == "paragraph" && {
3088 let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3089 if is_empty {
3090 true
3091 } else {
3092 let mut buf = String::new();
3093 render_inline_content(node, &mut buf, opts);
3094 buf.trim().is_empty() && !buf.is_empty()
3095 }
3096 };
3097 if !matches!(node.node_type.as_str(), "expand" | "nestedExpand") && !para_used_directive {
3098 if let Some(ref attrs) = node.attrs {
3099 maybe_push_local_id(attrs, &mut parts, opts);
3100 }
3101 }
3102 if !parts.is_empty() {
3103 output.push_str(&format!("{{{}}}\n", parts.join(" ")));
3104 }
3105}
3106
3107fn render_list_item_content(item: &AdfNode, output: &mut String, opts: &RenderOptions) {
3115 let Some(ref content) = item.content else {
3116 let bare = AdfNode::text("");
3118 emit_list_item_local_ids(item, &bare, output, opts);
3119 output.push('\n');
3120 return;
3121 };
3122 if content.is_empty() {
3123 let bare = AdfNode::text("");
3124 emit_list_item_local_ids(item, &bare, output, opts);
3125 output.push('\n');
3126 return;
3127 }
3128 let first = &content[0];
3129 let rest_start;
3130 if first.node_type == "paragraph" {
3131 let mut buf = String::new();
3132 render_inline_content(first, &mut buf, opts);
3133 let buf = buf.trim_end_matches('\n');
3139 let mut is_first_line = true;
3142 for line in buf.split('\n') {
3143 if is_first_line {
3144 output.push_str(line);
3145 is_first_line = false;
3146 } else {
3147 output.push('\n');
3148 if !line.is_empty() {
3149 output.push_str(" ");
3150 }
3151 output.push_str(line);
3152 }
3153 }
3154 emit_list_item_local_ids(item, first, output, opts);
3156 output.push('\n');
3157 rest_start = 1;
3158 } else if is_inline_node_type(&first.node_type) {
3159 rest_start = content
3161 .iter()
3162 .position(|c| !is_inline_node_type(&c.node_type))
3163 .unwrap_or(content.len());
3164 let mut buf = String::new();
3165 for child in &content[..rest_start] {
3166 render_inline_node(child, &mut buf, opts);
3167 }
3168 let buf = buf.trim_end_matches('\n');
3171 let mut is_first_line = true;
3172 for line in buf.split('\n') {
3173 if is_first_line {
3174 output.push_str(line);
3175 is_first_line = false;
3176 } else {
3177 output.push('\n');
3178 if !line.is_empty() {
3179 output.push_str(" ");
3180 }
3181 output.push_str(line);
3182 }
3183 }
3184 let bare = AdfNode::text("");
3186 emit_list_item_local_ids(item, &bare, output, opts);
3187 output.push('\n');
3188 } else if first.node_type == "taskItem" {
3191 let bare = AdfNode::text("");
3196 emit_list_item_local_ids(item, &bare, output, opts);
3197 output.push('\n');
3198 for child in content {
3199 if child.node_type == "taskItem" {
3200 let state = child
3201 .attrs
3202 .as_ref()
3203 .and_then(|a| a.get("state"))
3204 .and_then(serde_json::Value::as_str)
3205 .unwrap_or("TODO");
3206 let marker = if state == "DONE" { "- [x] " } else { "- [ ] " };
3207 output.push_str(" ");
3208 output.push_str(marker);
3209 render_list_item_content(child, output, opts);
3210 } else {
3211 let mut nested = String::new();
3212 render_block_node(child, &mut nested, opts);
3213 for line in nested.lines() {
3214 output.push_str(" ");
3215 output.push_str(line);
3216 output.push('\n');
3217 }
3218 }
3219 }
3220 return;
3221 } else {
3222 let mut buf = String::new();
3228 render_block_node(first, &mut buf, opts);
3229 let bare = AdfNode::text("");
3230 let mut is_first = true;
3231 for line in buf.lines() {
3232 if is_first {
3233 output.push_str(line);
3234 emit_list_item_local_ids(item, &bare, output, opts);
3235 output.push('\n');
3236 is_first = false;
3237 } else {
3238 output.push_str(" ");
3239 output.push_str(line);
3240 output.push('\n');
3241 }
3242 }
3243 rest_start = 1;
3244 }
3245 let rest = &content[rest_start..];
3246 for (i, child) in rest.iter().enumerate() {
3247 if child.node_type == "paragraph" {
3251 let prev_is_para = if i == 0 {
3252 first.node_type == "paragraph"
3255 } else {
3256 rest[i - 1].node_type == "paragraph"
3257 };
3258 if prev_is_para {
3259 output.push_str(" \n");
3260 }
3261 }
3262 let mut nested = String::new();
3263 render_block_node(child, &mut nested, opts);
3264 for line in nested.lines() {
3265 output.push_str(" ");
3266 output.push_str(line);
3267 output.push('\n');
3268 }
3269 }
3270}
3271
3272fn is_inline_node_type(node_type: &str) -> bool {
3274 matches!(
3275 node_type,
3276 "text"
3277 | "hardBreak"
3278 | "inlineCard"
3279 | "emoji"
3280 | "mention"
3281 | "status"
3282 | "date"
3283 | "placeholder"
3284 | "mediaInline"
3285 )
3286}
3287
3288fn emit_list_item_local_ids(
3291 item: &AdfNode,
3292 paragraph: &AdfNode,
3293 output: &mut String,
3294 opts: &RenderOptions,
3295) {
3296 if opts.strip_local_ids {
3297 return;
3298 }
3299 let mut parts = Vec::new();
3300 if let Some(ref attrs) = item.attrs {
3301 maybe_push_local_id(attrs, &mut parts, opts);
3302 }
3303 if paragraph.node_type == "paragraph" {
3304 let has_real_id = paragraph
3305 .attrs
3306 .as_ref()
3307 .and_then(|a| a.get("localId"))
3308 .and_then(serde_json::Value::as_str)
3309 .filter(|id| !id.is_empty() && *id != "00000000-0000-0000-0000-000000000000");
3310 if let Some(local_id) = has_real_id {
3311 parts.push(format!("paraLocalId={local_id}"));
3312 } else if item.node_type == "taskItem" {
3313 parts.push("paraLocalId=_".to_string());
3317 }
3318 }
3319 if !parts.is_empty() {
3320 output.push_str(&format!(" {{{}}}", parts.join(" ")));
3321 }
3322}
3323
3324fn render_table(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3326 let Some(ref rows) = node.content else {
3327 return;
3328 };
3329
3330 if table_qualifies_for_pipe_syntax(rows) {
3331 render_pipe_table(node, rows, output, opts);
3332 } else {
3333 render_directive_table(node, rows, output, opts);
3334 }
3335}
3336
3337fn table_qualifies_for_pipe_syntax(rows: &[AdfNode]) -> bool {
3344 if rows.iter().any(|n| n.node_type == "caption") {
3346 return false;
3347 }
3348 let mut first_row_has_header = false;
3349 for (row_idx, row) in rows.iter().enumerate() {
3350 let Some(ref cells) = row.content else {
3351 continue;
3352 };
3353 for cell in cells {
3354 if row_idx > 0 && cell.node_type == "tableHeader" {
3356 return false;
3357 }
3358 if row_idx == 0 && cell.node_type == "tableHeader" {
3359 first_row_has_header = true;
3360 }
3361 let Some(ref content) = cell.content else {
3363 continue;
3364 };
3365 if content.len() != 1 || content[0].node_type != "paragraph" {
3366 return false;
3367 }
3368 if cell_contains_hard_break(&content[0]) {
3371 return false;
3372 }
3373 if cell.marks.as_ref().is_some_and(|m| !m.is_empty()) {
3376 return false;
3377 }
3378 if content[0]
3381 .attrs
3382 .as_ref()
3383 .and_then(|a| a.get("localId"))
3384 .is_some()
3385 {
3386 return false;
3387 }
3388 }
3389 }
3390 first_row_has_header
3393}
3394
3395fn cell_contains_hard_break(paragraph: &AdfNode) -> bool {
3397 paragraph
3398 .content
3399 .as_ref()
3400 .is_some_and(|nodes| nodes.iter().any(|n| n.node_type == "hardBreak"))
3401}
3402
3403fn render_pipe_table(node: &AdfNode, rows: &[AdfNode], output: &mut String, opts: &RenderOptions) {
3405 for (row_idx, row) in rows.iter().enumerate() {
3406 let Some(ref cells) = row.content else {
3407 continue;
3408 };
3409
3410 output.push('|');
3411 for cell in cells {
3412 output.push(' ');
3413 render_cell_attrs_prefix(cell, output);
3414 render_inline_content_from_first_paragraph(cell, output, opts);
3415 output.push_str(" |");
3416 }
3417 output.push('\n');
3418
3419 if row_idx == 0 {
3421 output.push('|');
3422 for cell in cells {
3423 let align = get_cell_paragraph_alignment(cell);
3424 match align {
3425 Some("center") => output.push_str(" :---: |"),
3426 Some("end") => output.push_str(" ---: |"),
3427 _ => output.push_str(" --- |"),
3428 }
3429 }
3430 output.push('\n');
3431 }
3432 }
3433
3434 render_table_level_attrs(node, output, opts);
3436}
3437
3438fn render_directive_table(
3440 node: &AdfNode,
3441 rows: &[AdfNode],
3442 output: &mut String,
3443 opts: &RenderOptions,
3444) {
3445 let mut attr_parts = Vec::new();
3447 if let Some(ref attrs) = node.attrs {
3448 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3449 attr_parts.push(format!("layout={layout}"));
3450 }
3451 if let Some(numbered) = attrs
3452 .get("isNumberColumnEnabled")
3453 .and_then(serde_json::Value::as_bool)
3454 {
3455 if numbered {
3456 attr_parts.push("numbered".to_string());
3457 } else {
3458 attr_parts.push("numbered=false".to_string());
3459 }
3460 }
3461 if let Some(tw) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3462 let tw_str = if tw.fract() == 0.0 {
3463 (tw as u64).to_string()
3464 } else {
3465 tw.to_string()
3466 };
3467 attr_parts.push(format!("width={tw_str}"));
3468 }
3469 maybe_push_local_id(attrs, &mut attr_parts, opts);
3470 }
3471 if attr_parts.is_empty() {
3472 output.push_str("::::table\n");
3473 } else {
3474 output.push_str(&format!("::::table{{{}}}\n", attr_parts.join(" ")));
3475 }
3476
3477 for row in rows {
3478 if row.node_type == "caption" {
3479 let mut cap_parts = Vec::new();
3480 if let Some(ref attrs) = row.attrs {
3481 maybe_push_local_id(attrs, &mut cap_parts, opts);
3482 }
3483 if cap_parts.is_empty() {
3484 output.push_str(":::caption\n");
3485 } else {
3486 output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3487 }
3488 if let Some(ref content) = row.content {
3489 for child in content {
3490 render_inline_node(child, output, opts);
3491 }
3492 output.push('\n');
3493 }
3494 output.push_str(":::\n");
3495 continue;
3496 }
3497 let Some(ref cells) = row.content else {
3498 continue;
3499 };
3500 let mut tr_attrs = Vec::new();
3502 if let Some(ref attrs) = row.attrs {
3503 maybe_push_local_id(attrs, &mut tr_attrs, opts);
3504 }
3505 if tr_attrs.is_empty() {
3506 output.push_str(":::tr\n");
3507 } else {
3508 output.push_str(&format!(":::tr{{{}}}\n", tr_attrs.join(" ")));
3509 }
3510 for cell in cells {
3511 let directive_name = if cell.node_type == "tableHeader" {
3512 "th"
3513 } else {
3514 "td"
3515 };
3516 let mut cell_attr_str = build_cell_attrs_string(cell);
3517 if let Some(ref attrs) = cell.attrs {
3519 let mut lid_parts = Vec::new();
3520 maybe_push_local_id(attrs, &mut lid_parts, opts);
3521 if !lid_parts.is_empty() {
3522 if !cell_attr_str.is_empty() {
3523 cell_attr_str.push(' ');
3524 }
3525 cell_attr_str.push_str(&lid_parts.join(" "));
3526 }
3527 }
3528 if let Some(ref marks) = cell.marks {
3530 for mark in marks {
3531 if mark.mark_type == "border" {
3532 if let Some(ref attrs) = mark.attrs {
3533 if let Some(color) =
3534 attrs.get("color").and_then(serde_json::Value::as_str)
3535 {
3536 if !cell_attr_str.is_empty() {
3537 cell_attr_str.push(' ');
3538 }
3539 cell_attr_str.push_str(&format!("border-color={color}"));
3540 }
3541 if let Some(size) =
3542 attrs.get("size").and_then(serde_json::Value::as_u64)
3543 {
3544 if !cell_attr_str.is_empty() {
3545 cell_attr_str.push(' ');
3546 }
3547 cell_attr_str.push_str(&format!("border-size={size}"));
3548 }
3549 }
3550 }
3551 }
3552 }
3553 let has_marks = cell.marks.as_ref().is_some_and(|m| !m.is_empty());
3554 if cell_attr_str.is_empty() && cell.attrs.is_none() && !has_marks {
3555 output.push_str(&format!(":::{directive_name}\n"));
3556 } else {
3557 output.push_str(&format!(":::{directive_name}{{{cell_attr_str}}}\n"));
3558 }
3559 if let Some(ref content) = cell.content {
3560 render_block_children(content, output, opts);
3561 }
3562 output.push_str(":::\n");
3563 }
3564 output.push_str(":::\n");
3565 }
3566
3567 output.push_str("::::\n");
3568}
3569
3570fn needs_attr_quoting(value: &str) -> bool {
3574 value.contains(|c: char| c.is_whitespace() || c == '}' || c == '(' || c == ')' || c == ',')
3575}
3576
3577fn build_cell_attrs_string(cell: &AdfNode) -> String {
3579 let Some(ref attrs) = cell.attrs else {
3580 return String::new();
3581 };
3582 let mut parts = Vec::new();
3583 if let Some(colspan) = attrs.get("colspan").and_then(serde_json::Value::as_u64) {
3584 parts.push(format!("colspan={colspan}"));
3585 }
3586 if let Some(rowspan) = attrs.get("rowspan").and_then(serde_json::Value::as_u64) {
3587 parts.push(format!("rowspan={rowspan}"));
3588 }
3589 if let Some(bg) = attrs.get("background").and_then(serde_json::Value::as_str) {
3590 if needs_attr_quoting(bg) {
3591 let escaped = bg.replace('\\', "\\\\").replace('"', "\\\"");
3592 parts.push(format!("bg=\"{escaped}\""));
3593 } else {
3594 parts.push(format!("bg={bg}"));
3595 }
3596 }
3597 if let Some(colwidth) = attrs.get("colwidth").and_then(serde_json::Value::as_array) {
3598 let widths: Vec<String> = colwidth
3599 .iter()
3600 .filter_map(|v| {
3601 if let Some(n) = v.as_u64() {
3604 Some(n.to_string())
3605 } else if let Some(n) = v.as_f64() {
3606 if n.fract() == 0.0 {
3607 format!("{n:.1}")
3608 } else {
3609 n.to_string()
3610 }
3611 .into()
3612 } else {
3613 None
3614 }
3615 })
3616 .collect();
3617 if !widths.is_empty() {
3618 parts.push(format!("colwidth={}", widths.join(",")));
3619 }
3620 }
3621 parts.join(" ")
3622}
3623
3624fn render_cell_attrs_prefix(cell: &AdfNode, output: &mut String) {
3626 let Some(ref _attrs) = cell.attrs else {
3627 return;
3628 };
3629 let attr_str = build_cell_attrs_string(cell);
3630 if attr_str.is_empty() {
3631 output.push_str("{} ");
3632 } else {
3633 output.push_str(&format!("{{{attr_str}}} "));
3634 }
3635}
3636
3637fn get_cell_paragraph_alignment(cell: &AdfNode) -> Option<&str> {
3639 let content = cell.content.as_ref()?;
3640 let para = content.first()?;
3641 let marks = para.marks.as_ref()?;
3642 marks.iter().find_map(|m| {
3643 if m.mark_type == "alignment" {
3644 m.attrs
3645 .as_ref()
3646 .and_then(|a| a.get("align"))
3647 .and_then(serde_json::Value::as_str)
3648 } else {
3649 None
3650 }
3651 })
3652}
3653
3654fn render_table_level_attrs(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3656 if let Some(ref attrs) = node.attrs {
3657 let mut parts = Vec::new();
3658 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3659 parts.push(format!("layout={layout}"));
3660 }
3661 if let Some(numbered) = attrs
3662 .get("isNumberColumnEnabled")
3663 .and_then(serde_json::Value::as_bool)
3664 {
3665 if numbered {
3666 parts.push("numbered".to_string());
3667 } else {
3668 parts.push("numbered=false".to_string());
3669 }
3670 }
3671 if let Some(tw) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3672 let tw_str = if tw.fract() == 0.0 {
3673 (tw as u64).to_string()
3674 } else {
3675 tw.to_string()
3676 };
3677 parts.push(format!("width={tw_str}"));
3678 }
3679 maybe_push_local_id(attrs, &mut parts, opts);
3680 if !parts.is_empty() {
3681 output.push_str(&format!("{{{}}}\n", parts.join(" ")));
3682 }
3683 }
3684}
3685
3686fn render_inline_content_from_first_paragraph(
3688 cell: &AdfNode,
3689 output: &mut String,
3690 opts: &RenderOptions,
3691) {
3692 if let Some(ref content) = cell.content {
3693 if let Some(first) = content.first() {
3694 if first.node_type == "paragraph" {
3695 render_inline_content(first, output, opts);
3696 }
3697 }
3698 }
3699}
3700
3701fn push_border_mark_attrs(marks: &Option<Vec<AdfMark>>, parts: &mut Vec<String>) {
3703 if let Some(ref marks) = marks {
3704 for mark in marks {
3705 if mark.mark_type == "border" {
3706 if let Some(ref attrs) = mark.attrs {
3707 if let Some(color) = attrs.get("color").and_then(serde_json::Value::as_str) {
3708 parts.push(format!("border-color={color}"));
3709 }
3710 if let Some(size) = attrs.get("size").and_then(serde_json::Value::as_u64) {
3711 parts.push(format!("border-size={size}"));
3712 }
3713 }
3714 }
3715 }
3716 }
3717}
3718
3719fn render_media(
3721 node: &AdfNode,
3722 parent_attrs: Option<&serde_json::Value>,
3723 output: &mut String,
3724 opts: &RenderOptions,
3725) {
3726 if let Some(ref attrs) = node.attrs {
3727 let media_type = attrs
3728 .get("type")
3729 .and_then(serde_json::Value::as_str)
3730 .unwrap_or("external");
3731 let alt = attrs
3732 .get("alt")
3733 .and_then(serde_json::Value::as_str)
3734 .unwrap_or("");
3735
3736 if media_type == "file" {
3737 output.push_str(&format!("![{alt}]()"));
3739 let mut parts = vec!["type=file".to_string()];
3740 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
3741 parts.push(format!("id={id}"));
3742 }
3743 if let Some(collection) = attrs.get("collection").and_then(serde_json::Value::as_str) {
3744 parts.push(format!("collection={collection}"));
3745 }
3746 if let Some(height) = attrs.get("height").and_then(serde_json::Value::as_u64) {
3747 parts.push(format!("height={height}"));
3748 }
3749 if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
3750 parts.push(format!("width={width}"));
3751 }
3752 maybe_push_local_id(attrs, &mut parts, opts);
3753 if let Some(p_attrs) = parent_attrs {
3755 if let Some(layout) = p_attrs.get("layout").and_then(serde_json::Value::as_str) {
3756 if layout != "center" {
3757 parts.push(format!("layout={layout}"));
3758 }
3759 }
3760 if let Some(ms_width) = p_attrs.get("width").and_then(serde_json::Value::as_u64) {
3761 parts.push(format!("mediaWidth={ms_width}"));
3762 }
3763 if let Some(wt) = p_attrs.get("widthType").and_then(serde_json::Value::as_str) {
3764 parts.push(format!("widthType={wt}"));
3765 }
3766 if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
3767 parts.push(format!("mode={mode}"));
3768 }
3769 }
3770 push_border_mark_attrs(&node.marks, &mut parts);
3771 output.push_str(&format!("{{{}}}", parts.join(" ")));
3772 } else {
3773 let url = attrs
3775 .get("url")
3776 .and_then(serde_json::Value::as_str)
3777 .unwrap_or("");
3778 output.push_str(&format!(""));
3779
3780 {
3782 let mut parts = Vec::new();
3783 if let Some(p_attrs) = parent_attrs {
3784 let layout = p_attrs.get("layout").and_then(serde_json::Value::as_str);
3785 let width = p_attrs.get("width").and_then(serde_json::Value::as_u64);
3786 let width_type = p_attrs.get("widthType").and_then(serde_json::Value::as_str);
3787 if let Some(l) = layout {
3788 if l != "center" {
3789 parts.push(format!("layout={l}"));
3790 }
3791 }
3792 if let Some(w) = width {
3793 parts.push(format!("width={w}"));
3794 }
3795 if let Some(wt) = width_type {
3796 parts.push(format!("widthType={wt}"));
3797 }
3798 if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
3799 parts.push(format!("mode={mode}"));
3800 }
3801 }
3802 maybe_push_local_id(attrs, &mut parts, opts);
3803 push_border_mark_attrs(&node.marks, &mut parts);
3804 if !parts.is_empty() {
3805 output.push_str(&format!("{{{}}}", parts.join(" ")));
3806 }
3807 }
3808 }
3809
3810 output.push('\n');
3811 }
3812}
3813
3814fn render_inline_content(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3816 if let Some(ref content) = node.content {
3817 for child in content {
3818 render_inline_node(child, output, opts);
3819 }
3820 }
3821}
3822
3823fn render_inline_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3825 match node.node_type.as_str() {
3826 "text" => {
3827 let text = node.text.as_deref().unwrap_or("");
3828 let marks = node.marks.as_deref().unwrap_or(&[]);
3829 let has_code = marks.iter().any(|m| m.mark_type == "code");
3830 let owned;
3835 let text = if !has_code {
3836 owned = text.replace('\\', "\\\\");
3837 owned.as_str()
3838 } else {
3839 text
3840 };
3841 let owned_nl;
3846 let text = if text.contains('\n') {
3847 owned_nl = text.replace('\n', "\\n");
3848 owned_nl.as_str()
3849 } else {
3850 text
3851 };
3852 let owned_ts;
3858 let text = if !has_code && text.ends_with(" ") {
3859 let mut s = text.to_string();
3860 s.insert(s.len() - 1, '\\');
3862 owned_ts = s;
3863 owned_ts.as_str()
3864 } else {
3865 text
3866 };
3867 render_marked_text(text, marks, output);
3868 }
3869 "hardBreak" => {
3870 output.push_str("\\\n");
3871 }
3872 other => {
3873 let mut body = String::new();
3877 render_non_text_inline_body(other, node, &mut body, opts);
3878
3879 let annotations: Vec<&AdfMark> = node
3880 .marks
3881 .as_deref()
3882 .unwrap_or(&[])
3883 .iter()
3884 .filter(|m| m.mark_type == "annotation")
3885 .collect();
3886
3887 if annotations.is_empty() {
3888 output.push_str(&body);
3889 } else {
3890 let mut attr_parts = Vec::new();
3891 for ann in &annotations {
3892 if let Some(ref attrs) = ann.attrs {
3893 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
3894 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
3895 attr_parts.push(format!("annotation-id=\"{escaped}\""));
3896 }
3897 if let Some(at) = attrs
3898 .get("annotationType")
3899 .and_then(serde_json::Value::as_str)
3900 {
3901 attr_parts.push(format!("annotation-type={at}"));
3902 }
3903 }
3904 }
3905 output.push('[');
3906 output.push_str(&body);
3907 output.push_str("]{");
3908 output.push_str(&attr_parts.join(" "));
3909 output.push('}');
3910 }
3911 }
3912 }
3913}
3914
3915fn render_non_text_inline_body(
3917 node_type: &str,
3918 node: &AdfNode,
3919 output: &mut String,
3920 opts: &RenderOptions,
3921) {
3922 match node_type {
3923 "inlineCard" => {
3924 if let Some(ref attrs) = node.attrs {
3925 if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
3926 output.push_str(":card[");
3927 output.push_str(url);
3928 output.push(']');
3929 let mut attr_parts = Vec::new();
3930 maybe_push_local_id(attrs, &mut attr_parts, opts);
3931 if !attr_parts.is_empty() {
3932 output.push('{');
3933 output.push_str(&attr_parts.join(" "));
3934 output.push('}');
3935 }
3936 }
3937 }
3938 }
3939 "emoji" => {
3940 if let Some(ref attrs) = node.attrs {
3941 if let Some(short_name) = attrs.get("shortName").and_then(serde_json::Value::as_str)
3942 {
3943 output.push(':');
3944 let name = short_name.strip_prefix(':').unwrap_or(short_name);
3945 let name = name.strip_suffix(':').unwrap_or(name);
3946 output.push_str(name);
3947 output.push(':');
3948
3949 let mut parts = Vec::new();
3950 let escaped_sn = short_name.replace('\\', "\\\\").replace('"', "\\\"");
3951 parts.push(format!("shortName=\"{escaped_sn}\""));
3952 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
3953 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
3954 parts.push(format!("id=\"{escaped}\""));
3955 }
3956 if let Some(text) = attrs.get("text").and_then(serde_json::Value::as_str) {
3957 let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
3958 parts.push(format!("text=\"{escaped}\""));
3959 }
3960 maybe_push_local_id(attrs, &mut parts, opts);
3961 output.push('{');
3962 output.push_str(&parts.join(" "));
3963 output.push('}');
3964 }
3965 }
3966 }
3967 "status" => {
3968 if let Some(ref attrs) = node.attrs {
3969 let text = attrs
3970 .get("text")
3971 .and_then(serde_json::Value::as_str)
3972 .unwrap_or("");
3973 let color = attrs
3974 .get("color")
3975 .and_then(serde_json::Value::as_str)
3976 .unwrap_or("neutral");
3977 let mut attr_parts = vec![format!("color={color}")];
3978 if let Some(style) = attrs.get("style").and_then(serde_json::Value::as_str) {
3979 attr_parts.push(format!("style={style}"));
3980 }
3981 maybe_push_local_id(attrs, &mut attr_parts, opts);
3982 output.push_str(&format!(":status[{text}]{{{}}}", attr_parts.join(" ")));
3983 }
3984 }
3985 "date" => {
3986 if let Some(ref attrs) = node.attrs {
3987 if let Some(timestamp) = attrs.get("timestamp").and_then(serde_json::Value::as_str)
3988 {
3989 let display = epoch_ms_to_iso_date(timestamp);
3990 let mut attr_parts = vec![format!("timestamp={timestamp}")];
3991 maybe_push_local_id(attrs, &mut attr_parts, opts);
3992 output.push_str(&format!(":date[{display}]{{{}}}", attr_parts.join(" ")));
3993 }
3994 }
3995 }
3996 "mention" => {
3997 if let Some(ref attrs) = node.attrs {
3998 let id = attrs
3999 .get("id")
4000 .and_then(serde_json::Value::as_str)
4001 .unwrap_or("");
4002 let text = attrs
4003 .get("text")
4004 .and_then(serde_json::Value::as_str)
4005 .unwrap_or("");
4006 let mut attr_parts = vec![format!("id={id}")];
4007 if let Some(ut) = attrs.get("userType").and_then(serde_json::Value::as_str) {
4008 attr_parts.push(format!("userType={ut}"));
4009 }
4010 if let Some(al) = attrs.get("accessLevel").and_then(serde_json::Value::as_str) {
4011 attr_parts.push(format!("accessLevel={al}"));
4012 }
4013 maybe_push_local_id(attrs, &mut attr_parts, opts);
4014 output.push_str(&format!(":mention[{text}]{{{}}}", attr_parts.join(" ")));
4015 }
4016 }
4017 "placeholder" => {
4018 if let Some(ref attrs) = node.attrs {
4019 let text = attrs
4020 .get("text")
4021 .and_then(serde_json::Value::as_str)
4022 .unwrap_or("");
4023 output.push_str(&format!(":placeholder[{text}]"));
4024 }
4025 }
4026 "inlineExtension" => {
4027 if let Some(ref attrs) = node.attrs {
4028 let ext_type = attrs
4029 .get("extensionType")
4030 .and_then(serde_json::Value::as_str)
4031 .unwrap_or("");
4032 let ext_key = attrs
4033 .get("extensionKey")
4034 .and_then(serde_json::Value::as_str)
4035 .unwrap_or("");
4036 let fallback = node.text.as_deref().unwrap_or("");
4037 output.push_str(&format!(
4038 ":extension[{fallback}]{{type={ext_type} key={ext_key}}}"
4039 ));
4040 }
4041 }
4042 "mediaInline" => {
4043 if let Some(ref attrs) = node.attrs {
4044 let mut attr_parts = Vec::new();
4045 if let Some(media_type) = attrs.get("type").and_then(serde_json::Value::as_str) {
4046 attr_parts.push(format!("type={media_type}"));
4047 }
4048 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4049 attr_parts.push(format!("id={id}"));
4050 }
4051 if let Some(collection) =
4052 attrs.get("collection").and_then(serde_json::Value::as_str)
4053 {
4054 attr_parts.push(format!("collection={collection}"));
4055 }
4056 if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4057 attr_parts.push(format!("url={url}"));
4058 }
4059 if let Some(alt) = attrs.get("alt").and_then(serde_json::Value::as_str) {
4060 attr_parts.push(format!("alt={alt}"));
4061 }
4062 if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
4063 attr_parts.push(format!("width={width}"));
4064 }
4065 if let Some(height) = attrs.get("height").and_then(serde_json::Value::as_u64) {
4066 attr_parts.push(format!("height={height}"));
4067 }
4068 maybe_push_local_id(attrs, &mut attr_parts, opts);
4069 output.push_str(&format!(":media-inline[]{{{}}}", attr_parts.join(" ")));
4070 }
4071 }
4072 _ => {
4073 output.push_str(&format!("<!-- unsupported inline: {} -->", node.node_type));
4074 }
4075 }
4076}
4077
4078fn render_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4085 let link_pos = marks.iter().position(|m| m.mark_type == "link");
4086 let has_link = link_pos.map(|lp| &marks[lp]);
4087 let has_strong = marks.iter().any(|m| m.mark_type == "strong");
4088 let has_em = marks.iter().any(|m| m.mark_type == "em");
4089 let has_code = marks.iter().any(|m| m.mark_type == "code");
4090 let has_strike = marks.iter().any(|m| m.mark_type == "strike");
4091
4092 if has_code {
4093 let annotations: Vec<&AdfMark> = marks
4096 .iter()
4097 .filter(|m| m.mark_type == "annotation")
4098 .collect();
4099
4100 let mut code_str = String::new();
4101 if let Some(link_mark) = has_link {
4102 let href = link_href(link_mark);
4103 code_str.push('[');
4104 code_str.push('`');
4105 code_str.push_str(text);
4106 code_str.push('`');
4107 code_str.push_str("](");
4108 code_str.push_str(href);
4109 code_str.push(')');
4110 } else {
4111 code_str.push('`');
4112 code_str.push_str(text);
4113 code_str.push('`');
4114 }
4115
4116 if annotations.is_empty() {
4117 output.push_str(&code_str);
4118 } else {
4119 let mut attr_parts = Vec::new();
4120 for ann in &annotations {
4121 if let Some(ref attrs) = ann.attrs {
4122 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4123 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4124 attr_parts.push(format!("annotation-id=\"{escaped}\""));
4125 }
4126 if let Some(at) = attrs
4127 .get("annotationType")
4128 .and_then(serde_json::Value::as_str)
4129 {
4130 attr_parts.push(format!("annotation-type={at}"));
4131 }
4132 }
4133 }
4134 output.push('[');
4135 output.push_str(&code_str);
4136 output.push_str("]{");
4137 output.push_str(&attr_parts.join(" "));
4138 output.push('}');
4139 }
4140 return;
4141 }
4142
4143 let is_before_link = |mark_type: &str| -> bool {
4145 if let Some(lp) = link_pos {
4146 marks[..lp].iter().any(|m| m.mark_type == mark_type)
4147 } else {
4148 false
4149 }
4150 };
4151
4152 let mut outer_strike = has_strike && is_before_link("strike");
4154 let mut outer_strong = has_strong && is_before_link("strong");
4155 let mut outer_em = has_em && is_before_link("em");
4156 let inner_strike = has_strike && !outer_strike;
4157 let inner_strong = has_strong && !outer_strong;
4158 let inner_em = has_em && !outer_em;
4159
4160 let mut inner = String::new();
4162 if inner_strike {
4163 inner.push_str("~~");
4164 }
4165 if inner_strong {
4166 inner.push_str("**");
4167 }
4168 if inner_em {
4169 inner.push('*');
4170 }
4171 let escaped = escape_emphasis_markers(text);
4172 let escaped = escape_emoji_shortcodes(&escaped);
4173 let escaped = escape_backticks(&escaped);
4174 let escaped = if has_link.is_some() {
4175 escape_link_brackets(&escaped)
4176 } else {
4177 escape_bare_urls(&escaped)
4178 };
4179 inner.push_str(&escaped);
4180 if inner_em {
4181 inner.push('*');
4182 }
4183 if inner_strong {
4184 inner.push_str("**");
4185 }
4186 if inner_strike {
4187 inner.push_str("~~");
4188 }
4189
4190 let text_color = marks.iter().find(|m| m.mark_type == "textColor");
4192 let bg_color = marks.iter().find(|m| m.mark_type == "backgroundColor");
4193 let subsup = marks.iter().find(|m| m.mark_type == "subsup");
4194 let has_underline = marks.iter().any(|m| m.mark_type == "underline");
4195 let annotations: Vec<&AdfMark> = marks
4196 .iter()
4197 .filter(|m| m.mark_type == "annotation")
4198 .collect();
4199
4200 let needs_span = text_color.is_some() || bg_color.is_some() || subsup.is_some();
4201
4202 let mut core = String::new();
4204 if needs_span {
4205 let mut attr_parts = Vec::new();
4207 if let Some(m) = text_color {
4208 if let Some(c) = m
4209 .attrs
4210 .as_ref()
4211 .and_then(|a| a.get("color"))
4212 .and_then(serde_json::Value::as_str)
4213 {
4214 attr_parts.push(format!("color={c}"));
4215 }
4216 }
4217 if let Some(m) = bg_color {
4218 if let Some(c) = m
4219 .attrs
4220 .as_ref()
4221 .and_then(|a| a.get("color"))
4222 .and_then(serde_json::Value::as_str)
4223 {
4224 attr_parts.push(format!("bg={c}"));
4225 }
4226 }
4227 if let Some(m) = subsup {
4228 if let Some(kind) = m
4229 .attrs
4230 .as_ref()
4231 .and_then(|a| a.get("type"))
4232 .and_then(serde_json::Value::as_str)
4233 {
4234 attr_parts.push(kind.to_string());
4235 }
4236 }
4237 let span = format!(":span[{inner}]{{{}}}", attr_parts.join(" "));
4238 if let Some(link_mark) = has_link {
4239 let href = link_href(link_mark);
4240 if is_before_link("textColor")
4241 || is_before_link("backgroundColor")
4242 || is_before_link("subsup")
4243 {
4244 let link_part = format!("[{inner}]({href})");
4246 core = format!(":span[{link_part}]{{{}}}", attr_parts.join(" "));
4247 } else {
4248 core.push('[');
4250 core.push_str(&span);
4251 core.push_str("](");
4252 core.push_str(href);
4253 core.push(')');
4254 }
4255 } else {
4256 core.push_str(&span);
4257 }
4258 } else if has_underline || !annotations.is_empty() {
4259 let mut attr_parts = Vec::new();
4260 if has_underline {
4261 attr_parts.push("underline".to_string());
4262 }
4263 for ann in &annotations {
4264 if let Some(ref attrs) = ann.attrs {
4265 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4266 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4267 attr_parts.push(format!("annotation-id=\"{escaped}\""));
4268 }
4269 if let Some(at) = attrs
4270 .get("annotationType")
4271 .and_then(serde_json::Value::as_str)
4272 {
4273 attr_parts.push(format!("annotation-type={at}"));
4274 }
4275 }
4276 }
4277 let bracketed = format!("[{inner}]{{{}}}", attr_parts.join(" "));
4278 if let Some(link_mark) = has_link {
4279 let href = link_href(link_mark);
4280 if is_before_link("underline")
4281 || link_pos
4282 .is_some_and(|lp| marks[..lp].iter().any(|m| m.mark_type == "annotation"))
4283 {
4284 let underline_pos = marks.iter().position(|m| m.mark_type == "underline");
4289 let bracket_inner_strike = outer_strike
4290 && underline_pos.is_some_and(|up| {
4291 marks
4292 .iter()
4293 .position(|m| m.mark_type == "strike")
4294 .is_some_and(|sp| sp > up)
4295 });
4296 let bracket_inner_strong = outer_strong
4297 && underline_pos.is_some_and(|up| {
4298 marks
4299 .iter()
4300 .position(|m| m.mark_type == "strong")
4301 .is_some_and(|sp| sp > up)
4302 });
4303 let bracket_inner_em = outer_em
4304 && underline_pos.is_some_and(|up| {
4305 marks
4306 .iter()
4307 .position(|m| m.mark_type == "em")
4308 .is_some_and(|sp| sp > up)
4309 });
4310
4311 let mut bracket_content = String::new();
4312 if bracket_inner_strike {
4313 bracket_content.push_str("~~");
4314 }
4315 if bracket_inner_strong {
4316 bracket_content.push_str("**");
4317 }
4318 if bracket_inner_em {
4319 bracket_content.push('*');
4320 }
4321 bracket_content.push_str(&format!("[{inner}]({href})"));
4322 if bracket_inner_em {
4323 bracket_content.push('*');
4324 }
4325 if bracket_inner_strong {
4326 bracket_content.push_str("**");
4327 }
4328 if bracket_inner_strike {
4329 bracket_content.push_str("~~");
4330 }
4331
4332 if bracket_inner_strike {
4333 outer_strike = false;
4334 }
4335 if bracket_inner_strong {
4336 outer_strong = false;
4337 }
4338 if bracket_inner_em {
4339 outer_em = false;
4340 }
4341
4342 core = format!("[{bracket_content}]{{{}}}", attr_parts.join(" "));
4343 } else {
4344 core.push('[');
4346 core.push_str(&bracketed);
4347 core.push_str("](");
4348 core.push_str(href);
4349 core.push(')');
4350 }
4351 } else {
4352 core.push_str(&bracketed);
4353 }
4354 } else if let Some(link_mark) = has_link {
4355 let href = link_href(link_mark);
4356 core.push('[');
4357 core.push_str(&inner);
4358 core.push_str("](");
4359 core.push_str(href);
4360 core.push(')');
4361 } else {
4362 core.push_str(&inner);
4363 }
4364
4365 if outer_strike {
4367 output.push_str("~~");
4368 }
4369 if outer_strong {
4370 output.push_str("**");
4371 }
4372 if outer_em {
4373 output.push('*');
4374 }
4375 output.push_str(&core);
4376 if outer_em {
4377 output.push('*');
4378 }
4379 if outer_strong {
4380 output.push_str("**");
4381 }
4382 if outer_strike {
4383 output.push_str("~~");
4384 }
4385}
4386
4387fn link_href(mark: &AdfMark) -> &str {
4389 mark.attrs
4390 .as_ref()
4391 .and_then(|a| a.get("href"))
4392 .and_then(serde_json::Value::as_str)
4393 .unwrap_or("")
4394}
4395
4396#[cfg(test)]
4397#[allow(clippy::unwrap_used, clippy::expect_used)]
4398mod tests {
4399 use super::*;
4400
4401 #[test]
4404 fn paragraph() {
4405 let doc = markdown_to_adf("Hello world").unwrap();
4406 assert_eq!(doc.content.len(), 1);
4407 assert_eq!(doc.content[0].node_type, "paragraph");
4408 }
4409
4410 #[test]
4411 fn heading_levels() {
4412 for level in 1..=6 {
4413 let hashes = "#".repeat(level);
4414 let md = format!("{hashes} Title");
4415 let doc = markdown_to_adf(&md).unwrap();
4416 assert_eq!(doc.content[0].node_type, "heading");
4417 let attrs = doc.content[0].attrs.as_ref().unwrap();
4418 assert_eq!(attrs["level"], level as u64);
4419 }
4420 }
4421
4422 #[test]
4423 fn code_block() {
4424 let md = "```rust\nfn main() {}\n```";
4425 let doc = markdown_to_adf(md).unwrap();
4426 assert_eq!(doc.content[0].node_type, "codeBlock");
4427 let attrs = doc.content[0].attrs.as_ref().unwrap();
4428 assert_eq!(attrs["language"], "rust");
4429 }
4430
4431 #[test]
4432 fn code_block_no_language() {
4433 let md = "```\nsome code\n```";
4434 let doc = markdown_to_adf(md).unwrap();
4435 assert_eq!(doc.content[0].node_type, "codeBlock");
4436 assert!(doc.content[0].attrs.is_none());
4437 }
4438
4439 #[test]
4440 fn code_block_empty_language() {
4441 let md = "```\"\"\nsome code\n```";
4442 let doc = markdown_to_adf(md).unwrap();
4443 assert_eq!(doc.content[0].node_type, "codeBlock");
4444 let attrs = doc.content[0].attrs.as_ref().unwrap();
4445 assert_eq!(attrs["language"], "");
4446 }
4447
4448 #[test]
4449 fn horizontal_rule() {
4450 let doc = markdown_to_adf("---").unwrap();
4451 assert_eq!(doc.content[0].node_type, "rule");
4452 }
4453
4454 #[test]
4455 fn horizontal_rule_stars() {
4456 let doc = markdown_to_adf("***").unwrap();
4457 assert_eq!(doc.content[0].node_type, "rule");
4458 }
4459
4460 #[test]
4461 fn blockquote() {
4462 let md = "> This is a quote\n> Second line";
4463 let doc = markdown_to_adf(md).unwrap();
4464 assert_eq!(doc.content[0].node_type, "blockquote");
4465 }
4466
4467 #[test]
4468 fn bullet_list() {
4469 let md = "- Item 1\n- Item 2\n- Item 3";
4470 let doc = markdown_to_adf(md).unwrap();
4471 assert_eq!(doc.content[0].node_type, "bulletList");
4472 let items = doc.content[0].content.as_ref().unwrap();
4473 assert_eq!(items.len(), 3);
4474 }
4475
4476 #[test]
4477 fn ordered_list() {
4478 let md = "1. First\n2. Second\n3. Third";
4479 let doc = markdown_to_adf(md).unwrap();
4480 assert_eq!(doc.content[0].node_type, "orderedList");
4481 let items = doc.content[0].content.as_ref().unwrap();
4482 assert_eq!(items.len(), 3);
4483 }
4484
4485 #[test]
4486 fn task_list() {
4487 let md = "- [ ] Todo item\n- [x] Done item";
4488 let doc = markdown_to_adf(md).unwrap();
4489 assert_eq!(doc.content[0].node_type, "taskList");
4490 let items = doc.content[0].content.as_ref().unwrap();
4491 assert_eq!(items.len(), 2);
4492 assert_eq!(items[0].node_type, "taskItem");
4493 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
4494 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
4495 }
4496
4497 #[test]
4498 fn task_list_uppercase_x() {
4499 let md = "- [X] Done item";
4500 let doc = markdown_to_adf(md).unwrap();
4501 assert_eq!(doc.content[0].node_type, "taskList");
4502 let item = &doc.content[0].content.as_ref().unwrap()[0];
4503 assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
4504 }
4505
4506 #[test]
4507 fn adf_task_list_to_markdown() {
4508 let doc = AdfDocument {
4509 version: 1,
4510 doc_type: "doc".to_string(),
4511 content: vec![AdfNode::task_list(vec![
4512 AdfNode::task_item(
4513 "TODO",
4514 vec![AdfNode::paragraph(vec![AdfNode::text("Todo")])],
4515 ),
4516 AdfNode::task_item(
4517 "DONE",
4518 vec![AdfNode::paragraph(vec![AdfNode::text("Done")])],
4519 ),
4520 ])],
4521 };
4522 let md = adf_to_markdown(&doc).unwrap();
4523 assert!(md.contains("- [ ] Todo"));
4524 assert!(md.contains("- [x] Done"));
4525 }
4526
4527 #[test]
4528 fn round_trip_task_list() {
4529 let md = "- [ ] Todo item\n- [x] Done item\n";
4530 let doc = markdown_to_adf(md).unwrap();
4531 let result = adf_to_markdown(&doc).unwrap();
4532 assert!(result.contains("- [ ] Todo item"));
4533 assert!(result.contains("- [x] Done item"));
4534 }
4535
4536 #[test]
4538 fn adf_task_item_unwrapped_inline_content() {
4539 let json = r#"{
4541 "version": 1,
4542 "type": "doc",
4543 "content": [{
4544 "type": "taskList",
4545 "attrs": {"localId": "list-001"},
4546 "content": [{
4547 "type": "taskItem",
4548 "attrs": {"localId": "task-001", "state": "TODO"},
4549 "content": [{"type": "text", "text": "Do something"}]
4550 }]
4551 }]
4552 }"#;
4553 let doc: AdfDocument = serde_json::from_str(json).unwrap();
4554 let md = adf_to_markdown(&doc).unwrap();
4555 assert!(md.contains("- [ ] Do something"), "got: {md}");
4556 assert!(!md.contains("adf-unsupported"), "got: {md}");
4557 }
4558
4559 #[test]
4561 fn adf_task_list_multiple_unwrapped_items() {
4562 let json = r#"{
4563 "version": 1,
4564 "type": "doc",
4565 "content": [{
4566 "type": "taskList",
4567 "attrs": {"localId": "list-001"},
4568 "content": [
4569 {
4570 "type": "taskItem",
4571 "attrs": {"localId": "task-001", "state": "TODO"},
4572 "content": [{"type": "text", "text": "First task"}]
4573 },
4574 {
4575 "type": "taskItem",
4576 "attrs": {"localId": "task-002", "state": "DONE"},
4577 "content": [{"type": "text", "text": "Second task"}]
4578 }
4579 ]
4580 }]
4581 }"#;
4582 let doc: AdfDocument = serde_json::from_str(json).unwrap();
4583 let md = adf_to_markdown(&doc).unwrap();
4584 assert!(md.contains("- [ ] First task"), "got: {md}");
4585 assert!(md.contains("- [x] Second task"), "got: {md}");
4586 assert!(!md.contains("adf-unsupported"), "got: {md}");
4587 }
4588
4589 #[test]
4591 fn adf_task_item_unwrapped_inline_with_marks() {
4592 let json = r#"{
4593 "version": 1,
4594 "type": "doc",
4595 "content": [{
4596 "type": "taskList",
4597 "attrs": {"localId": "list-001"},
4598 "content": [{
4599 "type": "taskItem",
4600 "attrs": {"localId": "task-001", "state": "TODO"},
4601 "content": [
4602 {"type": "text", "text": "Buy "},
4603 {"type": "text", "text": "groceries", "marks": [{"type": "strong"}]},
4604 {"type": "text", "text": " today"}
4605 ]
4606 }]
4607 }]
4608 }"#;
4609 let doc: AdfDocument = serde_json::from_str(json).unwrap();
4610 let md = adf_to_markdown(&doc).unwrap();
4611 assert!(md.contains("- [ ] Buy **groceries** today"), "got: {md}");
4612 }
4613
4614 #[test]
4616 fn adf_task_item_unwrapped_preserves_local_id() {
4617 let json = r#"{
4618 "version": 1,
4619 "type": "doc",
4620 "content": [{
4621 "type": "taskList",
4622 "attrs": {"localId": "list-001"},
4623 "content": [{
4624 "type": "taskItem",
4625 "attrs": {"localId": "task-001", "state": "TODO"},
4626 "content": [{"type": "text", "text": "Do something"}]
4627 }]
4628 }]
4629 }"#;
4630 let doc: AdfDocument = serde_json::from_str(json).unwrap();
4631 let md = adf_to_markdown(&doc).unwrap();
4632 assert!(md.contains("{localId=task-001}"), "got: {md}");
4633 assert!(md.contains("{localId=list-001}"), "got: {md}");
4634 }
4635
4636 #[test]
4638 fn round_trip_task_list_unwrapped_inline() {
4639 let json = r#"{
4640 "version": 1,
4641 "type": "doc",
4642 "content": [{
4643 "type": "taskList",
4644 "attrs": {"localId": "list-001"},
4645 "content": [
4646 {
4647 "type": "taskItem",
4648 "attrs": {"localId": "task-001", "state": "TODO"},
4649 "content": [{"type": "text", "text": "Do something"}]
4650 },
4651 {
4652 "type": "taskItem",
4653 "attrs": {"localId": "task-002", "state": "DONE"},
4654 "content": [{"type": "text", "text": "Already done"}]
4655 }
4656 ]
4657 }]
4658 }"#;
4659 let doc: AdfDocument = serde_json::from_str(json).unwrap();
4660 let md = adf_to_markdown(&doc).unwrap();
4661
4662 let doc2 = markdown_to_adf(&md).unwrap();
4664 assert_eq!(doc2.content[0].node_type, "taskList");
4665
4666 let items = doc2.content[0].content.as_ref().unwrap();
4667 assert_eq!(items.len(), 2);
4668 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
4669 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
4670
4671 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
4673 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "task-002");
4674 assert_eq!(
4675 doc2.content[0].attrs.as_ref().unwrap()["localId"],
4676 "list-001"
4677 );
4678 }
4679
4680 #[test]
4682 fn adf_task_item_unwrapped_inline_then_block() {
4683 let json = r#"{
4684 "version": 1,
4685 "type": "doc",
4686 "content": [{
4687 "type": "taskList",
4688 "attrs": {"localId": "list-001"},
4689 "content": [{
4690 "type": "taskItem",
4691 "attrs": {"localId": "task-001", "state": "TODO"},
4692 "content": [
4693 {"type": "text", "text": "Parent task"},
4694 {
4695 "type": "bulletList",
4696 "content": [{
4697 "type": "listItem",
4698 "content": [{
4699 "type": "paragraph",
4700 "content": [{"type": "text", "text": "sub-item"}]
4701 }]
4702 }]
4703 }
4704 ]
4705 }]
4706 }]
4707 }"#;
4708 let doc: AdfDocument = serde_json::from_str(json).unwrap();
4709 let md = adf_to_markdown(&doc).unwrap();
4710 assert!(md.contains("- [ ] Parent task"), "got: {md}");
4711 assert!(md.contains(" - sub-item"), "got: {md}");
4712 assert!(!md.contains("adf-unsupported"), "got: {md}");
4713 }
4714
4715 #[test]
4717 fn adf_task_item_empty_content() {
4718 let json = r#"{
4719 "version": 1,
4720 "type": "doc",
4721 "content": [{
4722 "type": "taskList",
4723 "attrs": {"localId": "list-001"},
4724 "content": [{
4725 "type": "taskItem",
4726 "attrs": {"localId": "task-001", "state": "TODO"},
4727 "content": []
4728 }]
4729 }]
4730 }"#;
4731 let doc: AdfDocument = serde_json::from_str(json).unwrap();
4732 let md = adf_to_markdown(&doc).unwrap();
4733 assert!(md.contains("- [ ] "), "got: {md}");
4734 assert!(!md.contains("adf-unsupported"), "got: {md}");
4735 }
4736
4737 #[test]
4740 fn adf_nested_task_item_renders_without_corruption() {
4741 let json = r#"{
4742 "type": "doc",
4743 "version": 1,
4744 "content": [{
4745 "type": "taskList",
4746 "attrs": {"localId": ""},
4747 "content": [
4748 {
4749 "type": "taskItem",
4750 "attrs": {"localId": "aabbccdd-1234-5678-abcd-aabbccdd1234", "state": "TODO"},
4751 "content": [{"type": "text", "text": "Normal task"}]
4752 },
4753 {
4754 "type": "taskItem",
4755 "attrs": {"localId": ""},
4756 "content": [
4757 {
4758 "type": "taskItem",
4759 "attrs": {"localId": "bbccddee-2345-6789-bcde-bbccddee2345", "state": "TODO"},
4760 "content": [{"type": "text", "text": "Nested task one"}]
4761 },
4762 {
4763 "type": "taskItem",
4764 "attrs": {"localId": "ccddee11-3456-7890-cdef-ccddee113456", "state": "DONE"},
4765 "content": [{"type": "text", "text": "Nested task two"}]
4766 }
4767 ]
4768 }
4769 ]
4770 }]
4771 }"#;
4772 let doc: AdfDocument = serde_json::from_str(json).unwrap();
4773 let md = adf_to_markdown(&doc).unwrap();
4774 assert!(md.contains("- [ ] Normal task"), "got: {md}");
4776 assert!(!md.contains("adf-unsupported"), "got: {md}");
4778 assert!(md.contains(" - [ ] Nested task one"), "got: {md}");
4779 assert!(md.contains(" - [x] Nested task two"), "got: {md}");
4780 }
4781
4782 #[test]
4784 fn round_trip_nested_task_item() {
4785 let json = r#"{
4786 "type": "doc",
4787 "version": 1,
4788 "content": [{
4789 "type": "taskList",
4790 "attrs": {"localId": ""},
4791 "content": [
4792 {
4793 "type": "taskItem",
4794 "attrs": {"localId": "task-001", "state": "TODO"},
4795 "content": [{"type": "text", "text": "Normal task"}]
4796 },
4797 {
4798 "type": "taskItem",
4799 "attrs": {"localId": ""},
4800 "content": [
4801 {
4802 "type": "taskItem",
4803 "attrs": {"localId": "task-002", "state": "TODO"},
4804 "content": [{"type": "text", "text": "Nested one"}]
4805 },
4806 {
4807 "type": "taskItem",
4808 "attrs": {"localId": "task-003", "state": "DONE"},
4809 "content": [{"type": "text", "text": "Nested two"}]
4810 }
4811 ]
4812 }
4813 ]
4814 }]
4815 }"#;
4816 let doc: AdfDocument = serde_json::from_str(json).unwrap();
4817 let md = adf_to_markdown(&doc).unwrap();
4818 let doc2 = markdown_to_adf(&md).unwrap();
4819
4820 assert_eq!(doc2.content[0].node_type, "taskList");
4822 let items = doc2.content[0].content.as_ref().unwrap();
4823 assert_eq!(items.len(), 2, "expected 2 top-level items, got: {items:?}");
4824
4825 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
4827 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
4828 let first_content = items[0].content.as_ref().unwrap();
4829 assert_eq!(first_content[0].text.as_deref(), Some("Normal task"));
4830
4831 let container = &items[1];
4833 assert_eq!(container.node_type, "taskItem");
4834 let c_attrs = container.attrs.as_ref().unwrap();
4835 assert!(
4836 c_attrs.get("state").is_none(),
4837 "container should have no state attr, got: {c_attrs:?}"
4838 );
4839
4840 let container_content = container.content.as_ref().unwrap();
4842 assert_eq!(
4843 container_content.len(),
4844 2,
4845 "expected 2 bare taskItem children"
4846 );
4847 assert_eq!(container_content[0].node_type, "taskItem");
4848 assert_eq!(
4849 container_content[0].attrs.as_ref().unwrap()["state"],
4850 "TODO"
4851 );
4852 assert_eq!(
4853 container_content[0].attrs.as_ref().unwrap()["localId"],
4854 "task-002"
4855 );
4856 assert_eq!(container_content[1].node_type, "taskItem");
4857 assert_eq!(
4858 container_content[1].attrs.as_ref().unwrap()["state"],
4859 "DONE"
4860 );
4861 assert_eq!(
4862 container_content[1].attrs.as_ref().unwrap()["localId"],
4863 "task-003"
4864 );
4865 }
4866
4867 #[test]
4869 fn adf_nested_task_item_preserves_local_ids() {
4870 let json = r#"{
4871 "type": "doc",
4872 "version": 1,
4873 "content": [{
4874 "type": "taskList",
4875 "attrs": {"localId": "list-001"},
4876 "content": [{
4877 "type": "taskItem",
4878 "attrs": {"localId": "container-001", "state": "TODO"},
4879 "content": [{
4880 "type": "taskItem",
4881 "attrs": {"localId": "child-001", "state": "DONE"},
4882 "content": [{"type": "text", "text": "Nested child"}]
4883 }]
4884 }]
4885 }]
4886 }"#;
4887 let doc: AdfDocument = serde_json::from_str(json).unwrap();
4888 let md = adf_to_markdown(&doc).unwrap();
4889 assert!(
4891 md.contains("localId=container-001"),
4892 "container localId missing: {md}"
4893 );
4894 assert!(
4896 md.contains("localId=child-001"),
4897 "child localId missing: {md}"
4898 );
4899 assert!(!md.contains("adf-unsupported"), "got: {md}");
4900 }
4901
4902 #[test]
4905 fn adf_nested_task_item_mixed_with_block_node() {
4906 let json = r#"{
4907 "type": "doc",
4908 "version": 1,
4909 "content": [{
4910 "type": "taskList",
4911 "attrs": {"localId": ""},
4912 "content": [{
4913 "type": "taskItem",
4914 "attrs": {"localId": "", "state": "TODO"},
4915 "content": [
4916 {
4917 "type": "taskItem",
4918 "attrs": {"localId": "", "state": "TODO"},
4919 "content": [{"type": "text", "text": "A nested task"}]
4920 },
4921 {
4922 "type": "paragraph",
4923 "content": [{"type": "text", "text": "Stray paragraph"}]
4924 }
4925 ]
4926 }]
4927 }]
4928 }"#;
4929 let doc: AdfDocument = serde_json::from_str(json).unwrap();
4930 let md = adf_to_markdown(&doc).unwrap();
4931 assert!(md.contains(" - [ ] A nested task"), "got: {md}");
4932 assert!(md.contains(" Stray paragraph"), "got: {md}");
4933 assert!(!md.contains("adf-unsupported"), "got: {md}");
4934 }
4935
4936 #[test]
4940 fn task_item_with_text_and_nested_sub_content() {
4941 let md = "- [ ] Parent task\n - [ ] Sub task\n";
4942 let doc = markdown_to_adf(md).unwrap();
4943 assert_eq!(doc.content[0].node_type, "taskList");
4944 let items = doc.content[0].content.as_ref().unwrap();
4945 assert_eq!(items.len(), 2, "got: {items:?}");
4948 let parent = &items[0];
4949 assert_eq!(parent.attrs.as_ref().unwrap()["state"], "TODO");
4950 let parent_content = parent.content.as_ref().unwrap();
4951 assert_eq!(parent_content[0].text.as_deref(), Some("Parent task"));
4952 assert_eq!(items[1].node_type, "taskList");
4954 let nested = items[1].content.as_ref().unwrap();
4955 assert_eq!(nested.len(), 1);
4956 assert_eq!(nested[0].attrs.as_ref().unwrap()["state"], "TODO");
4957 }
4958
4959 #[test]
4963 fn task_item_empty_with_non_tasklist_sub_content() {
4964 let md = "- [ ] \n Some paragraph text\n";
4965 let doc = markdown_to_adf(md).unwrap();
4966 assert_eq!(doc.content[0].node_type, "taskList");
4967 let items = doc.content[0].content.as_ref().unwrap();
4968 assert_eq!(items.len(), 1);
4969 let item = &items[0];
4970 assert_eq!(item.attrs.as_ref().unwrap()["state"], "TODO");
4971 let content = item.content.as_ref().unwrap();
4972 assert_eq!(content[0].node_type, "paragraph");
4974 }
4975
4976 #[test]
4978 fn adf_nested_task_item_single_child() {
4979 let json = r#"{
4980 "type": "doc",
4981 "version": 1,
4982 "content": [{
4983 "type": "taskList",
4984 "attrs": {"localId": ""},
4985 "content": [{
4986 "type": "taskItem",
4987 "attrs": {"localId": "", "state": "TODO"},
4988 "content": [{
4989 "type": "taskItem",
4990 "attrs": {"localId": "", "state": "DONE"},
4991 "content": [{"type": "text", "text": "Only child"}]
4992 }]
4993 }]
4994 }]
4995 }"#;
4996 let doc: AdfDocument = serde_json::from_str(json).unwrap();
4997 let md = adf_to_markdown(&doc).unwrap();
4998 assert!(md.contains(" - [x] Only child"), "got: {md}");
4999 assert!(!md.contains("adf-unsupported"), "got: {md}");
5000 }
5001
5002 #[test]
5005 fn adf_nested_tasklist_sibling_renders_indented() {
5006 let json = r#"{
5007 "version": 1,
5008 "type": "doc",
5009 "content": [{
5010 "type": "taskList",
5011 "attrs": {"localId": ""},
5012 "content": [
5013 {
5014 "type": "taskItem",
5015 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000001", "state": "TODO"},
5016 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
5017 },
5018 {
5019 "type": "taskList",
5020 "attrs": {"localId": ""},
5021 "content": [{
5022 "type": "taskItem",
5023 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000002", "state": "TODO"},
5024 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
5025 }]
5026 },
5027 {
5028 "type": "taskItem",
5029 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000003", "state": "TODO"},
5030 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
5031 }
5032 ]
5033 }]
5034 }"#;
5035 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5036 let md = adf_to_markdown(&doc).unwrap();
5037 assert!(md.contains("- [ ] parent task one"), "got: {md}");
5039 assert!(md.contains(" - [ ] nested sub-task"), "got: {md}");
5040 assert!(md.contains("- [ ] parent task two"), "got: {md}");
5041 }
5042
5043 #[test]
5045 fn round_trip_nested_tasklist_preserves_type() {
5046 let json = r#"{
5047 "version": 1,
5048 "type": "doc",
5049 "content": [{
5050 "type": "taskList",
5051 "attrs": {"localId": ""},
5052 "content": [
5053 {
5054 "type": "taskItem",
5055 "attrs": {"localId": "", "state": "TODO"},
5056 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
5057 },
5058 {
5059 "type": "taskList",
5060 "attrs": {"localId": ""},
5061 "content": [{
5062 "type": "taskItem",
5063 "attrs": {"localId": "", "state": "TODO"},
5064 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
5065 }]
5066 },
5067 {
5068 "type": "taskItem",
5069 "attrs": {"localId": "", "state": "TODO"},
5070 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
5071 }
5072 ]
5073 }]
5074 }"#;
5075 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5076 let md = adf_to_markdown(&doc).unwrap();
5077 let rt_doc = markdown_to_adf(&md).unwrap();
5078 assert_eq!(rt_doc.content[0].node_type, "taskList");
5080 let items = rt_doc.content[0].content.as_ref().unwrap();
5081 assert_eq!(items.len(), 3, "got: {items:?}");
5084 assert_eq!(items[0].node_type, "taskItem");
5085 assert_eq!(
5086 items[1].node_type, "taskList",
5087 "nested taskList should survive round-trip"
5088 );
5089 assert_eq!(items[2].node_type, "taskItem");
5090 let nested_items = items[1].content.as_ref().unwrap();
5091 assert_eq!(nested_items[0].attrs.as_ref().unwrap()["state"], "TODO");
5092 }
5093
5094 #[test]
5096 fn adf_nested_tasklist_done_state() {
5097 let json = r#"{
5098 "version": 1,
5099 "type": "doc",
5100 "content": [{
5101 "type": "taskList",
5102 "attrs": {"localId": ""},
5103 "content": [
5104 {
5105 "type": "taskItem",
5106 "attrs": {"localId": "", "state": "TODO"},
5107 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
5108 },
5109 {
5110 "type": "taskList",
5111 "attrs": {"localId": ""},
5112 "content": [{
5113 "type": "taskItem",
5114 "attrs": {"localId": "", "state": "DONE"},
5115 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "done child"}]}]
5116 }]
5117 }
5118 ]
5119 }]
5120 }"#;
5121 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5122 let md = adf_to_markdown(&doc).unwrap();
5123 assert!(md.contains(" - [x] done child"), "got: {md}");
5124 let rt_doc = markdown_to_adf(&md).unwrap();
5126 let items = rt_doc.content[0].content.as_ref().unwrap();
5127 assert_eq!(
5128 items[1].node_type, "taskList",
5129 "nested taskList should survive round-trip"
5130 );
5131 let nested_item = &items[1].content.as_ref().unwrap()[0];
5132 assert_eq!(nested_item.attrs.as_ref().unwrap()["state"], "DONE");
5133 }
5134
5135 #[test]
5137 fn adf_multiple_nested_tasklists() {
5138 let json = r#"{
5139 "version": 1,
5140 "type": "doc",
5141 "content": [{
5142 "type": "taskList",
5143 "attrs": {"localId": ""},
5144 "content": [
5145 {
5146 "type": "taskItem",
5147 "attrs": {"localId": "", "state": "TODO"},
5148 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "first parent"}]}]
5149 },
5150 {
5151 "type": "taskList",
5152 "attrs": {"localId": ""},
5153 "content": [{
5154 "type": "taskItem",
5155 "attrs": {"localId": "", "state": "TODO"},
5156 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child A"}]}]
5157 }]
5158 },
5159 {
5160 "type": "taskItem",
5161 "attrs": {"localId": "", "state": "TODO"},
5162 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "second parent"}]}]
5163 },
5164 {
5165 "type": "taskList",
5166 "attrs": {"localId": ""},
5167 "content": [{
5168 "type": "taskItem",
5169 "attrs": {"localId": "", "state": "DONE"},
5170 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child B"}]}]
5171 }]
5172 }
5173 ]
5174 }]
5175 }"#;
5176 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5177 let md = adf_to_markdown(&doc).unwrap();
5178 assert!(md.contains("- [ ] first parent"), "got: {md}");
5179 assert!(md.contains(" - [ ] child A"), "got: {md}");
5180 assert!(md.contains("- [ ] second parent"), "got: {md}");
5181 assert!(md.contains(" - [x] child B"), "got: {md}");
5182 }
5183
5184 #[test]
5187 fn round_trip_nested_tasklist_stable() {
5188 let json = r#"{
5189 "version": 1,
5190 "type": "doc",
5191 "content": [{
5192 "type": "taskList",
5193 "attrs": {"localId": ""},
5194 "content": [
5195 {
5196 "type": "taskItem",
5197 "attrs": {"localId": "", "state": "TODO"},
5198 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
5199 },
5200 {
5201 "type": "taskList",
5202 "attrs": {"localId": ""},
5203 "content": [{
5204 "type": "taskItem",
5205 "attrs": {"localId": "", "state": "TODO"},
5206 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child"}]}]
5207 }]
5208 }
5209 ]
5210 }]
5211 }"#;
5212 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5213 let md1 = adf_to_markdown(&doc).unwrap();
5215 let rt1 = markdown_to_adf(&md1).unwrap();
5216 let md2 = adf_to_markdown(&rt1).unwrap();
5218 let rt2 = markdown_to_adf(&md2).unwrap();
5219 assert_eq!(md1, md2, "markdown should be stable across round-trips");
5221 let rt1_json = serde_json::to_string(&rt1).unwrap();
5223 let rt2_json = serde_json::to_string(&rt2).unwrap();
5224 assert_eq!(
5225 rt1_json, rt2_json,
5226 "ADF should be stable across round-trips"
5227 );
5228 }
5229
5230 #[test]
5235 fn task_item_mixed_sub_content_splits_siblings() {
5236 let md = "- [ ] Parent task\n - [ ] Sub task\n Some paragraph\n";
5237 let doc = markdown_to_adf(md).unwrap();
5238 let items = doc.content[0].content.as_ref().unwrap();
5239 assert_eq!(items.len(), 2, "got: {items:?}");
5241 assert_eq!(items[0].node_type, "taskItem");
5242 let parent_content = items[0].content.as_ref().unwrap();
5243 assert!(
5245 parent_content.iter().any(|n| n.node_type == "paragraph"),
5246 "non-taskList sub-content should stay as child: {parent_content:?}"
5247 );
5248 assert_eq!(items[1].node_type, "taskList");
5250 }
5251
5252 #[test]
5256 fn empty_task_item_mixed_sub_content_none_arm() {
5257 let md = "- [ ] \n Some paragraph\n - [ ] Sub task\n";
5258 let doc = markdown_to_adf(md).unwrap();
5259 let items = doc.content[0].content.as_ref().unwrap();
5260 assert_eq!(items.len(), 2, "got: {items:?}");
5262 assert_eq!(items[0].node_type, "taskItem");
5263 let parent_content = items[0].content.as_ref().unwrap();
5264 assert!(
5265 parent_content.iter().any(|n| n.node_type == "paragraph"),
5266 "paragraph should be assigned to taskItem: {parent_content:?}"
5267 );
5268 assert_eq!(items[1].node_type, "taskList");
5269 }
5270
5271 #[test]
5276 fn task_item_text_with_non_tasklist_sub_content_only() {
5277 let md = "- [ ] My task\n Extra paragraph content\n";
5278 let doc = markdown_to_adf(md).unwrap();
5279 let items = doc.content[0].content.as_ref().unwrap();
5280 assert_eq!(items.len(), 1, "got: {items:?}");
5282 assert_eq!(items[0].node_type, "taskItem");
5283 let content = items[0].content.as_ref().unwrap();
5284 assert!(
5286 content.iter().any(|n| n.node_type == "paragraph"),
5287 "paragraph sub-content should be a child of taskItem: {content:?}"
5288 );
5289 }
5290
5291 #[test]
5294 fn adf_list_item_leading_block_node() {
5295 let json = r#"{
5296 "version": 1,
5297 "type": "doc",
5298 "content": [{
5299 "type": "bulletList",
5300 "content": [{
5301 "type": "listItem",
5302 "content": [{
5303 "type": "codeBlock",
5304 "attrs": {"language": "rust"},
5305 "content": [{"type": "text", "text": "let x = 1;"}]
5306 }]
5307 }]
5308 }]
5309 }"#;
5310 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5311 let md = adf_to_markdown(&doc).unwrap();
5312 assert!(md.contains("```rust"), "got: {md}");
5313 assert!(md.contains("let x = 1;"), "got: {md}");
5314 for line in md.lines() {
5317 if line.starts_with("- ") {
5318 continue; }
5320 if line.trim().is_empty() {
5321 continue;
5322 }
5323 assert!(
5324 line.starts_with(" "),
5325 "continuation line not indented: {line:?}"
5326 );
5327 }
5328 }
5329
5330 #[test]
5333 fn code_block_in_list_item_backtick_roundtrip() {
5334 let json = r#"{
5335 "version": 1,
5336 "type": "doc",
5337 "content": [{
5338 "type": "bulletList",
5339 "content": [{
5340 "type": "listItem",
5341 "content": [{
5342 "type": "codeBlock",
5343 "attrs": {"language": ""},
5344 "content": [{"type": "text", "text": "error: some value with a backtick ` at end"}]
5345 }]
5346 }]
5347 }]
5348 }"#;
5349 let original: AdfDocument = serde_json::from_str(json).unwrap();
5350 let md = adf_to_markdown(&original).unwrap();
5351 let roundtripped = markdown_to_adf(&md).unwrap();
5352 let list = &roundtripped.content[0];
5353 assert_eq!(list.node_type, "bulletList", "top node: {}", list.node_type);
5354 let item = &list.content.as_ref().unwrap()[0];
5355 let first_child = &item.content.as_ref().unwrap()[0];
5356 assert_eq!(
5357 first_child.node_type, "codeBlock",
5358 "expected codeBlock, got: {}",
5359 first_child.node_type
5360 );
5361 let text = first_child.content.as_ref().unwrap()[0]
5362 .text
5363 .as_deref()
5364 .unwrap();
5365 assert_eq!(text, "error: some value with a backtick ` at end");
5366 }
5367
5368 #[test]
5370 fn code_block_with_language_in_list_item_roundtrip() {
5371 let json = r#"{
5372 "version": 1,
5373 "type": "doc",
5374 "content": [{
5375 "type": "bulletList",
5376 "content": [{
5377 "type": "listItem",
5378 "content": [{
5379 "type": "codeBlock",
5380 "attrs": {"language": "rust"},
5381 "content": [{"type": "text", "text": "fn main() {\n println!(\"hello\");\n}"}]
5382 }]
5383 }]
5384 }]
5385 }"#;
5386 let original: AdfDocument = serde_json::from_str(json).unwrap();
5387 let md = adf_to_markdown(&original).unwrap();
5388 let roundtripped = markdown_to_adf(&md).unwrap();
5389 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
5390 let code = &item.content.as_ref().unwrap()[0];
5391 assert_eq!(code.node_type, "codeBlock");
5392 let lang = code
5393 .attrs
5394 .as_ref()
5395 .and_then(|a| a.get("language"))
5396 .and_then(serde_json::Value::as_str)
5397 .unwrap_or("");
5398 assert_eq!(lang, "rust");
5399 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5400 assert!(text.contains("println!"), "code content: {text}");
5401 }
5402
5403 #[test]
5405 fn code_block_in_ordered_list_item_roundtrip() {
5406 let json = r#"{
5407 "version": 1,
5408 "type": "doc",
5409 "content": [{
5410 "type": "orderedList",
5411 "attrs": {"order": 1},
5412 "content": [{
5413 "type": "listItem",
5414 "content": [{
5415 "type": "codeBlock",
5416 "attrs": {"language": ""},
5417 "content": [{"type": "text", "text": "backtick ` here"}]
5418 }]
5419 }]
5420 }]
5421 }"#;
5422 let original: AdfDocument = serde_json::from_str(json).unwrap();
5423 let md = adf_to_markdown(&original).unwrap();
5424 let roundtripped = markdown_to_adf(&md).unwrap();
5425 let list = &roundtripped.content[0];
5426 assert_eq!(list.node_type, "orderedList");
5427 let item = &list.content.as_ref().unwrap()[0];
5428 let code = &item.content.as_ref().unwrap()[0];
5429 assert_eq!(code.node_type, "codeBlock");
5430 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5431 assert_eq!(text, "backtick ` here");
5432 }
5433
5434 #[test]
5436 fn code_block_then_paragraph_in_list_item() {
5437 let json = r#"{
5438 "version": 1,
5439 "type": "doc",
5440 "content": [{
5441 "type": "bulletList",
5442 "content": [{
5443 "type": "listItem",
5444 "content": [
5445 {
5446 "type": "codeBlock",
5447 "attrs": {"language": ""},
5448 "content": [{"type": "text", "text": "code with ` backtick"}]
5449 },
5450 {
5451 "type": "paragraph",
5452 "content": [{"type": "text", "text": "description"}]
5453 }
5454 ]
5455 }]
5456 }]
5457 }"#;
5458 let original: AdfDocument = serde_json::from_str(json).unwrap();
5459 let md = adf_to_markdown(&original).unwrap();
5460 let roundtripped = markdown_to_adf(&md).unwrap();
5461 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
5462 let children = item.content.as_ref().unwrap();
5463 assert_eq!(children[0].node_type, "codeBlock");
5464 assert_eq!(children[1].node_type, "paragraph");
5465 }
5466
5467 #[test]
5469 fn code_block_multiple_backticks_in_list_item() {
5470 let json = r#"{
5471 "version": 1,
5472 "type": "doc",
5473 "content": [{
5474 "type": "bulletList",
5475 "content": [{
5476 "type": "listItem",
5477 "content": [{
5478 "type": "codeBlock",
5479 "attrs": {"language": ""},
5480 "content": [{"type": "text", "text": "a ` b `` c ``` d"}]
5481 }]
5482 }]
5483 }]
5484 }"#;
5485 let original: AdfDocument = serde_json::from_str(json).unwrap();
5486 let md = adf_to_markdown(&original).unwrap();
5487 let roundtripped = markdown_to_adf(&md).unwrap();
5488 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
5489 let code = &item.content.as_ref().unwrap()[0];
5490 assert_eq!(code.node_type, "codeBlock");
5491 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5492 assert_eq!(text, "a ` b `` c ``` d");
5493 }
5494
5495 #[test]
5498 fn media_first_child_with_sub_content_in_list_item() {
5499 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
5500 {"type":"listItem","content":[
5501 {"type":"mediaSingle","attrs":{"layout":"center"},
5502 "content":[{"type":"media","attrs":{"type":"file","id":"img-99","collection":"col-x","height":50,"width":100}}]},
5503 {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
5504 ]}
5505 ]}]}"#;
5506 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
5507 let md = adf_to_markdown(&doc).unwrap();
5508 let rt = markdown_to_adf(&md).unwrap();
5509 let item = &rt.content[0].content.as_ref().unwrap()[0];
5510 let children = item.content.as_ref().unwrap();
5511 assert_eq!(
5512 children.len(),
5513 2,
5514 "expected 2 children, got {}",
5515 children.len()
5516 );
5517 assert_eq!(children[0].node_type, "mediaSingle");
5518 let media = &children[0].content.as_ref().unwrap()[0];
5519 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-99");
5520 assert_eq!(children[1].node_type, "paragraph");
5521 }
5522
5523 #[test]
5524 fn inline_bold() {
5525 let doc = markdown_to_adf("Some **bold** text").unwrap();
5526 let content = doc.content[0].content.as_ref().unwrap();
5527 assert!(content.len() >= 3);
5528 let bold_node = &content[1];
5529 assert_eq!(bold_node.text.as_deref(), Some("bold"));
5530 let marks = bold_node.marks.as_ref().unwrap();
5531 assert_eq!(marks[0].mark_type, "strong");
5532 }
5533
5534 #[test]
5535 fn inline_italic() {
5536 let doc = markdown_to_adf("Some *italic* text").unwrap();
5537 let content = doc.content[0].content.as_ref().unwrap();
5538 let italic_node = &content[1];
5539 assert_eq!(italic_node.text.as_deref(), Some("italic"));
5540 let marks = italic_node.marks.as_ref().unwrap();
5541 assert_eq!(marks[0].mark_type, "em");
5542 }
5543
5544 #[test]
5545 fn inline_code() {
5546 let doc = markdown_to_adf("Use `code` here").unwrap();
5547 let content = doc.content[0].content.as_ref().unwrap();
5548 let code_node = &content[1];
5549 assert_eq!(code_node.text.as_deref(), Some("code"));
5550 let marks = code_node.marks.as_ref().unwrap();
5551 assert_eq!(marks[0].mark_type, "code");
5552 }
5553
5554 #[test]
5555 fn inline_strikethrough() {
5556 let doc = markdown_to_adf("Some ~~deleted~~ text").unwrap();
5557 let content = doc.content[0].content.as_ref().unwrap();
5558 let strike_node = &content[1];
5559 assert_eq!(strike_node.text.as_deref(), Some("deleted"));
5560 let marks = strike_node.marks.as_ref().unwrap();
5561 assert_eq!(marks[0].mark_type, "strike");
5562 }
5563
5564 #[test]
5565 fn inline_link() {
5566 let doc = markdown_to_adf("Click [here](https://example.com) now").unwrap();
5567 let content = doc.content[0].content.as_ref().unwrap();
5568 let link_node = &content[1];
5569 assert_eq!(link_node.text.as_deref(), Some("here"));
5570 let marks = link_node.marks.as_ref().unwrap();
5571 assert_eq!(marks[0].mark_type, "link");
5572 }
5573
5574 #[test]
5575 fn block_image() {
5576 let md = "";
5577 let doc = markdown_to_adf(md).unwrap();
5578 assert_eq!(doc.content[0].node_type, "mediaSingle");
5579 }
5580
5581 #[test]
5582 fn table() {
5583 let md = "| A | B |\n| --- | --- |\n| 1 | 2 |";
5584 let doc = markdown_to_adf(md).unwrap();
5585 assert_eq!(doc.content[0].node_type, "table");
5586 let rows = doc.content[0].content.as_ref().unwrap();
5587 assert_eq!(rows.len(), 2); }
5589
5590 #[test]
5593 fn adf_paragraph_to_markdown() {
5594 let doc = AdfDocument {
5595 version: 1,
5596 doc_type: "doc".to_string(),
5597 content: vec![AdfNode::paragraph(vec![AdfNode::text("Hello world")])],
5598 };
5599 let md = adf_to_markdown(&doc).unwrap();
5600 assert_eq!(md.trim(), "Hello world");
5601 }
5602
5603 #[test]
5604 fn adf_heading_to_markdown() {
5605 let doc = AdfDocument {
5606 version: 1,
5607 doc_type: "doc".to_string(),
5608 content: vec![AdfNode::heading(2, vec![AdfNode::text("Title")])],
5609 };
5610 let md = adf_to_markdown(&doc).unwrap();
5611 assert_eq!(md.trim(), "## Title");
5612 }
5613
5614 #[test]
5615 fn adf_bold_to_markdown() {
5616 let doc = AdfDocument {
5617 version: 1,
5618 doc_type: "doc".to_string(),
5619 content: vec![AdfNode::paragraph(vec![
5620 AdfNode::text("Normal "),
5621 AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
5622 AdfNode::text(" text"),
5623 ])],
5624 };
5625 let md = adf_to_markdown(&doc).unwrap();
5626 assert_eq!(md.trim(), "Normal **bold** text");
5627 }
5628
5629 #[test]
5630 fn adf_code_block_to_markdown() {
5631 let doc = AdfDocument {
5632 version: 1,
5633 doc_type: "doc".to_string(),
5634 content: vec![AdfNode::code_block(Some("rust"), "let x = 1;")],
5635 };
5636 let md = adf_to_markdown(&doc).unwrap();
5637 assert!(md.contains("```rust"));
5638 assert!(md.contains("let x = 1;"));
5639 assert!(md.contains("```"));
5640 }
5641
5642 #[test]
5643 fn adf_rule_to_markdown() {
5644 let doc = AdfDocument {
5645 version: 1,
5646 doc_type: "doc".to_string(),
5647 content: vec![AdfNode::rule()],
5648 };
5649 let md = adf_to_markdown(&doc).unwrap();
5650 assert!(md.contains("---"));
5651 }
5652
5653 #[test]
5654 fn adf_bullet_list_to_markdown() {
5655 let doc = AdfDocument {
5656 version: 1,
5657 doc_type: "doc".to_string(),
5658 content: vec![AdfNode::bullet_list(vec![
5659 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("A")])]),
5660 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("B")])]),
5661 ])],
5662 };
5663 let md = adf_to_markdown(&doc).unwrap();
5664 assert!(md.contains("- A"));
5665 assert!(md.contains("- B"));
5666 }
5667
5668 #[test]
5669 fn adf_link_to_markdown() {
5670 let doc = AdfDocument {
5671 version: 1,
5672 doc_type: "doc".to_string(),
5673 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
5674 "click",
5675 vec![AdfMark::link("https://example.com")],
5676 )])],
5677 };
5678 let md = adf_to_markdown(&doc).unwrap();
5679 assert_eq!(md.trim(), "[click](https://example.com)");
5680 }
5681
5682 #[test]
5683 fn unsupported_block_preserved_as_json() {
5684 let doc = AdfDocument {
5685 version: 1,
5686 doc_type: "doc".to_string(),
5687 content: vec![AdfNode {
5688 node_type: "unknownBlock".to_string(),
5689 attrs: Some(serde_json::json!({"key": "value"})),
5690 content: None,
5691 text: None,
5692 marks: None,
5693 local_id: None,
5694 parameters: None,
5695 }],
5696 };
5697 let md = adf_to_markdown(&doc).unwrap();
5698 assert!(md.contains("```adf-unsupported"));
5699 assert!(md.contains("\"unknownBlock\""));
5700 }
5701
5702 #[test]
5703 fn unsupported_block_round_trips() {
5704 let original = AdfDocument {
5705 version: 1,
5706 doc_type: "doc".to_string(),
5707 content: vec![AdfNode {
5708 node_type: "unknownBlock".to_string(),
5709 attrs: Some(serde_json::json!({"key": "value"})),
5710 content: None,
5711 text: None,
5712 marks: None,
5713 local_id: None,
5714 parameters: None,
5715 }],
5716 };
5717 let md = adf_to_markdown(&original).unwrap();
5718 let restored = markdown_to_adf(&md).unwrap();
5719 assert_eq!(restored.content[0].node_type, "unknownBlock");
5720 assert_eq!(restored.content[0].attrs.as_ref().unwrap()["key"], "value");
5721 }
5722
5723 #[test]
5726 fn round_trip_simple_document() {
5727 let md = "# Hello\n\nSome text with **bold** and *italic*.\n\n- Item 1\n- Item 2\n";
5728 let adf = markdown_to_adf(md).unwrap();
5729 let restored = adf_to_markdown(&adf).unwrap();
5730
5731 assert!(restored.contains("# Hello"));
5732 assert!(restored.contains("**bold**"));
5733 assert!(restored.contains("*italic*"));
5734 assert!(restored.contains("- Item 1"));
5735 assert!(restored.contains("- Item 2"));
5736 }
5737
5738 #[test]
5739 fn round_trip_code_block() {
5740 let md = "```python\nprint('hello')\n```\n";
5741 let adf = markdown_to_adf(md).unwrap();
5742 let restored = adf_to_markdown(&adf).unwrap();
5743
5744 assert!(restored.contains("```python"));
5745 assert!(restored.contains("print('hello')"));
5746 }
5747
5748 #[test]
5749 fn round_trip_code_block_no_attrs() {
5750 let adf_json = r#"{"version":1,"type":"doc","content":[
5751 {"type":"codeBlock","content":[{"type":"text","text":"plain code"}]}
5752 ]}"#;
5753 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
5754 assert!(doc.content[0].attrs.is_none());
5755 let md = adf_to_markdown(&doc).unwrap();
5756 let round_tripped = markdown_to_adf(&md).unwrap();
5757 assert!(round_tripped.content[0].attrs.is_none());
5758 }
5759
5760 #[test]
5761 fn round_trip_code_block_empty_language() {
5762 let adf_json = r#"{"version":1,"type":"doc","content":[
5763 {"type":"codeBlock","attrs":{"language":""},"content":[{"type":"text","text":"simple code block no backtick"}]}
5764 ]}"#;
5765 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
5766 let attrs = doc.content[0].attrs.as_ref().unwrap();
5767 assert_eq!(attrs["language"], "");
5768 let md = adf_to_markdown(&doc).unwrap();
5769 let round_tripped = markdown_to_adf(&md).unwrap();
5770 let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
5771 assert_eq!(rt_attrs["language"], "");
5772 }
5773
5774 #[test]
5775 fn round_trip_code_block_with_language() {
5776 let adf_json = r#"{"version":1,"type":"doc","content":[
5777 {"type":"codeBlock","attrs":{"language":"python"},"content":[{"type":"text","text":"print('hi')"}]}
5778 ]}"#;
5779 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
5780 let md = adf_to_markdown(&doc).unwrap();
5781 let round_tripped = markdown_to_adf(&md).unwrap();
5782 let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
5783 assert_eq!(rt_attrs["language"], "python");
5784 }
5785
5786 #[test]
5787 fn multiple_paragraphs() {
5788 let md = "First paragraph.\n\nSecond paragraph.\n";
5789 let adf = markdown_to_adf(md).unwrap();
5790 assert_eq!(adf.content.len(), 2);
5791 assert_eq!(adf.content[0].node_type, "paragraph");
5792 assert_eq!(adf.content[1].node_type, "paragraph");
5793 }
5794
5795 #[test]
5798 fn horizontal_rule_underscores() {
5799 let doc = markdown_to_adf("___").unwrap();
5800 assert_eq!(doc.content[0].node_type, "rule");
5801 }
5802
5803 #[test]
5804 fn not_a_horizontal_rule_too_short() {
5805 let doc = markdown_to_adf("--").unwrap();
5806 assert_eq!(doc.content[0].node_type, "paragraph");
5807 }
5808
5809 #[test]
5810 fn bullet_list_star_marker() {
5811 let md = "* Apple\n* Banana";
5812 let doc = markdown_to_adf(md).unwrap();
5813 assert_eq!(doc.content[0].node_type, "bulletList");
5814 let items = doc.content[0].content.as_ref().unwrap();
5815 assert_eq!(items.len(), 2);
5816 }
5817
5818 #[test]
5819 fn bullet_list_plus_marker() {
5820 let md = "+ One\n+ Two";
5821 let doc = markdown_to_adf(md).unwrap();
5822 assert_eq!(doc.content[0].node_type, "bulletList");
5823 }
5824
5825 #[test]
5826 fn ordered_list_non_one_start() {
5827 let md = "5. Fifth\n6. Sixth";
5828 let doc = markdown_to_adf(md).unwrap();
5829 let node = &doc.content[0];
5830 assert_eq!(node.node_type, "orderedList");
5831 let attrs = node.attrs.as_ref().unwrap();
5832 assert_eq!(attrs["order"], 5);
5833 }
5834
5835 #[test]
5836 fn ordered_list_start_at_one_has_order_attr() {
5837 let md = "1. First\n2. Second";
5838 let doc = markdown_to_adf(md).unwrap();
5839 let node = &doc.content[0];
5840 assert_eq!(node.node_type, "orderedList");
5841 assert_eq!(node.attrs.as_ref().unwrap()["order"], 1);
5842 }
5843
5844 #[test]
5845 fn blockquote_bare_marker() {
5846 let md = ">quoted text";
5848 let doc = markdown_to_adf(md).unwrap();
5849 assert_eq!(doc.content[0].node_type, "blockquote");
5850 }
5851
5852 #[test]
5853 fn image_no_alt() {
5854 let md = "";
5855 let doc = markdown_to_adf(md).unwrap();
5856 let node = &doc.content[0];
5857 assert_eq!(node.node_type, "mediaSingle");
5858 let media = &node.content.as_ref().unwrap()[0];
5860 let attrs = media.attrs.as_ref().unwrap();
5861 assert!(attrs.get("alt").is_none());
5862 }
5863
5864 #[test]
5865 fn image_with_alt() {
5866 let md = "";
5867 let doc = markdown_to_adf(md).unwrap();
5868 let media = &doc.content[0].content.as_ref().unwrap()[0];
5869 let attrs = media.attrs.as_ref().unwrap();
5870 assert_eq!(attrs["alt"], "A photo");
5871 }
5872
5873 #[test]
5874 fn table_multi_body_rows() {
5875 let md = "| H1 | H2 |\n| --- | --- |\n| a | b |\n| c | d |";
5876 let doc = markdown_to_adf(md).unwrap();
5877 let rows = doc.content[0].content.as_ref().unwrap();
5878 assert_eq!(rows.len(), 3); let header_cells = rows[0].content.as_ref().unwrap();
5881 assert_eq!(header_cells[0].node_type, "tableHeader");
5882 let body_cells = rows[1].content.as_ref().unwrap();
5884 assert_eq!(body_cells[0].node_type, "tableCell");
5885 }
5886
5887 #[test]
5888 fn table_no_separator_is_not_table() {
5889 let md = "| not | a table |";
5891 let doc = markdown_to_adf(md).unwrap();
5892 assert_eq!(doc.content[0].node_type, "paragraph");
5893 }
5894
5895 #[test]
5896 fn inline_underscore_bold() {
5897 let doc = markdown_to_adf("Some __bold__ text").unwrap();
5898 let content = doc.content[0].content.as_ref().unwrap();
5899 let bold_node = &content[1];
5900 assert_eq!(bold_node.text.as_deref(), Some("bold"));
5901 let marks = bold_node.marks.as_ref().unwrap();
5902 assert_eq!(marks[0].mark_type, "strong");
5903 }
5904
5905 #[test]
5906 fn inline_underscore_italic() {
5907 let doc = markdown_to_adf("Some _italic_ text").unwrap();
5908 let content = doc.content[0].content.as_ref().unwrap();
5909 let italic_node = &content[1];
5910 assert_eq!(italic_node.text.as_deref(), Some("italic"));
5911 let marks = italic_node.marks.as_ref().unwrap();
5912 assert_eq!(marks[0].mark_type, "em");
5913 }
5914
5915 #[test]
5916 fn intraword_underscore_not_emphasis() {
5917 let doc = markdown_to_adf("call do_something_useful now").unwrap();
5919 let content = doc.content[0].content.as_ref().unwrap();
5920 assert_eq!(content.len(), 1, "should be a single text node");
5921 assert_eq!(
5922 content[0].text.as_deref(),
5923 Some("call do_something_useful now")
5924 );
5925 assert!(content[0].marks.is_none());
5926 }
5927
5928 #[test]
5929 fn intraword_underscore_multiple() {
5930 let doc = markdown_to_adf("use a_b_c_d here").unwrap();
5932 let content = doc.content[0].content.as_ref().unwrap();
5933 assert_eq!(content.len(), 1);
5934 assert_eq!(content[0].text.as_deref(), Some("use a_b_c_d here"));
5935 assert!(content[0].marks.is_none());
5936 }
5937
5938 #[test]
5939 fn intraword_double_underscore_not_bold() {
5940 let doc = markdown_to_adf("foo__bar__baz").unwrap();
5942 let content = doc.content[0].content.as_ref().unwrap();
5943 assert_eq!(content.len(), 1);
5944 assert_eq!(content[0].text.as_deref(), Some("foo__bar__baz"));
5945 assert!(content[0].marks.is_none());
5946 }
5947
5948 #[test]
5949 fn intraword_triple_underscore_not_bold_italic() {
5950 let doc = markdown_to_adf("x___y___z").unwrap();
5952 let content = doc.content[0].content.as_ref().unwrap();
5953 assert_eq!(content.len(), 1);
5954 assert_eq!(content[0].text.as_deref(), Some("x___y___z"));
5955 assert!(content[0].marks.is_none());
5956 }
5957
5958 #[test]
5959 fn underscore_emphasis_still_works_with_spaces() {
5960 let doc = markdown_to_adf("some _italic_ here").unwrap();
5962 let content = doc.content[0].content.as_ref().unwrap();
5963 assert_eq!(content.len(), 3);
5964 assert_eq!(content[1].text.as_deref(), Some("italic"));
5965 let marks = content[1].marks.as_ref().unwrap();
5966 assert_eq!(marks[0].mark_type, "em");
5967 }
5968
5969 #[test]
5970 fn underscore_bold_still_works_with_spaces() {
5971 let doc = markdown_to_adf("some __bold__ here").unwrap();
5973 let content = doc.content[0].content.as_ref().unwrap();
5974 assert_eq!(content.len(), 3);
5975 assert_eq!(content[1].text.as_deref(), Some("bold"));
5976 let marks = content[1].marks.as_ref().unwrap();
5977 assert_eq!(marks[0].mark_type, "strong");
5978 }
5979
5980 #[test]
5981 fn intraword_underscore_closing_only() {
5982 let doc = markdown_to_adf("_foo_bar").unwrap();
5984 let content = doc.content[0].content.as_ref().unwrap();
5985 assert_eq!(content.len(), 1);
5986 assert_eq!(content[0].text.as_deref(), Some("_foo_bar"));
5987 }
5988
5989 #[test]
5990 fn intraword_double_underscore_closing_only() {
5991 let doc = markdown_to_adf("__foo__bar").unwrap();
5993 let content = doc.content[0].content.as_ref().unwrap();
5994 assert_eq!(content.len(), 1);
5995 assert_eq!(content[0].text.as_deref(), Some("__foo__bar"));
5996 }
5997
5998 #[test]
5999 fn intraword_triple_underscore_closing_only() {
6000 let doc = markdown_to_adf("___foo___bar").unwrap();
6002 let content = doc.content[0].content.as_ref().unwrap();
6003 assert_eq!(content.len(), 1);
6004 assert_eq!(content[0].text.as_deref(), Some("___foo___bar"));
6005 }
6006
6007 #[test]
6008 fn asterisk_emphasis_unaffected_by_intraword_fix() {
6009 let doc = markdown_to_adf("foo*bar*baz").unwrap();
6011 let content = doc.content[0].content.as_ref().unwrap();
6012 assert!(content.len() > 1 || content[0].marks.is_some());
6014 }
6015
6016 #[test]
6017 fn intraword_underscore_at_start_of_text() {
6018 let doc = markdown_to_adf("_italic_ word").unwrap();
6020 let content = doc.content[0].content.as_ref().unwrap();
6021 assert_eq!(content[0].text.as_deref(), Some("italic"));
6022 let marks = content[0].marks.as_ref().unwrap();
6023 assert_eq!(marks[0].mark_type, "em");
6024 }
6025
6026 #[test]
6027 fn intraword_underscore_at_end_of_text() {
6028 let doc = markdown_to_adf("word _italic_").unwrap();
6030 let content = doc.content[0].content.as_ref().unwrap();
6031 let last = content.last().unwrap();
6032 assert_eq!(last.text.as_deref(), Some("italic"));
6033 let marks = last.marks.as_ref().unwrap();
6034 assert_eq!(marks[0].mark_type, "em");
6035 }
6036
6037 #[test]
6038 fn intraword_underscore_opening_only() {
6039 let doc = markdown_to_adf("a_b c_d").unwrap();
6042 let content = doc.content[0].content.as_ref().unwrap();
6043 assert_eq!(content.len(), 1);
6044 assert_eq!(content[0].text.as_deref(), Some("a_b c_d"));
6045 }
6046
6047 #[test]
6048 fn intraword_underscore_roundtrip() {
6049 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call the do_something_useful function"}]}]}"#;
6051 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6052 let jfm = adf_to_markdown(&adf).unwrap();
6053 let roundtripped = markdown_to_adf(&jfm).unwrap();
6054 let content = roundtripped.content[0].content.as_ref().unwrap();
6055 assert_eq!(content.len(), 1, "should round-trip as a single text node");
6056 assert_eq!(
6057 content[0].text.as_deref(),
6058 Some("call the do_something_useful function")
6059 );
6060 assert!(content[0].marks.is_none());
6061 }
6062
6063 #[test]
6064 fn asterisk_emphasis_roundtrip() {
6065 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status: *confirmed* and active"}]}]}"#;
6067 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6068 let jfm = adf_to_markdown(&adf).unwrap();
6069 let roundtripped = markdown_to_adf(&jfm).unwrap();
6070 let content = roundtripped.content[0].content.as_ref().unwrap();
6071 assert_eq!(content.len(), 1, "should round-trip as a single text node");
6072 assert_eq!(
6073 content[0].text.as_deref(),
6074 Some("Status: *confirmed* and active")
6075 );
6076 assert!(content[0].marks.is_none());
6077 }
6078
6079 #[test]
6080 fn double_asterisk_roundtrip() {
6081 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use **kwargs in Python"}]}]}"#;
6083 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6084 let jfm = adf_to_markdown(&adf).unwrap();
6085 let roundtripped = markdown_to_adf(&jfm).unwrap();
6086 let content = roundtripped.content[0].content.as_ref().unwrap();
6087 assert_eq!(content.len(), 1, "should round-trip as a single text node");
6088 assert_eq!(content[0].text.as_deref(), Some("Use **kwargs in Python"));
6089 assert!(content[0].marks.is_none());
6090 }
6091
6092 #[test]
6093 fn asterisk_with_em_mark_roundtrip() {
6094 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a*b","marks":[{"type":"em"}]}]}]}"#;
6096 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6097 let jfm = adf_to_markdown(&adf).unwrap();
6098 let roundtripped = markdown_to_adf(&jfm).unwrap();
6099 let content = roundtripped.content[0].content.as_ref().unwrap();
6100 let em_node = content.iter().find(|n| {
6102 n.marks
6103 .as_ref()
6104 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
6105 });
6106 assert!(em_node.is_some(), "should have an em-marked node");
6107 assert_eq!(em_node.unwrap().text.as_deref(), Some("a*b"));
6108 }
6109
6110 #[test]
6111 fn lone_asterisk_roundtrip() {
6112 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"rating: 5 * stars"}]}]}"#;
6114 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6115 let jfm = adf_to_markdown(&adf).unwrap();
6116 let roundtripped = markdown_to_adf(&jfm).unwrap();
6117 let content = roundtripped.content[0].content.as_ref().unwrap();
6118 assert_eq!(content.len(), 1, "should round-trip as a single text node");
6119 assert_eq!(content[0].text.as_deref(), Some("rating: 5 * stars"));
6120 }
6121
6122 #[test]
6123 fn escape_emphasis_markers_unit() {
6124 assert_eq!(escape_emphasis_markers("hello"), "hello");
6125 assert_eq!(escape_emphasis_markers("*bold*"), r"\*bold\*");
6126 assert_eq!(escape_emphasis_markers("**strong**"), r"\*\*strong\*\*");
6127 assert_eq!(escape_emphasis_markers("no stars"), "no stars");
6128 assert_eq!(escape_emphasis_markers("a * b"), r"a \* b");
6129 assert_eq!(escape_emphasis_markers(""), "");
6130 }
6131
6132 #[test]
6133 fn find_unescaped_skips_backslash_escaped() {
6134 assert_eq!(find_unescaped(r"a\*\*b**", "**"), Some(6));
6136 assert_eq!(find_unescaped(r"a\*\*b", "**"), None);
6138 assert_eq!(find_unescaped("a**b", "**"), Some(1));
6140 assert_eq!(find_unescaped("", "**"), None);
6142 }
6143
6144 #[test]
6145 fn find_unescaped_char_skips_backslash_escaped() {
6146 assert_eq!(find_unescaped_char(r"a\*b*", b'*'), Some(4));
6148 assert_eq!(find_unescaped_char(r"\*", b'*'), None);
6150 assert_eq!(find_unescaped_char("a*b", b'*'), Some(1));
6152 assert_eq!(find_unescaped_char("", b'*'), None);
6154 }
6155
6156 #[test]
6157 fn double_asterisk_in_strong_mark_roundtrip() {
6158 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call **kwargs","marks":[{"type":"strong"}]}]}]}"#;
6160 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6161 let jfm = adf_to_markdown(&adf).unwrap();
6162 let roundtripped = markdown_to_adf(&jfm).unwrap();
6163 let content = roundtripped.content[0].content.as_ref().unwrap();
6164 let strong_node = content.iter().find(|n| {
6165 n.marks
6166 .as_ref()
6167 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
6168 });
6169 assert!(strong_node.is_some(), "should have a strong-marked node");
6170 assert_eq!(strong_node.unwrap().text.as_deref(), Some("call **kwargs"));
6171 }
6172
6173 #[test]
6174 fn backtick_code_roundtrip() {
6175 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Set `max_retries` to 3 in the config"}]}]}"#;
6177 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6178 let jfm = adf_to_markdown(&adf).unwrap();
6179 let roundtripped = markdown_to_adf(&jfm).unwrap();
6180 let content = roundtripped.content[0].content.as_ref().unwrap();
6181 assert_eq!(content.len(), 1, "should round-trip as a single text node");
6182 assert_eq!(
6183 content[0].text.as_deref(),
6184 Some("Set `max_retries` to 3 in the config")
6185 );
6186 assert!(content[0].marks.is_none());
6187 }
6188
6189 #[test]
6190 fn multiple_backtick_spans_roundtrip() {
6191 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use `foo` and `bar` together"}]}]}"#;
6193 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6194 let jfm = adf_to_markdown(&adf).unwrap();
6195 let roundtripped = markdown_to_adf(&jfm).unwrap();
6196 let content = roundtripped.content[0].content.as_ref().unwrap();
6197 assert_eq!(content.len(), 1, "should round-trip as a single text node");
6198 assert_eq!(
6199 content[0].text.as_deref(),
6200 Some("Use `foo` and `bar` together")
6201 );
6202 assert!(content[0].marks.is_none());
6203 }
6204
6205 #[test]
6206 fn lone_backtick_roundtrip() {
6207 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use a ` character"}]}]}"#;
6209 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6210 let jfm = adf_to_markdown(&adf).unwrap();
6211 let roundtripped = markdown_to_adf(&jfm).unwrap();
6212 let content = roundtripped.content[0].content.as_ref().unwrap();
6213 assert_eq!(content.len(), 1, "should round-trip as a single text node");
6214 assert_eq!(content[0].text.as_deref(), Some("Use a ` character"));
6215 assert!(content[0].marks.is_none());
6216 }
6217
6218 #[test]
6219 fn backtick_with_code_mark_roundtrip() {
6220 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"max_retries","marks":[{"type":"code"}]}]}]}"#;
6222 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6223 let jfm = adf_to_markdown(&adf).unwrap();
6224 assert_eq!(jfm.trim(), "`max_retries`");
6225 let roundtripped = markdown_to_adf(&jfm).unwrap();
6226 let content = roundtripped.content[0].content.as_ref().unwrap();
6227 let code_node = content.iter().find(|n| {
6228 n.marks
6229 .as_ref()
6230 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
6231 });
6232 assert!(code_node.is_some(), "should have a code-marked node");
6233 assert_eq!(code_node.unwrap().text.as_deref(), Some("max_retries"));
6234 }
6235
6236 #[test]
6237 fn backtick_with_em_mark_roundtrip() {
6238 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"use `cfg`","marks":[{"type":"em"}]}]}]}"#;
6240 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6241 let jfm = adf_to_markdown(&adf).unwrap();
6242 let roundtripped = markdown_to_adf(&jfm).unwrap();
6243 let content = roundtripped.content[0].content.as_ref().unwrap();
6244 let em_node = content.iter().find(|n| {
6245 n.marks
6246 .as_ref()
6247 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
6248 });
6249 assert!(em_node.is_some(), "should have an em-marked node");
6250 assert_eq!(em_node.unwrap().text.as_deref(), Some("use `cfg`"));
6251 }
6252
6253 #[test]
6254 fn escape_backticks_unit() {
6255 assert_eq!(escape_backticks("hello"), "hello");
6256 assert_eq!(escape_backticks("`code`"), r"\`code\`");
6257 assert_eq!(escape_backticks("no ticks"), "no ticks");
6258 assert_eq!(escape_backticks("a ` b"), r"a \` b");
6259 assert_eq!(escape_backticks(""), "");
6260 assert_eq!(escape_backticks("`a` and `b`"), r"\`a\` and \`b\`");
6261 }
6262
6263 #[test]
6266 fn backslash_in_text_roundtrip() {
6267 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"The path is C:\\Users\\admin\\file.txt"}]}]}"#;
6269 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6270 let jfm = adf_to_markdown(&adf).unwrap();
6271 let roundtripped = markdown_to_adf(&jfm).unwrap();
6272 let content = roundtripped.content[0].content.as_ref().unwrap();
6273 assert_eq!(content.len(), 1, "should round-trip as a single text node");
6274 assert_eq!(
6275 content[0].text.as_deref(),
6276 Some(r"The path is C:\Users\admin\file.txt")
6277 );
6278 }
6279
6280 #[test]
6281 fn backslash_emitted_as_double_backslash() {
6282 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\b"}]}]}"#;
6283 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6284 let jfm = adf_to_markdown(&adf).unwrap();
6285 assert!(
6286 jfm.contains(r"a\\b"),
6287 "JFM should contain escaped backslash: {jfm}"
6288 );
6289 }
6290
6291 #[test]
6292 fn consecutive_backslashes_roundtrip() {
6293 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\\\b"}]}]}"#;
6294 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6295 let jfm = adf_to_markdown(&adf).unwrap();
6296 let roundtripped = markdown_to_adf(&jfm).unwrap();
6297 let content = roundtripped.content[0].content.as_ref().unwrap();
6298 assert_eq!(
6299 content[0].text.as_deref(),
6300 Some(r"a\\b"),
6301 "consecutive backslashes should survive round-trip"
6302 );
6303 }
6304
6305 #[test]
6306 fn backslash_with_strong_mark_roundtrip() {
6307 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"strong"}]}]}]}"#;
6308 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6309 let jfm = adf_to_markdown(&adf).unwrap();
6310 let roundtripped = markdown_to_adf(&jfm).unwrap();
6311 let content = roundtripped.content[0].content.as_ref().unwrap();
6312 let strong_node = content.iter().find(|n| {
6313 n.marks
6314 .as_ref()
6315 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
6316 });
6317 assert!(strong_node.is_some(), "should have a strong-marked node");
6318 assert_eq!(strong_node.unwrap().text.as_deref(), Some(r"C:\Users"));
6319 }
6320
6321 #[test]
6322 fn backslash_with_code_mark_not_escaped() {
6323 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"code"}]}]}]}"#;
6325 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6326 let jfm = adf_to_markdown(&adf).unwrap();
6327 assert_eq!(jfm.trim(), r"`C:\Users`");
6328 let roundtripped = markdown_to_adf(&jfm).unwrap();
6329 let content = roundtripped.content[0].content.as_ref().unwrap();
6330 let code_node = content.iter().find(|n| {
6331 n.marks
6332 .as_ref()
6333 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
6334 });
6335 assert!(code_node.is_some(), "should have a code-marked node");
6336 assert_eq!(code_node.unwrap().text.as_deref(), Some(r"C:\Users"));
6337 }
6338
6339 #[test]
6340 fn backslash_before_special_chars_roundtrip() {
6341 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"\\*not bold\\*"}]}]}"#;
6343 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6344 let jfm = adf_to_markdown(&adf).unwrap();
6345 let roundtripped = markdown_to_adf(&jfm).unwrap();
6346 let content = roundtripped.content[0].content.as_ref().unwrap();
6347 assert_eq!(
6348 content[0].text.as_deref(),
6349 Some(r"\*not bold\*"),
6350 "backslash before special char should survive round-trip"
6351 );
6352 }
6353
6354 #[test]
6355 fn backslash_and_newline_in_text_roundtrip() {
6356 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\path\nline2"}]}]}"#;
6358 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6359 let jfm = adf_to_markdown(&adf).unwrap();
6360 let roundtripped = markdown_to_adf(&jfm).unwrap();
6361 let content = roundtripped.content[0].content.as_ref().unwrap();
6362 assert_eq!(
6363 content[0].text.as_deref(),
6364 Some("C:\\path\nline2"),
6365 "backslash and newline should both survive round-trip"
6366 );
6367 }
6368
6369 #[test]
6370 fn lone_backslash_roundtrip() {
6371 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a \\ b"}]}]}"#;
6372 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6373 let jfm = adf_to_markdown(&adf).unwrap();
6374 let roundtripped = markdown_to_adf(&jfm).unwrap();
6375 let content = roundtripped.content[0].content.as_ref().unwrap();
6376 assert_eq!(content[0].text.as_deref(), Some(r"a \ b"));
6377 }
6378
6379 #[test]
6380 fn trailing_backslash_in_text_roundtrip() {
6381 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\"}]}]}"#;
6383 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6384 let jfm = adf_to_markdown(&adf).unwrap();
6385 let roundtripped = markdown_to_adf(&jfm).unwrap();
6386 let content = roundtripped.content[0].content.as_ref().unwrap();
6387 assert_eq!(
6388 content[0].text.as_deref(),
6389 Some(r"end\"),
6390 "trailing backslash should survive round-trip"
6391 );
6392 }
6393
6394 #[test]
6395 fn escape_bare_urls_unit() {
6396 assert_eq!(escape_bare_urls("hello"), "hello");
6397 assert_eq!(escape_bare_urls(""), "");
6398 assert_eq!(
6399 escape_bare_urls("https://example.com"),
6400 r"\https://example.com"
6401 );
6402 assert_eq!(
6403 escape_bare_urls("http://example.com"),
6404 r"\http://example.com"
6405 );
6406 assert_eq!(
6407 escape_bare_urls("see https://a.com and https://b.com"),
6408 r"see \https://a.com and \https://b.com"
6409 );
6410 assert_eq!(escape_bare_urls("http header"), "http header");
6412 assert_eq!(escape_bare_urls("https is secure"), "https is secure");
6413 }
6414
6415 #[test]
6416 fn heading_not_valid_without_space() {
6417 let doc = markdown_to_adf("#Title").unwrap();
6419 assert_eq!(doc.content[0].node_type, "paragraph");
6420 }
6421
6422 #[test]
6423 fn heading_level_too_high() {
6424 let doc = markdown_to_adf("####### Not a heading").unwrap();
6426 assert_eq!(doc.content[0].node_type, "paragraph");
6427 }
6428
6429 #[test]
6430 fn empty_document() {
6431 let doc = markdown_to_adf("").unwrap();
6432 assert!(doc.content.is_empty());
6433 }
6434
6435 #[test]
6436 fn only_blank_lines() {
6437 let doc = markdown_to_adf("\n\n\n").unwrap();
6438 assert!(doc.content.is_empty());
6439 }
6440
6441 #[test]
6442 fn code_block_unterminated() {
6443 let md = "```rust\nfn main() {}";
6445 let doc = markdown_to_adf(md).unwrap();
6446 assert_eq!(doc.content[0].node_type, "codeBlock");
6447 }
6448
6449 #[test]
6450 fn mixed_document() {
6451 let md = "# Title\n\nA paragraph.\n\n- Item\n\n```\ncode\n```\n\n> quote\n\n---\n\n1. numbered\n";
6452 let doc = markdown_to_adf(md).unwrap();
6453 let types: Vec<&str> = doc.content.iter().map(|n| n.node_type.as_str()).collect();
6454 assert_eq!(
6455 types,
6456 vec![
6457 "heading",
6458 "paragraph",
6459 "bulletList",
6460 "codeBlock",
6461 "blockquote",
6462 "rule",
6463 "orderedList",
6464 ]
6465 );
6466 }
6467
6468 #[test]
6471 fn adf_ordered_list_to_markdown() {
6472 let doc = AdfDocument {
6473 version: 1,
6474 doc_type: "doc".to_string(),
6475 content: vec![AdfNode::ordered_list(
6476 vec![
6477 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("First")])]),
6478 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("Second")])]),
6479 ],
6480 None,
6481 )],
6482 };
6483 let md = adf_to_markdown(&doc).unwrap();
6484 assert!(md.contains("1. First"));
6485 assert!(md.contains("2. Second"));
6486 }
6487
6488 #[test]
6489 fn adf_ordered_list_custom_start() {
6490 let doc = AdfDocument {
6491 version: 1,
6492 doc_type: "doc".to_string(),
6493 content: vec![AdfNode::ordered_list(
6494 vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
6495 AdfNode::text("Third"),
6496 ])])],
6497 Some(3),
6498 )],
6499 };
6500 let md = adf_to_markdown(&doc).unwrap();
6501 assert!(md.contains("3. Third"));
6502 }
6503
6504 #[test]
6505 fn adf_blockquote_to_markdown() {
6506 let doc = AdfDocument {
6507 version: 1,
6508 doc_type: "doc".to_string(),
6509 content: vec![AdfNode::blockquote(vec![AdfNode::paragraph(vec![
6510 AdfNode::text("A quote"),
6511 ])])],
6512 };
6513 let md = adf_to_markdown(&doc).unwrap();
6514 assert!(md.contains("> A quote"));
6515 }
6516
6517 #[test]
6518 fn adf_table_to_markdown() {
6519 let doc = AdfDocument {
6520 version: 1,
6521 doc_type: "doc".to_string(),
6522 content: vec![AdfNode::table(vec![
6523 AdfNode::table_row(vec![
6524 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Name")])]),
6525 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Value")])]),
6526 ]),
6527 AdfNode::table_row(vec![
6528 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a")])]),
6529 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("1")])]),
6530 ]),
6531 ])],
6532 };
6533 let md = adf_to_markdown(&doc).unwrap();
6534 assert!(md.contains("| Name | Value |"));
6535 assert!(md.contains("| --- | --- |"));
6536 assert!(md.contains("| a | 1 |"));
6537 }
6538
6539 #[test]
6540 fn adf_media_to_markdown() {
6541 let doc = AdfDocument {
6542 version: 1,
6543 doc_type: "doc".to_string(),
6544 content: vec![AdfNode::media_single(
6545 "https://example.com/img.png",
6546 Some("Alt"),
6547 )],
6548 };
6549 let md = adf_to_markdown(&doc).unwrap();
6550 assert!(md.contains(""));
6551 }
6552
6553 #[test]
6554 fn adf_media_no_alt_to_markdown() {
6555 let doc = AdfDocument {
6556 version: 1,
6557 doc_type: "doc".to_string(),
6558 content: vec![AdfNode::media_single("https://example.com/img.png", None)],
6559 };
6560 let md = adf_to_markdown(&doc).unwrap();
6561 assert!(md.contains(""));
6562 }
6563
6564 #[test]
6565 fn adf_italic_to_markdown() {
6566 let doc = AdfDocument {
6567 version: 1,
6568 doc_type: "doc".to_string(),
6569 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6570 "emphasis",
6571 vec![AdfMark::em()],
6572 )])],
6573 };
6574 let md = adf_to_markdown(&doc).unwrap();
6575 assert_eq!(md.trim(), "*emphasis*");
6576 }
6577
6578 #[test]
6579 fn adf_strikethrough_to_markdown() {
6580 let doc = AdfDocument {
6581 version: 1,
6582 doc_type: "doc".to_string(),
6583 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6584 "deleted",
6585 vec![AdfMark::strike()],
6586 )])],
6587 };
6588 let md = adf_to_markdown(&doc).unwrap();
6589 assert_eq!(md.trim(), "~~deleted~~");
6590 }
6591
6592 #[test]
6593 fn adf_inline_code_to_markdown() {
6594 let doc = AdfDocument {
6595 version: 1,
6596 doc_type: "doc".to_string(),
6597 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6598 "code",
6599 vec![AdfMark::code()],
6600 )])],
6601 };
6602 let md = adf_to_markdown(&doc).unwrap();
6603 assert_eq!(md.trim(), "`code`");
6604 }
6605
6606 #[test]
6607 fn adf_code_with_link_to_markdown() {
6608 let doc = AdfDocument {
6609 version: 1,
6610 doc_type: "doc".to_string(),
6611 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6612 "func",
6613 vec![AdfMark::code(), AdfMark::link("https://example.com")],
6614 )])],
6615 };
6616 let md = adf_to_markdown(&doc).unwrap();
6617 assert_eq!(md.trim(), "[`func`](https://example.com)");
6618 }
6619
6620 #[test]
6621 fn adf_bold_italic_to_markdown() {
6622 let doc = AdfDocument {
6623 version: 1,
6624 doc_type: "doc".to_string(),
6625 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6626 "both",
6627 vec![AdfMark::strong(), AdfMark::em()],
6628 )])],
6629 };
6630 let md = adf_to_markdown(&doc).unwrap();
6631 assert_eq!(md.trim(), "***both***");
6632 }
6633
6634 #[test]
6635 fn adf_bold_link_to_markdown() {
6636 let doc = AdfDocument {
6637 version: 1,
6638 doc_type: "doc".to_string(),
6639 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6640 "bold link",
6641 vec![AdfMark::strong(), AdfMark::link("https://example.com")],
6642 )])],
6643 };
6644 let md = adf_to_markdown(&doc).unwrap();
6645 assert_eq!(md.trim(), "**[bold link](https://example.com)**");
6646 }
6647
6648 #[test]
6649 fn adf_strikethrough_bold_to_markdown() {
6650 let doc = AdfDocument {
6651 version: 1,
6652 doc_type: "doc".to_string(),
6653 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6654 "struck",
6655 vec![AdfMark::strike(), AdfMark::strong()],
6656 )])],
6657 };
6658 let md = adf_to_markdown(&doc).unwrap();
6659 assert_eq!(md.trim(), "~~**struck**~~");
6660 }
6661
6662 #[test]
6663 fn adf_hard_break_to_markdown() {
6664 let doc = AdfDocument {
6665 version: 1,
6666 doc_type: "doc".to_string(),
6667 content: vec![AdfNode::paragraph(vec![
6668 AdfNode::text("Line 1"),
6669 AdfNode::hard_break(),
6670 AdfNode::text("Line 2"),
6671 ])],
6672 };
6673 let md = adf_to_markdown(&doc).unwrap();
6674 assert!(md.contains("Line 1\\\n Line 2"));
6675 }
6676
6677 #[test]
6678 #[test]
6679 fn adf_unsupported_inline_to_markdown() {
6680 let doc = AdfDocument {
6681 version: 1,
6682 doc_type: "doc".to_string(),
6683 content: vec![AdfNode::paragraph(vec![AdfNode {
6684 node_type: "unknownInline".to_string(),
6685 attrs: None,
6686 content: None,
6687 text: None,
6688 marks: None,
6689 local_id: None,
6690 parameters: None,
6691 }])],
6692 };
6693 let md = adf_to_markdown(&doc).unwrap();
6694 assert!(md.contains("<!-- unsupported inline: unknownInline -->"));
6695 }
6696
6697 #[test]
6700 fn adf_media_inline_to_markdown() {
6701 let doc = AdfDocument {
6702 version: 1,
6703 doc_type: "doc".to_string(),
6704 content: vec![AdfNode::paragraph(vec![
6705 AdfNode::text("see "),
6706 AdfNode::media_inline(serde_json::json!({
6707 "type": "image",
6708 "id": "abcdef01-2345-6789-abcd-abcdef012345",
6709 "collection": "contentId-111111",
6710 "width": 200,
6711 "height": 100
6712 })),
6713 AdfNode::text(" for details"),
6714 ])],
6715 };
6716 let md = adf_to_markdown(&doc).unwrap();
6717 assert!(md.contains(":media-inline[]{"), "got: {md}");
6718 assert!(md.contains("type=image"));
6719 assert!(md.contains("id=abcdef01-2345-6789-abcd-abcdef012345"));
6720 assert!(md.contains("collection=contentId-111111"));
6721 assert!(md.contains("width=200"));
6722 assert!(md.contains("height=100"));
6723 assert!(!md.contains("<!-- unsupported inline"));
6724 }
6725
6726 #[test]
6727 fn media_inline_round_trip() {
6728 let doc = AdfDocument {
6729 version: 1,
6730 doc_type: "doc".to_string(),
6731 content: vec![AdfNode::paragraph(vec![
6732 AdfNode::text("see "),
6733 AdfNode::media_inline(serde_json::json!({
6734 "type": "image",
6735 "id": "abcdef01-2345-6789-abcd-abcdef012345",
6736 "collection": "contentId-111111",
6737 "width": 200,
6738 "height": 100
6739 })),
6740 AdfNode::text(" for details"),
6741 ])],
6742 };
6743 let md = adf_to_markdown(&doc).unwrap();
6744 let rt = markdown_to_adf(&md).unwrap();
6745
6746 let content = rt.content[0].content.as_ref().unwrap();
6747 assert_eq!(content[0].text.as_deref(), Some("see "));
6748 assert_eq!(content[1].node_type, "mediaInline");
6749 let attrs = content[1].attrs.as_ref().unwrap();
6750 assert_eq!(attrs["type"], "image");
6751 assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
6752 assert_eq!(attrs["collection"], "contentId-111111");
6753 assert_eq!(attrs["width"], 200);
6754 assert_eq!(attrs["height"], 100);
6755 assert_eq!(content[2].text.as_deref(), Some(" for details"));
6756 }
6757
6758 #[test]
6759 fn media_inline_external_url_round_trip() {
6760 let doc = AdfDocument {
6761 version: 1,
6762 doc_type: "doc".to_string(),
6763 content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
6764 serde_json::json!({
6765 "type": "external",
6766 "url": "https://example.com/image.png",
6767 "alt": "example",
6768 "width": 400,
6769 "height": 300
6770 }),
6771 )])],
6772 };
6773 let md = adf_to_markdown(&doc).unwrap();
6774 let rt = markdown_to_adf(&md).unwrap();
6775
6776 let content = rt.content[0].content.as_ref().unwrap();
6777 assert_eq!(content[0].node_type, "mediaInline");
6778 let attrs = content[0].attrs.as_ref().unwrap();
6779 assert_eq!(attrs["type"], "external");
6780 assert_eq!(attrs["url"], "https://example.com/image.png");
6781 assert_eq!(attrs["alt"], "example");
6782 assert_eq!(attrs["width"], 400);
6783 assert_eq!(attrs["height"], 300);
6784 }
6785
6786 #[test]
6787 fn media_inline_minimal_attrs() {
6788 let doc = AdfDocument {
6789 version: 1,
6790 doc_type: "doc".to_string(),
6791 content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
6792 serde_json::json!({"type": "file", "id": "abc-123"}),
6793 )])],
6794 };
6795 let md = adf_to_markdown(&doc).unwrap();
6796 let rt = markdown_to_adf(&md).unwrap();
6797
6798 let content = rt.content[0].content.as_ref().unwrap();
6799 assert_eq!(content[0].node_type, "mediaInline");
6800 let attrs = content[0].attrs.as_ref().unwrap();
6801 assert_eq!(attrs["type"], "file");
6802 assert_eq!(attrs["id"], "abc-123");
6803 }
6804
6805 #[test]
6806 fn media_inline_from_issue_476_reproducer() {
6807 let adf_json: serde_json::Value = serde_json::json!({
6809 "type": "doc",
6810 "version": 1,
6811 "content": [
6812 {
6813 "type": "paragraph",
6814 "content": [
6815 {"type": "text", "text": "see "},
6816 {
6817 "type": "mediaInline",
6818 "attrs": {
6819 "collection": "contentId-111111",
6820 "height": 100,
6821 "id": "abcdef01-2345-6789-abcd-abcdef012345",
6822 "localId": "aabbccdd-1234-5678-abcd-aabbccdd1234",
6823 "type": "image",
6824 "width": 200
6825 }
6826 },
6827 {"type": "text", "text": " for details"}
6828 ]
6829 }
6830 ]
6831 });
6832 let doc: AdfDocument = serde_json::from_value(adf_json).unwrap();
6833 let md = adf_to_markdown(&doc).unwrap();
6834 assert!(
6835 !md.contains("<!-- unsupported inline"),
6836 "mediaInline should not be unsupported; got: {md}"
6837 );
6838
6839 let rt = markdown_to_adf(&md).unwrap();
6840 let content = rt.content[0].content.as_ref().unwrap();
6841 assert_eq!(content[1].node_type, "mediaInline");
6842 let attrs = content[1].attrs.as_ref().unwrap();
6843 assert_eq!(attrs["type"], "image");
6844 assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
6845 assert_eq!(attrs["collection"], "contentId-111111");
6846 assert_eq!(attrs["width"], 200);
6847 assert_eq!(attrs["height"], 100);
6848 assert_eq!(attrs["localId"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
6849 }
6850
6851 #[test]
6852 fn emoji_shortcode() {
6853 let doc = markdown_to_adf("Hello :wave: world").unwrap();
6854 let content = doc.content[0].content.as_ref().unwrap();
6855 assert_eq!(content[0].text.as_deref(), Some("Hello "));
6856 assert_eq!(content[1].node_type, "emoji");
6857 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":wave:");
6858 assert_eq!(content[2].text.as_deref(), Some(" world"));
6859 }
6860
6861 #[test]
6862 fn adf_emoji_to_markdown() {
6863 let doc = AdfDocument {
6864 version: 1,
6865 doc_type: "doc".to_string(),
6866 content: vec![AdfNode::paragraph(vec![AdfNode::emoji("thumbsup")])],
6867 };
6868 let md = adf_to_markdown(&doc).unwrap();
6869 assert!(md.contains(":thumbsup:"));
6870 }
6871
6872 #[test]
6873 fn adf_emoji_with_colon_prefix_to_markdown() {
6874 let doc = AdfDocument {
6876 version: 1,
6877 doc_type: "doc".to_string(),
6878 content: vec![AdfNode::paragraph(vec![AdfNode {
6879 node_type: "emoji".to_string(),
6880 attrs: Some(serde_json::json!({"shortName": ":thumbsup:"})),
6881 content: None,
6882 text: None,
6883 marks: None,
6884 local_id: None,
6885 parameters: None,
6886 }])],
6887 };
6888 let md = adf_to_markdown(&doc).unwrap();
6889 assert!(md.contains(":thumbsup:"));
6890 assert!(!md.contains("::thumbsup::"));
6892 }
6893
6894 #[test]
6895 fn round_trip_emoji() {
6896 let md = "Hello :wave: world\n";
6897 let doc = markdown_to_adf(md).unwrap();
6898 let result = adf_to_markdown(&doc).unwrap();
6899 assert!(result.contains(":wave:"));
6900 }
6901
6902 #[test]
6903 fn emoji_with_id_and_text_round_trips() {
6904 let doc = AdfDocument {
6905 version: 1,
6906 doc_type: "doc".to_string(),
6907 content: vec![AdfNode::paragraph(vec![AdfNode {
6908 node_type: "emoji".to_string(),
6909 attrs: Some(
6910 serde_json::json!({"shortName": ":check_mark:", "id": "2705", "text": "✅"}),
6911 ),
6912 content: None,
6913 text: None,
6914 marks: None,
6915 local_id: None,
6916 parameters: None,
6917 }])],
6918 };
6919 let md = adf_to_markdown(&doc).unwrap();
6920 assert!(md.contains(":check_mark:"), "shortcode present: {md}");
6921 assert!(md.contains("id="), "id attr present: {md}");
6922 assert!(md.contains("text="), "text attr present: {md}");
6923
6924 let round_tripped = markdown_to_adf(&md).unwrap();
6926 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
6927 let attrs = emoji.attrs.as_ref().unwrap();
6928 assert_eq!(attrs["shortName"], ":check_mark:");
6929 assert_eq!(attrs["id"], "2705");
6930 assert_eq!(attrs["text"], "✅");
6931 }
6932
6933 #[test]
6934 fn emoji_without_extra_attrs_still_works() {
6935 let md = "Hello :wave: world\n";
6936 let doc = markdown_to_adf(md).unwrap();
6937 let emoji = &doc.content[0].content.as_ref().unwrap()[1];
6938 assert_eq!(emoji.attrs.as_ref().unwrap()["shortName"], ":wave:");
6939 assert!(emoji.attrs.as_ref().unwrap().get("id").is_none());
6941 }
6942
6943 #[test]
6944 fn emoji_shortname_preserves_colons_round_trip() {
6945 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
6947 {"type":"emoji","attrs":{"shortName":":cross_mark:","id":"atlassian-cross_mark","text":"❌"}}
6948 ]}]}"#;
6949 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6950
6951 let md = adf_to_markdown(&doc).unwrap();
6953 let round_tripped = markdown_to_adf(&md).unwrap();
6954 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
6955 let attrs = emoji.attrs.as_ref().unwrap();
6956 assert_eq!(
6957 attrs["shortName"], ":cross_mark:",
6958 "shortName should preserve colons, got: {}",
6959 attrs["shortName"]
6960 );
6961 assert_eq!(attrs["id"], "atlassian-cross_mark");
6962 assert_eq!(attrs["text"], "❌");
6963 }
6964
6965 #[test]
6966 fn emoji_shortname_without_colons_preserved() {
6967 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
6969 {"type":"emoji","attrs":{"shortName":"white_check_mark","id":"2705","text":"✅"}}
6970 ]}]}"#;
6971 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6972 let md = adf_to_markdown(&doc).unwrap();
6973 let round_tripped = markdown_to_adf(&md).unwrap();
6974 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
6975 let attrs = emoji.attrs.as_ref().unwrap();
6976 assert_eq!(
6977 attrs["shortName"], "white_check_mark",
6978 "shortName without colons should stay without colons, got: {}",
6979 attrs["shortName"]
6980 );
6981 }
6982
6983 #[test]
6984 fn colon_in_text_not_emoji() {
6985 let doc = markdown_to_adf("Time is 10:30 today").unwrap();
6987 let content = doc.content[0].content.as_ref().unwrap();
6988 assert_eq!(content.len(), 1);
6989 assert_eq!(content[0].node_type, "text");
6990 }
6991
6992 #[test]
6993 fn text_with_shortcode_pattern_round_trips_as_text() {
6994 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Alert :fire: triggered on pod:pod42"}]}]}"#;
6996 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6997
6998 let md = adf_to_markdown(&doc).unwrap();
6999 let round_tripped = markdown_to_adf(&md).unwrap();
7000 let content = round_tripped.content[0].content.as_ref().unwrap();
7001
7002 assert_eq!(
7003 content.len(),
7004 1,
7005 "should be a single text node, got: {content:?}"
7006 );
7007 assert_eq!(content[0].node_type, "text");
7008 assert_eq!(
7009 content[0].text.as_deref().unwrap(),
7010 "Alert :fire: triggered on pod:pod42"
7011 );
7012 }
7013
7014 #[test]
7015 fn double_colon_pattern_round_trips_as_text() {
7016 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status::Active::Running"}]}]}"#;
7018 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7019
7020 let md = adf_to_markdown(&doc).unwrap();
7021 let round_tripped = markdown_to_adf(&md).unwrap();
7022 let content = round_tripped.content[0].content.as_ref().unwrap();
7023
7024 assert_eq!(
7025 content.len(),
7026 1,
7027 "should be a single text node, got: {content:?}"
7028 );
7029 assert_eq!(content[0].node_type, "text");
7030 assert_eq!(
7031 content[0].text.as_deref().unwrap(),
7032 "Status::Active::Running"
7033 );
7034 }
7035
7036 #[test]
7037 fn real_emoji_node_still_round_trips() {
7038 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
7040 {"type":"text","text":"Hello "},
7041 {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
7042 {"type":"text","text":" world"}
7043 ]}]}"#;
7044 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7045
7046 let md = adf_to_markdown(&doc).unwrap();
7047 let round_tripped = markdown_to_adf(&md).unwrap();
7048 let content = round_tripped.content[0].content.as_ref().unwrap();
7049
7050 assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
7052 assert_eq!(content[0].text.as_deref(), Some("Hello "));
7053 assert_eq!(content[1].node_type, "emoji");
7054 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":fire:");
7055 assert_eq!(content[2].text.as_deref(), Some(" world"));
7056 }
7057
7058 #[test]
7059 fn text_shortcode_with_marks_round_trips() {
7060 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
7062 {"type":"text","text":"Alert :fire: triggered","marks":[{"type":"strong"}]}
7063 ]}]}"#;
7064 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7065
7066 let md = adf_to_markdown(&doc).unwrap();
7067 let round_tripped = markdown_to_adf(&md).unwrap();
7068 let content = round_tripped.content[0].content.as_ref().unwrap();
7069
7070 assert_eq!(
7071 content.len(),
7072 1,
7073 "should be single bold text node: {content:?}"
7074 );
7075 assert_eq!(content[0].node_type, "text");
7076 assert_eq!(
7077 content[0].text.as_deref().unwrap(),
7078 "Alert :fire: triggered"
7079 );
7080 assert!(content[0]
7081 .marks
7082 .as_ref()
7083 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong")));
7084 }
7085
7086 #[test]
7087 fn mixed_emoji_node_and_text_shortcode_round_trips() {
7088 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
7090 {"type":"emoji","attrs":{"shortName":":wave:","id":"1f44b","text":"👋"}},
7091 {"type":"text","text":" says :hello: to you"}
7092 ]}]}"#;
7093 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7094
7095 let md = adf_to_markdown(&doc).unwrap();
7096 let round_tripped = markdown_to_adf(&md).unwrap();
7097 let content = round_tripped.content[0].content.as_ref().unwrap();
7098
7099 assert_eq!(content.len(), 2, "should have 2 nodes: {content:?}");
7101 assert_eq!(content[0].node_type, "emoji");
7102 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":wave:");
7103 assert_eq!(content[1].node_type, "text");
7104 assert_eq!(content[1].text.as_deref().unwrap(), " says :hello: to you");
7105 }
7106
7107 #[test]
7108 fn adf_inline_card_to_markdown() {
7109 let doc = AdfDocument {
7110 version: 1,
7111 doc_type: "doc".to_string(),
7112 content: vec![AdfNode::paragraph(vec![AdfNode {
7113 node_type: "inlineCard".to_string(),
7114 attrs: Some(
7115 serde_json::json!({"url": "https://org.atlassian.net/browse/ACCS-4382"}),
7116 ),
7117 content: None,
7118 text: None,
7119 marks: None,
7120 local_id: None,
7121 parameters: None,
7122 }])],
7123 };
7124 let md = adf_to_markdown(&doc).unwrap();
7125 assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
7126 assert!(!md.contains("<!-- unsupported inline"));
7127 }
7128
7129 #[test]
7130 fn inline_card_directive_round_trips() {
7131 let original = AdfDocument {
7133 version: 1,
7134 doc_type: "doc".to_string(),
7135 content: vec![AdfNode::paragraph(vec![AdfNode::inline_card(
7136 "https://org.atlassian.net/browse/ACCS-4382",
7137 )])],
7138 };
7139 let md = adf_to_markdown(&original).unwrap();
7140 assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
7141 let restored = markdown_to_adf(&md).unwrap();
7142 let node = &restored.content[0].content.as_ref().unwrap()[0];
7143 assert_eq!(node.node_type, "inlineCard");
7144 assert_eq!(
7145 node.attrs.as_ref().unwrap()["url"],
7146 "https://org.atlassian.net/browse/ACCS-4382"
7147 );
7148 }
7149
7150 #[test]
7151 fn inline_card_directive_parsed_from_jfm() {
7152 let doc = markdown_to_adf("See :card[https://example.com/issue/123] for details.").unwrap();
7154 let nodes = doc.content[0].content.as_ref().unwrap();
7155 assert_eq!(nodes[0].node_type, "text");
7156 assert_eq!(nodes[0].text.as_deref(), Some("See "));
7157 assert_eq!(nodes[1].node_type, "inlineCard");
7158 assert_eq!(
7159 nodes[1].attrs.as_ref().unwrap()["url"],
7160 "https://example.com/issue/123"
7161 );
7162 assert_eq!(nodes[2].node_type, "text");
7163 assert_eq!(nodes[2].text.as_deref(), Some(" for details."));
7164 }
7165
7166 #[test]
7167 fn self_link_becomes_link_mark_not_inline_card() {
7168 let doc = markdown_to_adf("[https://example.com](https://example.com)").unwrap();
7171 let node = &doc.content[0].content.as_ref().unwrap()[0];
7172 assert_eq!(node.node_type, "text");
7173 assert_eq!(node.text.as_deref(), Some("https://example.com"));
7174 let mark = &node.marks.as_ref().unwrap()[0];
7175 assert_eq!(mark.mark_type, "link");
7176 assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
7177 }
7178
7179 #[test]
7180 fn url_link_text_with_trailing_slash_mismatch_becomes_link_mark() {
7181 let doc =
7184 markdown_to_adf("[https://octopz.example.com](https://octopz.example.com/)").unwrap();
7185 let node = &doc.content[0].content.as_ref().unwrap()[0];
7186 assert_eq!(node.node_type, "text");
7187 assert_eq!(node.text.as_deref(), Some("https://octopz.example.com"));
7188 let mark = &node.marks.as_ref().unwrap()[0];
7189 assert_eq!(mark.mark_type, "link");
7190 assert_eq!(
7191 mark.attrs.as_ref().unwrap()["href"],
7192 "https://octopz.example.com/"
7193 );
7194 }
7195
7196 #[test]
7197 fn named_link_does_not_become_inline_card() {
7198 let doc = markdown_to_adf("[#4668](https://github.com/org/repo/pull/4668)").unwrap();
7200 let node = &doc.content[0].content.as_ref().unwrap()[0];
7201 assert_eq!(node.node_type, "text");
7202 assert_eq!(node.text.as_deref(), Some("#4668"));
7203 let mark = &node.marks.as_ref().unwrap()[0];
7204 assert_eq!(mark.mark_type, "link");
7205 }
7206
7207 #[test]
7208 fn adf_inline_card_no_url_to_markdown() {
7209 let doc = AdfDocument {
7210 version: 1,
7211 doc_type: "doc".to_string(),
7212 content: vec![AdfNode::paragraph(vec![AdfNode {
7213 node_type: "inlineCard".to_string(),
7214 attrs: Some(serde_json::json!({})),
7215 content: None,
7216 text: None,
7217 marks: None,
7218 local_id: None,
7219 parameters: None,
7220 }])],
7221 };
7222 let md = adf_to_markdown(&doc).unwrap();
7223 assert!(!md.contains("<!-- unsupported inline"));
7225 }
7226
7227 #[test]
7228 fn adf_code_block_no_language_to_markdown() {
7229 let doc = AdfDocument {
7230 version: 1,
7231 doc_type: "doc".to_string(),
7232 content: vec![AdfNode::code_block(None, "plain code")],
7233 };
7234 let md = adf_to_markdown(&doc).unwrap();
7235 assert!(md.contains("```\n"));
7236 assert!(md.contains("plain code"));
7237 }
7238
7239 #[test]
7240 fn adf_code_block_empty_language_to_markdown() {
7241 let doc = AdfDocument {
7242 version: 1,
7243 doc_type: "doc".to_string(),
7244 content: vec![AdfNode::code_block(Some(""), "plain code")],
7245 };
7246 let md = adf_to_markdown(&doc).unwrap();
7247 assert!(md.contains("```\"\"\n"));
7248 assert!(md.contains("plain code"));
7249 }
7250
7251 #[test]
7254 fn round_trip_table() {
7255 let md = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
7256 let adf = markdown_to_adf(md).unwrap();
7257 let restored = adf_to_markdown(&adf).unwrap();
7258 assert!(restored.contains("| A | B |"));
7259 assert!(restored.contains("| 1 | 2 |"));
7260 }
7261
7262 #[test]
7263 fn round_trip_blockquote() {
7264 let md = "> This is quoted\n";
7265 let adf = markdown_to_adf(md).unwrap();
7266 let restored = adf_to_markdown(&adf).unwrap();
7267 assert!(restored.contains("> This is quoted"));
7268 }
7269
7270 #[test]
7271 fn round_trip_image() {
7272 let md = "\n";
7273 let adf = markdown_to_adf(md).unwrap();
7274 let restored = adf_to_markdown(&adf).unwrap();
7275 assert!(restored.contains(""));
7276 }
7277
7278 #[test]
7279 fn round_trip_ordered_list() {
7280 let md = "1. A\n2. B\n3. C\n";
7281 let adf = markdown_to_adf(md).unwrap();
7282 let restored = adf_to_markdown(&adf).unwrap();
7283 assert!(restored.contains("1. A"));
7284 assert!(restored.contains("2. B"));
7285 assert!(restored.contains("3. C"));
7286 }
7287
7288 #[test]
7289 fn round_trip_inline_marks() {
7290 let md = "Text with `code` and ~~strike~~ and [link](https://x.com).\n";
7291 let adf = markdown_to_adf(md).unwrap();
7292 let restored = adf_to_markdown(&adf).unwrap();
7293 assert!(restored.contains("`code`"));
7294 assert!(restored.contains("~~strike~~"));
7295 assert!(restored.contains("[link](https://x.com)"));
7296 }
7297
7298 #[test]
7301 fn panel_info() {
7302 let md = ":::panel{type=info}\nThis is informational.\n:::";
7303 let doc = markdown_to_adf(md).unwrap();
7304 assert_eq!(doc.content[0].node_type, "panel");
7305 assert_eq!(doc.content[0].attrs.as_ref().unwrap()["panelType"], "info");
7306 let inner = doc.content[0].content.as_ref().unwrap();
7307 assert_eq!(inner[0].node_type, "paragraph");
7308 }
7309
7310 #[test]
7311 fn adf_panel_to_markdown() {
7312 let doc = AdfDocument {
7313 version: 1,
7314 doc_type: "doc".to_string(),
7315 content: vec![AdfNode::panel(
7316 "warning",
7317 vec![AdfNode::paragraph(vec![AdfNode::text("Be careful.")])],
7318 )],
7319 };
7320 let md = adf_to_markdown(&doc).unwrap();
7321 assert!(md.contains(":::panel{type=warning}"));
7322 assert!(md.contains("Be careful."));
7323 assert!(md.contains(":::"));
7324 }
7325
7326 #[test]
7327 fn round_trip_panel() {
7328 let md = ":::panel{type=info}\nThis is informational.\n:::\n";
7329 let doc = markdown_to_adf(md).unwrap();
7330 let result = adf_to_markdown(&doc).unwrap();
7331 assert!(result.contains(":::panel{type=info}"));
7332 assert!(result.contains("This is informational."));
7333 }
7334
7335 #[test]
7336 fn expand_with_title() {
7337 let md = ":::expand{title=\"Click me\"}\nHidden content.\n:::";
7338 let doc = markdown_to_adf(md).unwrap();
7339 assert_eq!(doc.content[0].node_type, "expand");
7340 assert_eq!(doc.content[0].attrs.as_ref().unwrap()["title"], "Click me");
7341 }
7342
7343 #[test]
7344 fn adf_expand_to_markdown() {
7345 let doc = AdfDocument {
7346 version: 1,
7347 doc_type: "doc".to_string(),
7348 content: vec![AdfNode::expand(
7349 Some("Details"),
7350 vec![AdfNode::paragraph(vec![AdfNode::text("Inner.")])],
7351 )],
7352 };
7353 let md = adf_to_markdown(&doc).unwrap();
7354 assert!(md.contains(":::expand{title=\"Details\"}"));
7355 assert!(md.contains("Inner."));
7356 }
7357
7358 #[test]
7359 fn round_trip_expand() {
7360 let md = ":::expand{title=\"Details\"}\nInner content.\n:::\n";
7361 let doc = markdown_to_adf(md).unwrap();
7362 let result = adf_to_markdown(&doc).unwrap();
7363 assert!(result.contains(":::expand{title=\"Details\"}"));
7364 assert!(result.contains("Inner content."));
7365 }
7366
7367 #[test]
7368 fn layout_two_columns() {
7369 let md =
7370 "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
7371 let doc = markdown_to_adf(md).unwrap();
7372 assert_eq!(doc.content[0].node_type, "layoutSection");
7373 let columns = doc.content[0].content.as_ref().unwrap();
7374 assert_eq!(columns.len(), 2);
7375 assert_eq!(columns[0].node_type, "layoutColumn");
7376 assert_eq!(columns[1].node_type, "layoutColumn");
7377 }
7378
7379 #[test]
7380 fn adf_layout_to_markdown() {
7381 let doc = AdfDocument {
7382 version: 1,
7383 doc_type: "doc".to_string(),
7384 content: vec![AdfNode::layout_section(vec![
7385 AdfNode::layout_column(
7386 50.0,
7387 vec![AdfNode::paragraph(vec![AdfNode::text("Left.")])],
7388 ),
7389 AdfNode::layout_column(
7390 50.0,
7391 vec![AdfNode::paragraph(vec![AdfNode::text("Right.")])],
7392 ),
7393 ])],
7394 };
7395 let md = adf_to_markdown(&doc).unwrap();
7396 assert!(md.contains("::::layout"));
7397 assert!(md.contains(":::column{width=50}"));
7398 assert!(md.contains("Left."));
7399 assert!(md.contains("Right."));
7400 }
7401
7402 #[test]
7403 fn layout_column_localid_roundtrip() {
7404 let adf_json = r#"{
7405 "version": 1,
7406 "type": "doc",
7407 "content": [{
7408 "type": "layoutSection",
7409 "content": [
7410 {
7411 "type": "layoutColumn",
7412 "attrs": {"width": 50.0, "localId": "aabb112233cc"},
7413 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Left"}]}]
7414 },
7415 {
7416 "type": "layoutColumn",
7417 "attrs": {"width": 50.0, "localId": "ddeeff445566"},
7418 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Right"}]}]
7419 }
7420 ]
7421 }]
7422 }"#;
7423 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7424 let md = adf_to_markdown(&doc).unwrap();
7425 assert!(
7426 md.contains("localId=aabb112233cc"),
7427 "first column localId should appear in markdown: {md}"
7428 );
7429 assert!(
7430 md.contains("localId=ddeeff445566"),
7431 "second column localId should appear in markdown: {md}"
7432 );
7433 let rt = markdown_to_adf(&md).unwrap();
7434 let cols = rt.content[0].content.as_ref().unwrap();
7435 assert_eq!(
7436 cols[0].attrs.as_ref().unwrap()["localId"],
7437 "aabb112233cc",
7438 "first column localId should round-trip"
7439 );
7440 assert_eq!(
7441 cols[1].attrs.as_ref().unwrap()["localId"],
7442 "ddeeff445566",
7443 "second column localId should round-trip"
7444 );
7445 }
7446
7447 #[test]
7448 fn layout_column_without_localid() {
7449 let md =
7450 "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
7451 let doc = markdown_to_adf(md).unwrap();
7452 let cols = doc.content[0].content.as_ref().unwrap();
7453 assert!(
7454 cols[0].attrs.as_ref().unwrap().get("localId").is_none(),
7455 "column without localId should not gain one"
7456 );
7457 let md2 = adf_to_markdown(&doc).unwrap();
7458 assert!(
7459 !md2.contains("localId"),
7460 "no localId should appear in output: {md2}"
7461 );
7462 }
7463
7464 #[test]
7465 fn layout_column_localid_stripped_when_option_set() {
7466 let adf_json = r#"{
7467 "version": 1,
7468 "type": "doc",
7469 "content": [{
7470 "type": "layoutSection",
7471 "content": [{
7472 "type": "layoutColumn",
7473 "attrs": {"width": 50.0, "localId": "aabb112233cc"},
7474 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Col"}]}]
7475 }]
7476 }]
7477 }"#;
7478 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7479 let opts = RenderOptions {
7480 strip_local_ids: true,
7481 ..Default::default()
7482 };
7483 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
7484 assert!(!md.contains("localId"), "localId should be stripped: {md}");
7485 }
7486
7487 #[test]
7488 fn layout_column_localid_flush_previous() {
7489 let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nLeft.\n:::column{width=50 localId=ddeeff445566}\nRight.\n:::\n::::";
7491 let doc = markdown_to_adf(md).unwrap();
7492 let cols = doc.content[0].content.as_ref().unwrap();
7493 assert_eq!(
7494 cols[0].attrs.as_ref().unwrap()["localId"],
7495 "aabb112233cc",
7496 "flush-previous column should preserve localId"
7497 );
7498 assert_eq!(
7499 cols[1].attrs.as_ref().unwrap()["localId"],
7500 "ddeeff445566",
7501 "second column localId should be preserved"
7502 );
7503 }
7504
7505 #[test]
7506 fn layout_column_localid_flush_last() {
7507 let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nOnly column.";
7509 let doc = markdown_to_adf(md).unwrap();
7510 let cols = doc.content[0].content.as_ref().unwrap();
7511 assert_eq!(
7512 cols[0].attrs.as_ref().unwrap()["localId"],
7513 "aabb112233cc",
7514 "flush-last column should preserve localId"
7515 );
7516 }
7517
7518 #[test]
7519 fn decisions_list() {
7520 let md = ":::decisions\n- <> Use PostgreSQL\n- <> REST API\n:::";
7521 let doc = markdown_to_adf(md).unwrap();
7522 assert_eq!(doc.content[0].node_type, "decisionList");
7523 let items = doc.content[0].content.as_ref().unwrap();
7524 assert_eq!(items.len(), 2);
7525 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DECIDED");
7526 }
7527
7528 #[test]
7529 fn adf_decisions_to_markdown() {
7530 let doc = AdfDocument {
7531 version: 1,
7532 doc_type: "doc".to_string(),
7533 content: vec![AdfNode::decision_list(vec![AdfNode::decision_item(
7534 "DECIDED",
7535 vec![AdfNode::paragraph(vec![AdfNode::text("Use PostgreSQL")])],
7536 )])],
7537 };
7538 let md = adf_to_markdown(&doc).unwrap();
7539 assert!(md.contains(":::decisions"));
7540 assert!(md.contains("- <> Use PostgreSQL"));
7541 }
7542
7543 #[test]
7544 fn bodied_extension_container() {
7545 let md = ":::extension{type=com.forge key=my-macro}\nContent.\n:::";
7546 let doc = markdown_to_adf(md).unwrap();
7547 assert_eq!(doc.content[0].node_type, "bodiedExtension");
7548 assert_eq!(
7549 doc.content[0].attrs.as_ref().unwrap()["extensionType"],
7550 "com.forge"
7551 );
7552 }
7553
7554 #[test]
7555 fn adf_bodied_extension_to_markdown() {
7556 let doc = AdfDocument {
7557 version: 1,
7558 doc_type: "doc".to_string(),
7559 content: vec![AdfNode::bodied_extension(
7560 "com.forge",
7561 "my-macro",
7562 vec![AdfNode::paragraph(vec![AdfNode::text("Content.")])],
7563 )],
7564 };
7565 let md = adf_to_markdown(&doc).unwrap();
7566 assert!(md.contains(":::extension{type=com.forge key=my-macro}"));
7567 assert!(md.contains("Content."));
7568 }
7569
7570 #[test]
7573 fn leaf_block_card() {
7574 let doc = markdown_to_adf("::card[https://example.com/browse/PROJ-123]").unwrap();
7575 assert_eq!(doc.content[0].node_type, "blockCard");
7576 assert_eq!(
7577 doc.content[0].attrs.as_ref().unwrap()["url"],
7578 "https://example.com/browse/PROJ-123"
7579 );
7580 }
7581
7582 #[test]
7583 fn adf_block_card_to_markdown() {
7584 let doc = AdfDocument {
7585 version: 1,
7586 doc_type: "doc".to_string(),
7587 content: vec![AdfNode::block_card("https://example.com/browse/PROJ-123")],
7588 };
7589 let md = adf_to_markdown(&doc).unwrap();
7590 assert!(md.contains("::card[https://example.com/browse/PROJ-123]"));
7591 }
7592
7593 #[test]
7594 fn round_trip_block_card() {
7595 let md = "::card[https://example.com/browse/PROJ-123]\n";
7596 let doc = markdown_to_adf(md).unwrap();
7597 let result = adf_to_markdown(&doc).unwrap();
7598 assert!(result.contains("::card[https://example.com/browse/PROJ-123]"));
7599 }
7600
7601 #[test]
7602 fn leaf_embed_card() {
7603 let doc =
7604 markdown_to_adf("::embed[https://figma.com/file/abc]{layout=wide width=80}").unwrap();
7605 assert_eq!(doc.content[0].node_type, "embedCard");
7606 let attrs = doc.content[0].attrs.as_ref().unwrap();
7607 assert_eq!(attrs["url"], "https://figma.com/file/abc");
7608 assert_eq!(attrs["layout"], "wide");
7609 assert_eq!(attrs["width"], 80.0);
7610 }
7611
7612 #[test]
7613 fn leaf_embed_card_with_original_height() {
7614 let doc = markdown_to_adf(
7615 "::embed[https://example.com]{layout=center originalHeight=732 width=100}",
7616 )
7617 .unwrap();
7618 assert_eq!(doc.content[0].node_type, "embedCard");
7619 let attrs = doc.content[0].attrs.as_ref().unwrap();
7620 assert_eq!(attrs["url"], "https://example.com");
7621 assert_eq!(attrs["layout"], "center");
7622 assert_eq!(attrs["originalHeight"], 732.0);
7623 assert_eq!(attrs["width"], 100.0);
7624 }
7625
7626 #[test]
7627 fn adf_embed_card_to_markdown() {
7628 let doc = AdfDocument {
7629 version: 1,
7630 doc_type: "doc".to_string(),
7631 content: vec![AdfNode::embed_card(
7632 "https://figma.com/file/abc",
7633 Some("wide"),
7634 None,
7635 Some(80.0),
7636 )],
7637 };
7638 let md = adf_to_markdown(&doc).unwrap();
7639 assert!(md.contains("::embed[https://figma.com/file/abc]{layout=wide width=80}"));
7640 }
7641
7642 #[test]
7643 fn adf_embed_card_original_height_to_markdown() {
7644 let doc = AdfDocument {
7645 version: 1,
7646 doc_type: "doc".to_string(),
7647 content: vec![AdfNode::embed_card(
7648 "https://example.com",
7649 Some("center"),
7650 Some(732.0),
7651 Some(100.0),
7652 )],
7653 };
7654 let md = adf_to_markdown(&doc).unwrap();
7655 assert!(
7656 md.contains("::embed[https://example.com]{layout=center originalHeight=732 width=100}"),
7657 "expected originalHeight and width in md: {md}"
7658 );
7659 }
7660
7661 #[test]
7662 fn embed_card_roundtrip_with_all_attrs() {
7663 let adf_json = r#"{"version":1,"type":"doc","content":[{
7664 "type":"embedCard",
7665 "attrs":{"layout":"center","originalHeight":732.0,"url":"https://example.com","width":100.0}
7666 }]}"#;
7667 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7668 let md = adf_to_markdown(&doc).unwrap();
7669 assert!(
7670 md.contains("originalHeight=732"),
7671 "originalHeight missing from md: {md}"
7672 );
7673 assert!(md.contains("width=100"), "width missing from md: {md}");
7674 let rt = markdown_to_adf(&md).unwrap();
7675 let attrs = rt.content[0].attrs.as_ref().unwrap();
7676 assert_eq!(attrs["originalHeight"], 732.0);
7677 assert_eq!(attrs["width"], 100.0);
7678 assert_eq!(attrs["layout"], "center");
7679 assert_eq!(attrs["url"], "https://example.com");
7680 }
7681
7682 #[test]
7683 fn embed_card_fractional_dimensions() {
7684 let doc = AdfDocument {
7685 version: 1,
7686 doc_type: "doc".to_string(),
7687 content: vec![AdfNode::embed_card(
7688 "https://example.com",
7689 Some("center"),
7690 Some(732.5),
7691 Some(99.9),
7692 )],
7693 };
7694 let md = adf_to_markdown(&doc).unwrap();
7695 assert!(
7696 md.contains("originalHeight=732.5"),
7697 "fractional originalHeight missing: {md}"
7698 );
7699 assert!(md.contains("width=99.9"), "fractional width missing: {md}");
7700 let rt = markdown_to_adf(&md).unwrap();
7701 let attrs = rt.content[0].attrs.as_ref().unwrap();
7702 assert_eq!(attrs["originalHeight"], 732.5);
7703 assert_eq!(attrs["width"], 99.9);
7704 }
7705
7706 #[test]
7707 fn embed_card_integer_width_in_json() {
7708 let adf_json = r#"{"version":1,"type":"doc","content":[{
7710 "type":"embedCard",
7711 "attrs":{"url":"https://example.com","width":100}
7712 }]}"#;
7713 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7714 let md = adf_to_markdown(&doc).unwrap();
7715 assert!(
7716 md.contains("width=100"),
7717 "integer width missing from md: {md}"
7718 );
7719 let rt = markdown_to_adf(&md).unwrap();
7720 assert_eq!(rt.content[0].attrs.as_ref().unwrap()["width"], 100.0);
7721 }
7722
7723 #[test]
7724 fn embed_card_only_original_height() {
7725 let adf_json = r#"{"version":1,"type":"doc","content":[{
7727 "type":"embedCard",
7728 "attrs":{"url":"https://example.com","originalHeight":500.0}
7729 }]}"#;
7730 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7731 let md = adf_to_markdown(&doc).unwrap();
7732 assert!(
7733 md.contains("originalHeight=500"),
7734 "originalHeight missing: {md}"
7735 );
7736 assert!(!md.contains("width="), "width should not appear: {md}");
7737 let rt = markdown_to_adf(&md).unwrap();
7738 let attrs = rt.content[0].attrs.as_ref().unwrap();
7739 assert_eq!(attrs["originalHeight"], 500.0);
7740 assert!(attrs.get("width").is_none());
7741 }
7742
7743 #[test]
7744 fn leaf_void_extension() {
7745 let doc = markdown_to_adf("::extension{type=com.atlassian.macro key=jira-chart}").unwrap();
7746 assert_eq!(doc.content[0].node_type, "extension");
7747 assert_eq!(
7748 doc.content[0].attrs.as_ref().unwrap()["extensionType"],
7749 "com.atlassian.macro"
7750 );
7751 assert_eq!(
7752 doc.content[0].attrs.as_ref().unwrap()["extensionKey"],
7753 "jira-chart"
7754 );
7755 }
7756
7757 #[test]
7758 fn adf_void_extension_to_markdown() {
7759 let doc = AdfDocument {
7760 version: 1,
7761 doc_type: "doc".to_string(),
7762 content: vec![AdfNode::extension(
7763 "com.atlassian.macro",
7764 "jira-chart",
7765 None,
7766 )],
7767 };
7768 let md = adf_to_markdown(&doc).unwrap();
7769 assert!(md.contains("::extension{type=com.atlassian.macro key=jira-chart}"));
7770 }
7771
7772 #[test]
7775 fn bare_url_autolink() {
7776 let doc = markdown_to_adf("Visit https://example.com today").unwrap();
7777 let content = doc.content[0].content.as_ref().unwrap();
7778 assert_eq!(content[0].text.as_deref(), Some("Visit "));
7779 assert_eq!(content[1].node_type, "inlineCard");
7780 assert_eq!(
7781 content[1].attrs.as_ref().unwrap()["url"],
7782 "https://example.com"
7783 );
7784 assert_eq!(content[2].text.as_deref(), Some(" today"));
7785 }
7786
7787 #[test]
7788 fn bare_url_strips_trailing_punctuation() {
7789 let doc = markdown_to_adf("See https://example.com.").unwrap();
7790 let content = doc.content[0].content.as_ref().unwrap();
7791 assert_eq!(
7792 content[1].attrs.as_ref().unwrap()["url"],
7793 "https://example.com"
7794 );
7795 }
7796
7797 #[test]
7798 fn bare_url_round_trip() {
7799 let doc = markdown_to_adf("Visit https://example.com/path today").unwrap();
7800 let md = adf_to_markdown(&doc).unwrap();
7801 assert!(md.contains(":card[https://example.com/path]"));
7802 }
7803
7804 #[test]
7807 fn plain_text_url_round_trips_as_text() {
7808 let adf_json = r#"{
7811 "version": 1,
7812 "type": "doc",
7813 "content": [{
7814 "type": "paragraph",
7815 "content": [
7816 {"type": "text", "text": "https://example.com/some/path/to/resource"}
7817 ]
7818 }]
7819 }"#;
7820 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7821 let jfm = adf_to_markdown(&adf).unwrap();
7822 let roundtripped = markdown_to_adf(&jfm).unwrap();
7823 let content = roundtripped.content[0].content.as_ref().unwrap();
7824 assert_eq!(content.len(), 1, "should be a single node");
7825 assert_eq!(content[0].node_type, "text");
7826 assert_eq!(
7827 content[0].text.as_deref(),
7828 Some("https://example.com/some/path/to/resource")
7829 );
7830 }
7831
7832 #[test]
7833 fn url_text_with_link_mark_round_trips_as_text_node() {
7834 let adf_json = r#"{
7838 "version": 1,
7839 "type": "doc",
7840 "content": [{
7841 "type": "paragraph",
7842 "content": [{
7843 "type": "text",
7844 "text": "https://octopz.example.com",
7845 "marks": [{"type": "link", "attrs": {"href": "https://octopz.example.com/"}}]
7846 }]
7847 }]
7848 }"#;
7849 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7850 let jfm = adf_to_markdown(&adf).unwrap();
7851 let roundtripped = markdown_to_adf(&jfm).unwrap();
7852 let content = roundtripped.content[0].content.as_ref().unwrap();
7853 assert_eq!(content.len(), 1, "should be a single node");
7854 assert_eq!(content[0].node_type, "text", "must be text, not inlineCard");
7855 assert_eq!(
7856 content[0].text.as_deref(),
7857 Some("https://octopz.example.com")
7858 );
7859 let mark = &content[0].marks.as_ref().unwrap()[0];
7860 assert_eq!(mark.mark_type, "link");
7861 assert_eq!(
7862 mark.attrs.as_ref().unwrap()["href"],
7863 "https://octopz.example.com/"
7864 );
7865 }
7866
7867 #[test]
7868 fn url_text_with_exact_link_mark_round_trips() {
7869 let adf_json = r#"{
7871 "version": 1,
7872 "type": "doc",
7873 "content": [{
7874 "type": "paragraph",
7875 "content": [{
7876 "type": "text",
7877 "text": "https://example.com/path",
7878 "marks": [{"type": "link", "attrs": {"href": "https://example.com/path"}}]
7879 }]
7880 }]
7881 }"#;
7882 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7883 let jfm = adf_to_markdown(&adf).unwrap();
7884 let roundtripped = markdown_to_adf(&jfm).unwrap();
7885 let content = roundtripped.content[0].content.as_ref().unwrap();
7886 assert_eq!(content.len(), 1, "should be a single node");
7887 assert_eq!(content[0].node_type, "text");
7888 assert_eq!(content[0].text.as_deref(), Some("https://example.com/path"));
7889 let mark = &content[0].marks.as_ref().unwrap()[0];
7890 assert_eq!(mark.mark_type, "link");
7891 }
7892
7893 #[test]
7894 fn plain_text_url_amid_text_round_trips() {
7895 let adf_json = r#"{
7897 "version": 1,
7898 "type": "doc",
7899 "content": [{
7900 "type": "paragraph",
7901 "content": [
7902 {"type": "text", "text": "see https://example.com for info"}
7903 ]
7904 }]
7905 }"#;
7906 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7907 let jfm = adf_to_markdown(&adf).unwrap();
7908 let roundtripped = markdown_to_adf(&jfm).unwrap();
7909 let content = roundtripped.content[0].content.as_ref().unwrap();
7910 assert_eq!(content.len(), 1);
7911 assert_eq!(content[0].node_type, "text");
7912 assert_eq!(
7913 content[0].text.as_deref(),
7914 Some("see https://example.com for info")
7915 );
7916 }
7917
7918 #[test]
7919 fn plain_text_multiple_urls_round_trips() {
7920 let adf_json = r#"{
7921 "version": 1,
7922 "type": "doc",
7923 "content": [{
7924 "type": "paragraph",
7925 "content": [
7926 {"type": "text", "text": "http://a.com and https://b.com"}
7927 ]
7928 }]
7929 }"#;
7930 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7931 let jfm = adf_to_markdown(&adf).unwrap();
7932 let roundtripped = markdown_to_adf(&jfm).unwrap();
7933 let content = roundtripped.content[0].content.as_ref().unwrap();
7934 assert_eq!(content.len(), 1);
7935 assert_eq!(content[0].node_type, "text");
7936 assert_eq!(
7937 content[0].text.as_deref(),
7938 Some("http://a.com and https://b.com")
7939 );
7940 }
7941
7942 #[test]
7943 fn plain_text_http_prefix_no_url_unchanged() {
7944 let adf_json = r#"{
7946 "version": 1,
7947 "type": "doc",
7948 "content": [{
7949 "type": "paragraph",
7950 "content": [
7951 {"type": "text", "text": "the http header is important"}
7952 ]
7953 }]
7954 }"#;
7955 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7956 let jfm = adf_to_markdown(&adf).unwrap();
7957 let roundtripped = markdown_to_adf(&jfm).unwrap();
7958 let content = roundtripped.content[0].content.as_ref().unwrap();
7959 assert_eq!(
7960 content[0].text.as_deref(),
7961 Some("the http header is important")
7962 );
7963 }
7964
7965 #[test]
7966 fn linked_url_text_not_double_escaped() {
7967 let adf_json = r#"{
7970 "version": 1,
7971 "type": "doc",
7972 "content": [{
7973 "type": "paragraph",
7974 "content": [{
7975 "type": "text",
7976 "text": "https://example.com",
7977 "marks": [{"type": "link", "attrs": {"href": "https://example.com"}}]
7978 }]
7979 }]
7980 }"#;
7981 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7982 let jfm = adf_to_markdown(&adf).unwrap();
7983 assert!(!jfm.contains(r"\https"));
7985 let roundtripped = markdown_to_adf(&jfm).unwrap();
7987 let content = roundtripped.content[0].content.as_ref().unwrap();
7988 let has_link = content.iter().any(|n| {
7989 n.marks
7990 .as_ref()
7991 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
7992 });
7993 assert!(has_link, "link mark should be preserved");
7994 }
7995
7996 #[test]
7999 fn escape_link_brackets_unit() {
8000 assert_eq!(escape_link_brackets("hello"), "hello");
8001 assert_eq!(escape_link_brackets("["), "\\[");
8002 assert_eq!(escape_link_brackets("]"), "\\]");
8003 assert_eq!(escape_link_brackets("[PROJ-456]"), "\\[PROJ-456\\]");
8004 assert_eq!(escape_link_brackets("a[b]c"), "a\\[b\\]c");
8005 }
8006
8007 #[test]
8008 fn bracket_text_with_link_mark_escapes_brackets() {
8009 let doc = AdfDocument {
8012 version: 1,
8013 doc_type: "doc".to_string(),
8014 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8015 "[",
8016 vec![AdfMark::link("https://example.com")],
8017 )])],
8018 };
8019 let md = adf_to_markdown(&doc).unwrap();
8020 assert_eq!(md.trim(), "[\\[](https://example.com)");
8021 }
8022
8023 #[test]
8024 fn bracket_text_with_link_mark_round_trips() {
8025 let adf_json = r#"{
8028 "type": "doc",
8029 "version": 1,
8030 "content": [{
8031 "type": "paragraph",
8032 "content": [
8033 {
8034 "type": "text",
8035 "text": "[",
8036 "marks": [{"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}]
8037 },
8038 {
8039 "type": "text",
8040 "text": "PROJ-456] Fix the auth bug",
8041 "marks": [
8042 {"type": "underline"},
8043 {"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}
8044 ]
8045 }
8046 ]
8047 }]
8048 }"#;
8049 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
8050 let jfm = adf_to_markdown(&adf).unwrap();
8051
8052 assert!(jfm.contains("\\["), "opening bracket should be escaped");
8054
8055 let rt = markdown_to_adf(&jfm).unwrap();
8057 let content = rt.content[0].content.as_ref().unwrap();
8058
8059 let link_nodes: Vec<_> = content
8061 .iter()
8062 .filter(|n| {
8063 n.marks
8064 .as_ref()
8065 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
8066 })
8067 .collect();
8068 assert!(
8069 !link_nodes.is_empty(),
8070 "link mark must be preserved on round-trip"
8071 );
8072
8073 let all_text: String = content.iter().filter_map(|n| n.text.as_deref()).collect();
8075 assert!(
8076 all_text.contains('['),
8077 "literal '[' must survive round-trip"
8078 );
8079 assert!(
8080 all_text.contains("PROJ-456]"),
8081 "continuation text must survive round-trip"
8082 );
8083 }
8084
8085 #[test]
8086 fn closing_bracket_in_link_text_round_trips() {
8087 let doc = AdfDocument {
8090 version: 1,
8091 doc_type: "doc".to_string(),
8092 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8093 "item]",
8094 vec![AdfMark::link("https://example.com")],
8095 )])],
8096 };
8097 let md = adf_to_markdown(&doc).unwrap();
8098 assert_eq!(md.trim(), "[item\\]](https://example.com)");
8099
8100 let rt = markdown_to_adf(&md).unwrap();
8101 let content = rt.content[0].content.as_ref().unwrap();
8102 assert_eq!(content[0].text.as_deref(), Some("item]"));
8103 assert!(content[0]
8104 .marks
8105 .as_ref()
8106 .unwrap()
8107 .iter()
8108 .any(|m| m.mark_type == "link"));
8109 }
8110
8111 #[test]
8112 fn brackets_in_link_text_round_trip() {
8113 let doc = AdfDocument {
8115 version: 1,
8116 doc_type: "doc".to_string(),
8117 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8118 "[PROJ-123]",
8119 vec![AdfMark::link("https://example.com")],
8120 )])],
8121 };
8122 let md = adf_to_markdown(&doc).unwrap();
8123 assert_eq!(md.trim(), "[\\[PROJ-123\\]](https://example.com)");
8124
8125 let rt = markdown_to_adf(&md).unwrap();
8126 let content = rt.content[0].content.as_ref().unwrap();
8127 assert_eq!(content[0].text.as_deref(), Some("[PROJ-123]"));
8128 assert!(content[0]
8129 .marks
8130 .as_ref()
8131 .unwrap()
8132 .iter()
8133 .any(|m| m.mark_type == "link"));
8134 }
8135
8136 #[test]
8137 fn plain_text_brackets_not_escaped() {
8138 let doc = AdfDocument {
8140 version: 1,
8141 doc_type: "doc".to_string(),
8142 content: vec![AdfNode::paragraph(vec![AdfNode::text(
8143 "see [PROJ-123] for details",
8144 )])],
8145 };
8146 let md = adf_to_markdown(&doc).unwrap();
8147 assert_eq!(md.trim(), "see [PROJ-123] for details");
8148 }
8149
8150 #[test]
8151 fn link_with_no_brackets_unchanged() {
8152 let doc = AdfDocument {
8154 version: 1,
8155 doc_type: "doc".to_string(),
8156 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8157 "click here",
8158 vec![AdfMark::link("https://example.com")],
8159 )])],
8160 };
8161 let md = adf_to_markdown(&doc).unwrap();
8162 assert_eq!(md.trim(), "[click here](https://example.com)");
8163 }
8164
8165 #[test]
8166 fn inline_card_still_round_trips() {
8167 let adf_json = r#"{
8170 "version": 1,
8171 "type": "doc",
8172 "content": [{
8173 "type": "paragraph",
8174 "content": [
8175 {"type": "inlineCard", "attrs": {"url": "https://example.com/page"}}
8176 ]
8177 }]
8178 }"#;
8179 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
8180 let jfm = adf_to_markdown(&adf).unwrap();
8181 assert!(jfm.contains(":card[https://example.com/page]"));
8182 let roundtripped = markdown_to_adf(&jfm).unwrap();
8183 let content = roundtripped.content[0].content.as_ref().unwrap();
8184 assert_eq!(content[0].node_type, "inlineCard");
8185 assert_eq!(
8186 content[0].attrs.as_ref().unwrap()["url"],
8187 "https://example.com/page"
8188 );
8189 }
8190
8191 #[test]
8194 fn paragraph_align_center() {
8195 let md = "Centered text.\n{align=center}";
8196 let doc = markdown_to_adf(md).unwrap();
8197 let marks = doc.content[0].marks.as_ref().unwrap();
8198 assert_eq!(marks[0].mark_type, "alignment");
8199 assert_eq!(marks[0].attrs.as_ref().unwrap()["align"], "center");
8200 }
8201
8202 #[test]
8203 fn adf_alignment_to_markdown() {
8204 let mut node = AdfNode::paragraph(vec![AdfNode::text("Centered.")]);
8205 node.marks = Some(vec![AdfMark::alignment("center")]);
8206 let doc = AdfDocument {
8207 version: 1,
8208 doc_type: "doc".to_string(),
8209 content: vec![node],
8210 };
8211 let md = adf_to_markdown(&doc).unwrap();
8212 assert!(md.contains("Centered."));
8213 assert!(md.contains("{align=center}"));
8214 }
8215
8216 #[test]
8217 fn round_trip_alignment() {
8218 let md = "Centered.\n{align=center}\n";
8219 let doc = markdown_to_adf(md).unwrap();
8220 let result = adf_to_markdown(&doc).unwrap();
8221 assert!(result.contains("{align=center}"));
8222 }
8223
8224 #[test]
8225 fn paragraph_indent() {
8226 let md = "Indented.\n{indent=2}";
8227 let doc = markdown_to_adf(md).unwrap();
8228 let marks = doc.content[0].marks.as_ref().unwrap();
8229 assert_eq!(marks[0].mark_type, "indentation");
8230 assert_eq!(marks[0].attrs.as_ref().unwrap()["level"], 2);
8231 }
8232
8233 #[test]
8234 fn code_block_breakout() {
8235 let md = "```python\ndef f(): pass\n```\n{breakout=wide}";
8236 let doc = markdown_to_adf(md).unwrap();
8237 let marks = doc.content[0].marks.as_ref().unwrap();
8238 assert_eq!(marks[0].mark_type, "breakout");
8239 assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
8240 assert!(marks[0].attrs.as_ref().unwrap().get("width").is_none());
8241 }
8242
8243 #[test]
8244 fn code_block_breakout_with_width() {
8245 let md = "```python\ndef f(): pass\n```\n{breakout=wide breakoutWidth=1200}";
8246 let doc = markdown_to_adf(md).unwrap();
8247 let marks = doc.content[0].marks.as_ref().unwrap();
8248 assert_eq!(marks[0].mark_type, "breakout");
8249 assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
8250 assert_eq!(marks[0].attrs.as_ref().unwrap()["width"], 1200);
8251 }
8252
8253 #[test]
8254 fn adf_breakout_to_markdown() {
8255 let mut node = AdfNode::code_block(Some("python"), "pass");
8256 node.marks = Some(vec![AdfMark::breakout("wide", None)]);
8257 let doc = AdfDocument {
8258 version: 1,
8259 doc_type: "doc".to_string(),
8260 content: vec![node],
8261 };
8262 let md = adf_to_markdown(&doc).unwrap();
8263 assert!(md.contains("{breakout=wide}"));
8264 assert!(!md.contains("breakoutWidth"));
8265 }
8266
8267 #[test]
8268 fn adf_breakout_with_width_to_markdown() {
8269 let mut node = AdfNode::code_block(Some("python"), "pass");
8270 node.marks = Some(vec![AdfMark::breakout("wide", Some(1200))]);
8271 let doc = AdfDocument {
8272 version: 1,
8273 doc_type: "doc".to_string(),
8274 content: vec![node],
8275 };
8276 let md = adf_to_markdown(&doc).unwrap();
8277 assert!(md.contains("breakout=wide"));
8278 assert!(md.contains("breakoutWidth=1200"));
8279 }
8280
8281 #[test]
8282 fn breakout_width_round_trip() {
8283 let adf_json = r#"{"version":1,"type":"doc","content":[{
8284 "type":"codeBlock",
8285 "attrs":{"language":"text"},
8286 "marks":[{"type":"breakout","attrs":{"mode":"wide","width":1200}}],
8287 "content":[{"type":"text","text":"some code"}]
8288 }]}"#;
8289 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8290 let md = adf_to_markdown(&doc).unwrap();
8291 assert!(md.contains("breakout=wide"));
8292 assert!(md.contains("breakoutWidth=1200"));
8293 let round_tripped = markdown_to_adf(&md).unwrap();
8294 let marks = round_tripped.content[0].marks.as_ref().unwrap();
8295 let breakout = marks.iter().find(|m| m.mark_type == "breakout").unwrap();
8296 assert_eq!(breakout.attrs.as_ref().unwrap()["mode"], "wide");
8297 assert_eq!(breakout.attrs.as_ref().unwrap()["width"], 1200);
8298 }
8299
8300 #[test]
8303 fn image_with_layout_attrs() {
8304 let doc = markdown_to_adf("{layout=wide width=80}").unwrap();
8305 let node = &doc.content[0];
8306 assert_eq!(node.node_type, "mediaSingle");
8307 let attrs = node.attrs.as_ref().unwrap();
8308 assert_eq!(attrs["layout"], "wide");
8309 assert_eq!(attrs["width"], 80);
8310 }
8311
8312 #[test]
8313 fn adf_image_with_layout_to_markdown() {
8314 let mut node = AdfNode::media_single("url", Some("alt"));
8315 node.attrs.as_mut().unwrap()["layout"] = serde_json::json!("wide");
8316 node.attrs.as_mut().unwrap()["width"] = serde_json::json!(80);
8317 let doc = AdfDocument {
8318 version: 1,
8319 doc_type: "doc".to_string(),
8320 content: vec![node],
8321 };
8322 let md = adf_to_markdown(&doc).unwrap();
8323 assert!(md.contains("{layout=wide width=80}"));
8324 }
8325
8326 #[test]
8327 fn table_with_layout_attrs() {
8328 let md = "| H |\n| --- |\n| C |\n{layout=wide numbered}";
8329 let doc = markdown_to_adf(md).unwrap();
8330 let table = &doc.content[0];
8331 assert_eq!(table.node_type, "table");
8332 let attrs = table.attrs.as_ref().unwrap();
8333 assert_eq!(attrs["layout"], "wide");
8334 assert_eq!(attrs["isNumberColumnEnabled"], true);
8335 }
8336
8337 #[test]
8338 fn adf_table_with_attrs_to_markdown() {
8339 let mut table = AdfNode::table(vec![
8340 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
8341 AdfNode::text("H"),
8342 ])])]),
8343 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
8344 AdfNode::text("C"),
8345 ])])]),
8346 ]);
8347 table.attrs = Some(serde_json::json!({"layout": "wide", "isNumberColumnEnabled": true}));
8348 let doc = AdfDocument {
8349 version: 1,
8350 doc_type: "doc".to_string(),
8351 content: vec![table],
8352 };
8353 let md = adf_to_markdown(&doc).unwrap();
8354 assert!(md.contains("{layout=wide numbered}"));
8355 }
8356
8357 #[test]
8360 fn underline_bracketed_span() {
8361 let doc = markdown_to_adf("This is [underlined text]{underline} here.").unwrap();
8362 let content = doc.content[0].content.as_ref().unwrap();
8363 assert_eq!(content[1].text.as_deref(), Some("underlined text"));
8364 let marks = content[1].marks.as_ref().unwrap();
8365 assert_eq!(marks[0].mark_type, "underline");
8366 }
8367
8368 #[test]
8369 fn adf_underline_to_markdown() {
8370 let doc = AdfDocument {
8371 version: 1,
8372 doc_type: "doc".to_string(),
8373 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8374 "underlined",
8375 vec![AdfMark::underline()],
8376 )])],
8377 };
8378 let md = adf_to_markdown(&doc).unwrap();
8379 assert!(md.contains("[underlined]{underline}"));
8380 }
8381
8382 #[test]
8383 fn round_trip_underline() {
8384 let md = "This is [underlined text]{underline} here.\n";
8385 let doc = markdown_to_adf(md).unwrap();
8386 let result = adf_to_markdown(&doc).unwrap();
8387 assert!(result.contains("[underlined text]{underline}"));
8388 }
8389
8390 #[test]
8391 fn mark_ordering_underline_strong_preserved() {
8392 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8394 {"type":"text","text":"bold and underlined","marks":[{"type":"underline"},{"type":"strong"}]}
8395 ]}]}"#;
8396 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8397 let md = adf_to_markdown(&doc).unwrap();
8398 let round_tripped = markdown_to_adf(&md).unwrap();
8399 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8400 let mark_types: Vec<&str> = node
8401 .marks
8402 .as_ref()
8403 .unwrap()
8404 .iter()
8405 .map(|m| m.mark_type.as_str())
8406 .collect();
8407 assert_eq!(
8408 mark_types,
8409 vec!["underline", "strong"],
8410 "mark order should be preserved, got: {mark_types:?}"
8411 );
8412 }
8413
8414 #[test]
8415 fn mark_ordering_link_strong_preserved() {
8416 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8418 {"type":"text","text":"bold link","marks":[
8419 {"type":"link","attrs":{"href":"https://example.com"}},
8420 {"type":"strong"}
8421 ]}
8422 ]}]}"#;
8423 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8424 let md = adf_to_markdown(&doc).unwrap();
8425 let round_tripped = markdown_to_adf(&md).unwrap();
8426 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8427 let mark_types: Vec<&str> = node
8428 .marks
8429 .as_ref()
8430 .unwrap()
8431 .iter()
8432 .map(|m| m.mark_type.as_str())
8433 .collect();
8434 assert_eq!(
8435 mark_types,
8436 vec!["link", "strong"],
8437 "mark order should be preserved, got: {mark_types:?}"
8438 );
8439 }
8440
8441 #[test]
8442 fn mark_ordering_link_textcolor_preserved() {
8443 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8445 {"type":"text","text":"red link","marks":[
8446 {"type":"link","attrs":{"href":"https://example.com"}},
8447 {"type":"textColor","attrs":{"color":"#ff0000"}}
8448 ]}
8449 ]}]}"##;
8450 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8451 let md = adf_to_markdown(&doc).unwrap();
8452 let round_tripped = markdown_to_adf(&md).unwrap();
8453 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8454 let mark_types: Vec<&str> = node
8455 .marks
8456 .as_ref()
8457 .unwrap()
8458 .iter()
8459 .map(|m| m.mark_type.as_str())
8460 .collect();
8461 assert_eq!(
8462 mark_types,
8463 vec!["link", "textColor"],
8464 "mark order should be preserved, got: {mark_types:?}"
8465 );
8466 }
8467
8468 #[test]
8469 fn mark_ordering_link_em_preserved() {
8470 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8472 {"type":"text","text":"italic link","marks":[
8473 {"type":"link","attrs":{"href":"https://example.com"}},
8474 {"type":"em"}
8475 ]}
8476 ]}]}"#;
8477 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8478 let md = adf_to_markdown(&doc).unwrap();
8479 let round_tripped = markdown_to_adf(&md).unwrap();
8480 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8481 let mark_types: Vec<&str> = node
8482 .marks
8483 .as_ref()
8484 .unwrap()
8485 .iter()
8486 .map(|m| m.mark_type.as_str())
8487 .collect();
8488 assert_eq!(
8489 mark_types,
8490 vec!["link", "em"],
8491 "mark order should be preserved, got: {mark_types:?}"
8492 );
8493 }
8494
8495 #[test]
8496 fn mark_ordering_link_strike_preserved() {
8497 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8499 {"type":"text","text":"struck link","marks":[
8500 {"type":"link","attrs":{"href":"https://example.com"}},
8501 {"type":"strike"}
8502 ]}
8503 ]}]}"#;
8504 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8505 let md = adf_to_markdown(&doc).unwrap();
8506 let round_tripped = markdown_to_adf(&md).unwrap();
8507 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8508 let mark_types: Vec<&str> = node
8509 .marks
8510 .as_ref()
8511 .unwrap()
8512 .iter()
8513 .map(|m| m.mark_type.as_str())
8514 .collect();
8515 assert_eq!(
8516 mark_types,
8517 vec!["link", "strike"],
8518 "mark order should be preserved, got: {mark_types:?}"
8519 );
8520 }
8521
8522 #[test]
8523 fn mark_ordering_strong_link_preserved() {
8524 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8526 {"type":"text","text":"bold link","marks":[
8527 {"type":"strong"},
8528 {"type":"link","attrs":{"href":"https://example.com"}}
8529 ]}
8530 ]}]}"#;
8531 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8532 let md = adf_to_markdown(&doc).unwrap();
8533 let round_tripped = markdown_to_adf(&md).unwrap();
8534 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8535 let mark_types: Vec<&str> = node
8536 .marks
8537 .as_ref()
8538 .unwrap()
8539 .iter()
8540 .map(|m| m.mark_type.as_str())
8541 .collect();
8542 assert_eq!(
8543 mark_types,
8544 vec!["strong", "link"],
8545 "mark order should be preserved, got: {mark_types:?}"
8546 );
8547 }
8548
8549 #[test]
8550 fn mark_ordering_em_link_preserved() {
8551 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8553 {"type":"text","text":"italic link","marks":[
8554 {"type":"em"},
8555 {"type":"link","attrs":{"href":"https://example.com"}}
8556 ]}
8557 ]}]}"#;
8558 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8559 let md = adf_to_markdown(&doc).unwrap();
8560 let round_tripped = markdown_to_adf(&md).unwrap();
8561 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8562 let mark_types: Vec<&str> = node
8563 .marks
8564 .as_ref()
8565 .unwrap()
8566 .iter()
8567 .map(|m| m.mark_type.as_str())
8568 .collect();
8569 assert_eq!(
8570 mark_types,
8571 vec!["em", "link"],
8572 "mark order should be preserved, got: {mark_types:?}"
8573 );
8574 }
8575
8576 #[test]
8577 fn mark_ordering_strike_link_preserved() {
8578 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8580 {"type":"text","text":"struck link","marks":[
8581 {"type":"strike"},
8582 {"type":"link","attrs":{"href":"https://example.com"}}
8583 ]}
8584 ]}]}"#;
8585 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8586 let md = adf_to_markdown(&doc).unwrap();
8587 let round_tripped = markdown_to_adf(&md).unwrap();
8588 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8589 let mark_types: Vec<&str> = node
8590 .marks
8591 .as_ref()
8592 .unwrap()
8593 .iter()
8594 .map(|m| m.mark_type.as_str())
8595 .collect();
8596 assert_eq!(
8597 mark_types,
8598 vec!["strike", "link"],
8599 "mark order should be preserved, got: {mark_types:?}"
8600 );
8601 }
8602
8603 #[test]
8604 fn mark_ordering_underline_link_preserved() {
8605 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8607 {"type":"text","text":"click here","marks":[
8608 {"type":"underline"},
8609 {"type":"link","attrs":{"href":"https://example.com"}}
8610 ]}
8611 ]}]}"#;
8612 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8613 let md = adf_to_markdown(&doc).unwrap();
8614 let round_tripped = markdown_to_adf(&md).unwrap();
8615 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8616 let mark_types: Vec<&str> = node
8617 .marks
8618 .as_ref()
8619 .unwrap()
8620 .iter()
8621 .map(|m| m.mark_type.as_str())
8622 .collect();
8623 assert_eq!(
8624 mark_types,
8625 vec!["underline", "link"],
8626 "mark order should be preserved, got: {mark_types:?}"
8627 );
8628 }
8629
8630 #[test]
8631 fn mark_ordering_textcolor_link_preserved() {
8632 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8634 {"type":"text","text":"red link","marks":[
8635 {"type":"textColor","attrs":{"color":"#ff0000"}},
8636 {"type":"link","attrs":{"href":"https://example.com"}}
8637 ]}
8638 ]}]}"##;
8639 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8640 let md = adf_to_markdown(&doc).unwrap();
8641 let round_tripped = markdown_to_adf(&md).unwrap();
8642 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8643 let mark_types: Vec<&str> = node
8644 .marks
8645 .as_ref()
8646 .unwrap()
8647 .iter()
8648 .map(|m| m.mark_type.as_str())
8649 .collect();
8650 assert_eq!(
8651 mark_types,
8652 vec!["textColor", "link"],
8653 "mark order should be preserved, got: {mark_types:?}"
8654 );
8655 }
8656
8657 #[test]
8658 fn mark_ordering_link_underline_preserved() {
8659 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8661 {"type":"text","text":"click here","marks":[
8662 {"type":"link","attrs":{"href":"https://example.com"}},
8663 {"type":"underline"}
8664 ]}
8665 ]}]}"#;
8666 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8667 let md = adf_to_markdown(&doc).unwrap();
8668 assert!(
8670 md.contains("](https://example.com)"),
8671 "should have link: {md}"
8672 );
8673 assert!(md.contains("underline"), "should have underline: {md}");
8674 let round_tripped = markdown_to_adf(&md).unwrap();
8675 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8676 let mark_types: Vec<&str> = node
8677 .marks
8678 .as_ref()
8679 .unwrap()
8680 .iter()
8681 .map(|m| m.mark_type.as_str())
8682 .collect();
8683 assert_eq!(
8684 mark_types,
8685 vec!["link", "underline"],
8686 "mark order should be preserved, got: {mark_types:?}"
8687 );
8688 }
8689
8690 #[test]
8691 fn mark_ordering_underline_strong_link_preserved() {
8692 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8694 {"type":"text","text":"bold underlined link","marks":[
8695 {"type":"underline"},
8696 {"type":"strong"},
8697 {"type":"link","attrs":{"href":"https://example.com/page"}}
8698 ]}
8699 ]}]}"#;
8700 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8701 let md = adf_to_markdown(&doc).unwrap();
8702 let round_tripped = markdown_to_adf(&md).unwrap();
8703 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8704 let mark_types: Vec<&str> = node
8705 .marks
8706 .as_ref()
8707 .unwrap()
8708 .iter()
8709 .map(|m| m.mark_type.as_str())
8710 .collect();
8711 assert_eq!(
8712 mark_types,
8713 vec!["underline", "strong", "link"],
8714 "mark order should be preserved, got: {mark_types:?}"
8715 );
8716 }
8717
8718 #[test]
8719 fn mark_ordering_strong_underline_link_preserved() {
8720 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8722 {"type":"text","text":"bold underlined link","marks":[
8723 {"type":"strong"},
8724 {"type":"underline"},
8725 {"type":"link","attrs":{"href":"https://example.com/page"}}
8726 ]}
8727 ]}]}"#;
8728 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8729 let md = adf_to_markdown(&doc).unwrap();
8730 let round_tripped = markdown_to_adf(&md).unwrap();
8731 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8732 let mark_types: Vec<&str> = node
8733 .marks
8734 .as_ref()
8735 .unwrap()
8736 .iter()
8737 .map(|m| m.mark_type.as_str())
8738 .collect();
8739 assert_eq!(
8740 mark_types,
8741 vec!["strong", "underline", "link"],
8742 "mark order should be preserved, got: {mark_types:?}"
8743 );
8744 }
8745
8746 #[test]
8747 fn mark_ordering_underline_em_link_preserved() {
8748 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8750 {"type":"text","text":"italic underlined link","marks":[
8751 {"type":"underline"},
8752 {"type":"em"},
8753 {"type":"link","attrs":{"href":"https://example.com/page"}}
8754 ]}
8755 ]}]}"#;
8756 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8757 let md = adf_to_markdown(&doc).unwrap();
8758 let round_tripped = markdown_to_adf(&md).unwrap();
8759 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8760 let mark_types: Vec<&str> = node
8761 .marks
8762 .as_ref()
8763 .unwrap()
8764 .iter()
8765 .map(|m| m.mark_type.as_str())
8766 .collect();
8767 assert_eq!(
8768 mark_types,
8769 vec!["underline", "em", "link"],
8770 "mark order should be preserved, got: {mark_types:?}"
8771 );
8772 }
8773
8774 #[test]
8775 fn mark_ordering_underline_strike_link_preserved() {
8776 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8778 {"type":"text","text":"struck underlined link","marks":[
8779 {"type":"underline"},
8780 {"type":"strike"},
8781 {"type":"link","attrs":{"href":"https://example.com/page"}}
8782 ]}
8783 ]}]}"#;
8784 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8785 let md = adf_to_markdown(&doc).unwrap();
8786 let round_tripped = markdown_to_adf(&md).unwrap();
8787 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8788 let mark_types: Vec<&str> = node
8789 .marks
8790 .as_ref()
8791 .unwrap()
8792 .iter()
8793 .map(|m| m.mark_type.as_str())
8794 .collect();
8795 assert_eq!(
8796 mark_types,
8797 vec!["underline", "strike", "link"],
8798 "mark order should be preserved, got: {mark_types:?}"
8799 );
8800 }
8801
8802 #[test]
8803 fn mark_ordering_underline_strong_em_link_preserved() {
8804 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8806 {"type":"text","text":"all the marks","marks":[
8807 {"type":"underline"},
8808 {"type":"strong"},
8809 {"type":"em"},
8810 {"type":"link","attrs":{"href":"https://example.com/page"}}
8811 ]}
8812 ]}]}"#;
8813 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8814 let md = adf_to_markdown(&doc).unwrap();
8815 let round_tripped = markdown_to_adf(&md).unwrap();
8816 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8817 let mark_types: Vec<&str> = node
8818 .marks
8819 .as_ref()
8820 .unwrap()
8821 .iter()
8822 .map(|m| m.mark_type.as_str())
8823 .collect();
8824 assert_eq!(
8825 mark_types,
8826 vec!["underline", "strong", "em", "link"],
8827 "mark order should be preserved, got: {mark_types:?}"
8828 );
8829 }
8830
8831 #[test]
8832 fn em_strong_round_trip() {
8833 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8835 {"type":"text","text":"bold and italic","marks":[{"type":"strong"},{"type":"em"}]}
8836 ]}]}"#;
8837 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8838 let md = adf_to_markdown(&doc).unwrap();
8839 assert_eq!(md.trim(), "***bold and italic***");
8840 let round_tripped = markdown_to_adf(&md).unwrap();
8841 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8842 assert_eq!(node.text.as_deref(), Some("bold and italic"));
8843 let mark_types: Vec<&str> = node
8844 .marks
8845 .as_ref()
8846 .unwrap()
8847 .iter()
8848 .map(|m| m.mark_type.as_str())
8849 .collect();
8850 assert_eq!(
8851 mark_types,
8852 vec!["strong", "em"],
8853 "both strong and em marks should be preserved, got: {mark_types:?}"
8854 );
8855 }
8856
8857 #[test]
8858 fn em_strong_round_trip_em_first() {
8859 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8861 {"type":"text","text":"italic and bold","marks":[{"type":"em"},{"type":"strong"}]}
8862 ]}]}"#;
8863 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8864 let md = adf_to_markdown(&doc).unwrap();
8865 let round_tripped = markdown_to_adf(&md).unwrap();
8866 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8867 assert_eq!(node.text.as_deref(), Some("italic and bold"));
8868 let mark_types: Vec<&str> = node
8869 .marks
8870 .as_ref()
8871 .unwrap()
8872 .iter()
8873 .map(|m| m.mark_type.as_str())
8874 .collect();
8875 assert!(
8876 mark_types.contains(&"strong") && mark_types.contains(&"em"),
8877 "both strong and em marks should be present, got: {mark_types:?}"
8878 );
8879 }
8880
8881 #[test]
8882 fn triple_asterisk_parse_to_adf() {
8883 let md = "***bold and italic***\n";
8885 let doc = markdown_to_adf(md).unwrap();
8886 let node = &doc.content[0].content.as_ref().unwrap()[0];
8887 assert_eq!(node.text.as_deref(), Some("bold and italic"));
8888 let mark_types: Vec<&str> = node
8889 .marks
8890 .as_ref()
8891 .unwrap()
8892 .iter()
8893 .map(|m| m.mark_type.as_str())
8894 .collect();
8895 assert!(
8896 mark_types.contains(&"strong") && mark_types.contains(&"em"),
8897 "***text*** should produce both strong and em marks, got: {mark_types:?}"
8898 );
8899 }
8900
8901 #[test]
8902 fn triple_asterisk_with_surrounding_text() {
8903 let md = "before ***bold italic*** after\n";
8905 let doc = markdown_to_adf(md).unwrap();
8906 let nodes = doc.content[0].content.as_ref().unwrap();
8907 assert!(
8909 nodes.len() >= 3,
8910 "expected at least 3 nodes, got {}",
8911 nodes.len()
8912 );
8913 assert_eq!(nodes[0].text.as_deref(), Some("before "));
8914 assert_eq!(nodes[1].text.as_deref(), Some("bold italic"));
8915 let mark_types: Vec<&str> = nodes[1]
8916 .marks
8917 .as_ref()
8918 .unwrap()
8919 .iter()
8920 .map(|m| m.mark_type.as_str())
8921 .collect();
8922 assert!(
8923 mark_types.contains(&"strong") && mark_types.contains(&"em"),
8924 "middle node should have strong+em, got: {mark_types:?}"
8925 );
8926 assert_eq!(nodes[2].text.as_deref(), Some(" after"));
8927 }
8928
8929 #[test]
8930 fn annotation_mark_round_trip() {
8931 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8933 {"type":"text","text":"highlighted text","marks":[
8934 {"type":"annotation","attrs":{"id":"abc123","annotationType":"inlineComment"}}
8935 ]}
8936 ]}]}"#;
8937 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8938
8939 let md = adf_to_markdown(&doc).unwrap();
8940 assert!(
8941 md.contains("annotation-id="),
8942 "JFM should contain annotation-id, got: {md}"
8943 );
8944
8945 let round_tripped = markdown_to_adf(&md).unwrap();
8946 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8947 assert_eq!(text_node.text.as_deref(), Some("highlighted text"));
8948 let marks = text_node.marks.as_ref().expect("should have marks");
8949 let ann = marks
8950 .iter()
8951 .find(|m| m.mark_type == "annotation")
8952 .expect("should have annotation mark");
8953 let attrs = ann.attrs.as_ref().unwrap();
8954 assert_eq!(attrs["id"], "abc123");
8955 assert_eq!(attrs["annotationType"], "inlineComment");
8956 }
8957
8958 #[test]
8959 fn annotation_mark_with_bold() {
8960 let doc = AdfDocument {
8962 version: 1,
8963 doc_type: "doc".to_string(),
8964 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8965 "bold comment",
8966 vec![
8967 AdfMark::strong(),
8968 AdfMark::annotation("def456", "inlineComment"),
8969 ],
8970 )])],
8971 };
8972 let md = adf_to_markdown(&doc).unwrap();
8973 let round_tripped = markdown_to_adf(&md).unwrap();
8974 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8975 let marks = text_node.marks.as_ref().expect("should have marks");
8976 assert!(
8977 marks.iter().any(|m| m.mark_type == "strong"),
8978 "should have strong mark"
8979 );
8980 assert!(
8981 marks.iter().any(|m| m.mark_type == "annotation"),
8982 "should have annotation mark"
8983 );
8984 }
8985
8986 #[test]
8987 fn annotation_and_link_marks_both_preserved() {
8988 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8990 {"type":"text","text":"HANGUL-8","marks":[
8991 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"5ca7425e-34cd-48d3-b4eb-9873ac8b20e0"}},
8992 {"type":"link","attrs":{"href":"https://zendesk.atlassian.net/browse/HANGUL-8"}}
8993 ]}
8994 ]}]}"#;
8995 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8996 let md = adf_to_markdown(&doc).unwrap();
8997 assert!(
8999 md.contains("annotation-id="),
9000 "JFM should contain annotation-id, got: {md}"
9001 );
9002 assert!(
9003 md.contains("](https://"),
9004 "JFM should contain link href, got: {md}"
9005 );
9006 let round_tripped = markdown_to_adf(&md).unwrap();
9007 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
9008 let marks = text_node.marks.as_ref().expect("should have marks");
9009 assert!(
9010 marks.iter().any(|m| m.mark_type == "annotation"),
9011 "should have annotation mark, got: {:?}",
9012 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
9013 );
9014 assert!(
9015 marks.iter().any(|m| m.mark_type == "link"),
9016 "should have link mark, got: {:?}",
9017 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
9018 );
9019 }
9020
9021 #[test]
9022 fn annotation_and_code_marks_both_preserved() {
9023 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
9025 {"type":"text","text":"some text with "},
9026 {"type":"text","text":"annotated code","marks":[
9027 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"aabbccdd-1234-5678-abcd-000000000001"}},
9028 {"type":"code"}
9029 ]},
9030 {"type":"text","text":" remaining text"}
9031 ]}]}"#;
9032 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9033 let md = adf_to_markdown(&doc).unwrap();
9034 assert!(
9035 md.contains("annotation-id="),
9036 "JFM should contain annotation-id, got: {md}"
9037 );
9038 assert!(
9039 md.contains('`'),
9040 "JFM should contain backticks for code, got: {md}"
9041 );
9042
9043 let round_tripped = markdown_to_adf(&md).unwrap();
9044 let nodes = round_tripped.content[0].content.as_ref().unwrap();
9045 let code_node = nodes
9047 .iter()
9048 .find(|n| n.text.as_deref() == Some("annotated code"))
9049 .expect("should have 'annotated code' text node");
9050 let marks = code_node.marks.as_ref().expect("should have marks");
9051 assert!(
9052 marks.iter().any(|m| m.mark_type == "annotation"),
9053 "should have annotation mark, got: {:?}",
9054 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
9055 );
9056 assert!(
9057 marks.iter().any(|m| m.mark_type == "code"),
9058 "should have code mark, got: {:?}",
9059 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
9060 );
9061 let ann = marks.iter().find(|m| m.mark_type == "annotation").unwrap();
9062 let attrs = ann.attrs.as_ref().unwrap();
9063 assert_eq!(attrs["id"], "aabbccdd-1234-5678-abcd-000000000001");
9064 assert_eq!(attrs["annotationType"], "inlineComment");
9065 }
9066
9067 #[test]
9068 fn annotation_and_code_and_link_marks_all_preserved() {
9069 let doc = AdfDocument {
9071 version: 1,
9072 doc_type: "doc".to_string(),
9073 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9074 "linked code",
9075 vec![
9076 AdfMark::annotation("ann-001", "inlineComment"),
9077 AdfMark::code(),
9078 AdfMark::link("https://example.com"),
9079 ],
9080 )])],
9081 };
9082 let md = adf_to_markdown(&doc).unwrap();
9083 assert!(
9084 md.contains("annotation-id="),
9085 "JFM should contain annotation-id, got: {md}"
9086 );
9087 assert!(md.contains('`'), "JFM should contain backticks, got: {md}");
9088 assert!(
9089 md.contains("](https://example.com)"),
9090 "JFM should contain link, got: {md}"
9091 );
9092
9093 let round_tripped = markdown_to_adf(&md).unwrap();
9094 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
9095 let marks = text_node.marks.as_ref().expect("should have marks");
9096 assert!(
9097 marks.iter().any(|m| m.mark_type == "annotation"),
9098 "should have annotation mark, got: {:?}",
9099 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
9100 );
9101 assert!(
9102 marks.iter().any(|m| m.mark_type == "code"),
9103 "should have code mark, got: {:?}",
9104 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
9105 );
9106 assert!(
9107 marks.iter().any(|m| m.mark_type == "link"),
9108 "should have link mark, got: {:?}",
9109 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
9110 );
9111 }
9112
9113 #[test]
9114 fn multiple_annotations_and_code_mark_preserved() {
9115 let doc = AdfDocument {
9117 version: 1,
9118 doc_type: "doc".to_string(),
9119 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9120 "doubly annotated",
9121 vec![
9122 AdfMark::annotation("ann-aaa", "inlineComment"),
9123 AdfMark::annotation("ann-bbb", "inlineComment"),
9124 AdfMark::code(),
9125 ],
9126 )])],
9127 };
9128 let md = adf_to_markdown(&doc).unwrap();
9129 assert!(
9130 md.contains("ann-aaa"),
9131 "JFM should contain first annotation id, got: {md}"
9132 );
9133 assert!(
9134 md.contains("ann-bbb"),
9135 "JFM should contain second annotation id, got: {md}"
9136 );
9137
9138 let round_tripped = markdown_to_adf(&md).unwrap();
9139 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
9140 let marks = text_node.marks.as_ref().expect("should have marks");
9141 let ann_marks: Vec<_> = marks
9142 .iter()
9143 .filter(|m| m.mark_type == "annotation")
9144 .collect();
9145 assert_eq!(
9146 ann_marks.len(),
9147 2,
9148 "should have 2 annotation marks, got: {}",
9149 ann_marks.len()
9150 );
9151 assert!(
9152 marks.iter().any(|m| m.mark_type == "code"),
9153 "should have code mark"
9154 );
9155 }
9156
9157 #[test]
9158 fn underline_and_link_marks_both_preserved() {
9159 let doc = AdfDocument {
9161 version: 1,
9162 doc_type: "doc".to_string(),
9163 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9164 "click here",
9165 vec![AdfMark::underline(), AdfMark::link("https://example.com")],
9166 )])],
9167 };
9168 let md = adf_to_markdown(&doc).unwrap();
9169 assert!(md.contains("underline"), "should have underline attr: {md}");
9170 assert!(
9171 md.contains("](https://example.com)"),
9172 "should have link: {md}"
9173 );
9174 let round_tripped = markdown_to_adf(&md).unwrap();
9175 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
9176 let marks = text_node.marks.as_ref().expect("should have marks");
9177 assert!(marks.iter().any(|m| m.mark_type == "underline"));
9178 assert!(marks.iter().any(|m| m.mark_type == "link"));
9179 }
9180
9181 #[test]
9182 fn annotation_link_and_bold_all_preserved() {
9183 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
9185 {"type":"text","text":"important","marks":[
9186 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"abc"}},
9187 {"type":"link","attrs":{"href":"https://example.com"}},
9188 {"type":"strong"}
9189 ]}
9190 ]}]}"#;
9191 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9192 let md = adf_to_markdown(&doc).unwrap();
9193 let round_tripped = markdown_to_adf(&md).unwrap();
9194 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
9195 let marks = text_node.marks.as_ref().expect("should have marks");
9196 assert!(
9197 marks.iter().any(|m| m.mark_type == "annotation"),
9198 "should have annotation"
9199 );
9200 assert!(
9201 marks.iter().any(|m| m.mark_type == "link"),
9202 "should have link"
9203 );
9204 assert!(
9205 marks.iter().any(|m| m.mark_type == "strong"),
9206 "should have strong"
9207 );
9208 }
9209
9210 #[test]
9211 fn multiple_annotation_marks_round_trip() {
9212 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
9214 {"type":"text","text":"some annotated text","marks":[
9215 {"type":"annotation","attrs":{"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","annotationType":"inlineComment"}},
9216 {"type":"annotation","attrs":{"id":"ffffffff-1111-2222-3333-444444444444","annotationType":"inlineComment"}}
9217 ]}
9218 ]}]}"#;
9219 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9220
9221 let md = adf_to_markdown(&doc).unwrap();
9222 assert!(
9223 md.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
9224 "JFM should contain first annotation id, got: {md}"
9225 );
9226 assert!(
9227 md.contains("ffffffff-1111-2222-3333-444444444444"),
9228 "JFM should contain second annotation id, got: {md}"
9229 );
9230
9231 let round_tripped = markdown_to_adf(&md).unwrap();
9232 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
9233 assert_eq!(text_node.text.as_deref(), Some("some annotated text"));
9234 let marks = text_node.marks.as_ref().expect("should have marks");
9235 let annotations: Vec<_> = marks
9236 .iter()
9237 .filter(|m| m.mark_type == "annotation")
9238 .collect();
9239 assert_eq!(
9240 annotations.len(),
9241 2,
9242 "should have 2 annotation marks, got: {annotations:?}"
9243 );
9244 let ids: Vec<_> = annotations
9245 .iter()
9246 .map(|a| a.attrs.as_ref().unwrap()["id"].as_str().unwrap())
9247 .collect();
9248 assert!(ids.contains(&"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
9249 assert!(ids.contains(&"ffffffff-1111-2222-3333-444444444444"));
9250 }
9251
9252 #[test]
9253 fn three_annotation_marks_round_trip() {
9254 let doc = AdfDocument {
9256 version: 1,
9257 doc_type: "doc".to_string(),
9258 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9259 "triple annotated",
9260 vec![
9261 AdfMark::annotation("id-1", "inlineComment"),
9262 AdfMark::annotation("id-2", "inlineComment"),
9263 AdfMark::annotation("id-3", "inlineComment"),
9264 ],
9265 )])],
9266 };
9267 let md = adf_to_markdown(&doc).unwrap();
9268 let round_tripped = markdown_to_adf(&md).unwrap();
9269 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
9270 let marks = text_node.marks.as_ref().expect("should have marks");
9271 let annotations: Vec<_> = marks
9272 .iter()
9273 .filter(|m| m.mark_type == "annotation")
9274 .collect();
9275 assert_eq!(
9276 annotations.len(),
9277 3,
9278 "should have 3 annotation marks, got: {annotations:?}"
9279 );
9280 }
9281
9282 #[test]
9283 fn multiple_annotations_with_bold_round_trip() {
9284 let doc = AdfDocument {
9286 version: 1,
9287 doc_type: "doc".to_string(),
9288 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9289 "bold double annotated",
9290 vec![
9291 AdfMark::strong(),
9292 AdfMark::annotation("ann-a", "inlineComment"),
9293 AdfMark::annotation("ann-b", "inlineComment"),
9294 ],
9295 )])],
9296 };
9297 let md = adf_to_markdown(&doc).unwrap();
9298 let round_tripped = markdown_to_adf(&md).unwrap();
9299 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
9300 let marks = text_node.marks.as_ref().expect("should have marks");
9301 assert!(
9302 marks.iter().any(|m| m.mark_type == "strong"),
9303 "should have strong mark"
9304 );
9305 let annotations: Vec<_> = marks
9306 .iter()
9307 .filter(|m| m.mark_type == "annotation")
9308 .collect();
9309 assert_eq!(
9310 annotations.len(),
9311 2,
9312 "should have 2 annotation marks, got: {annotations:?}"
9313 );
9314 }
9315
9316 #[test]
9317 fn multiple_annotations_with_link_round_trip() {
9318 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
9320 {"type":"text","text":"linked text","marks":[
9321 {"type":"annotation","attrs":{"id":"ann-x","annotationType":"inlineComment"}},
9322 {"type":"annotation","attrs":{"id":"ann-y","annotationType":"inlineComment"}},
9323 {"type":"link","attrs":{"href":"https://example.com"}}
9324 ]}
9325 ]}]}"#;
9326 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9327 let md = adf_to_markdown(&doc).unwrap();
9328 let round_tripped = markdown_to_adf(&md).unwrap();
9329 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
9330 let marks = text_node.marks.as_ref().expect("should have marks");
9331 assert!(
9332 marks.iter().any(|m| m.mark_type == "link"),
9333 "should have link mark"
9334 );
9335 let annotations: Vec<_> = marks
9336 .iter()
9337 .filter(|m| m.mark_type == "annotation")
9338 .collect();
9339 assert_eq!(
9340 annotations.len(),
9341 2,
9342 "should have 2 annotation marks, got: {annotations:?}"
9343 );
9344 }
9345
9346 #[test]
9349 fn annotation_on_emoji_round_trip() {
9350 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
9352 {"type":"emoji","attrs":{"id":"1f4dd","shortName":":memo:","text":"📝"},"marks":[
9353 {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
9354 ]},
9355 {"type":"text","text":" annotated text","marks":[
9356 {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
9357 ]}
9358 ]}]}"#;
9359 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9360 let md = adf_to_markdown(&doc).unwrap();
9361 assert!(
9362 md.contains("annotation-id="),
9363 "JFM should contain annotation-id for emoji, got: {md}"
9364 );
9365
9366 let round_tripped = markdown_to_adf(&md).unwrap();
9367 let nodes = round_tripped.content[0].content.as_ref().unwrap();
9368
9369 let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
9371 let emoji_marks = emoji_node.marks.as_ref().expect("emoji should have marks");
9372 assert!(
9373 emoji_marks.iter().any(|m| m.mark_type == "annotation"),
9374 "emoji should have annotation mark, got: {emoji_marks:?}"
9375 );
9376 let ann = emoji_marks
9377 .iter()
9378 .find(|m| m.mark_type == "annotation")
9379 .unwrap();
9380 assert_eq!(
9381 ann.attrs.as_ref().unwrap()["id"],
9382 "ccddee11-2233-4455-aabb-ccddee112233"
9383 );
9384
9385 let text_node = nodes.iter().find(|n| n.node_type == "text").unwrap();
9387 let text_marks = text_node.marks.as_ref().expect("text should have marks");
9388 assert!(
9389 text_marks.iter().any(|m| m.mark_type == "annotation"),
9390 "text should have annotation mark"
9391 );
9392 }
9393
9394 #[test]
9395 fn annotation_on_status_round_trip() {
9396 let mut status = AdfNode::status("In Progress", "blue");
9397 status.marks = Some(vec![AdfMark::annotation("ann-status-1", "inlineComment")]);
9398
9399 let doc = AdfDocument {
9400 version: 1,
9401 doc_type: "doc".to_string(),
9402 content: vec![AdfNode::paragraph(vec![status])],
9403 };
9404 let md = adf_to_markdown(&doc).unwrap();
9405 assert!(
9406 md.contains("annotation-id="),
9407 "JFM should contain annotation-id for status, got: {md}"
9408 );
9409
9410 let round_tripped = markdown_to_adf(&md).unwrap();
9411 let nodes = round_tripped.content[0].content.as_ref().unwrap();
9412 let status_node = nodes.iter().find(|n| n.node_type == "status").unwrap();
9413 let marks = status_node
9414 .marks
9415 .as_ref()
9416 .expect("status should have marks");
9417 assert!(
9418 marks.iter().any(|m| m.mark_type == "annotation"),
9419 "status should have annotation mark, got: {marks:?}"
9420 );
9421 }
9422
9423 #[test]
9424 fn annotation_on_date_round_trip() {
9425 let mut date = AdfNode::date("1704067200000");
9426 date.marks = Some(vec![AdfMark::annotation("ann-date-1", "inlineComment")]);
9427
9428 let doc = AdfDocument {
9429 version: 1,
9430 doc_type: "doc".to_string(),
9431 content: vec![AdfNode::paragraph(vec![date])],
9432 };
9433 let md = adf_to_markdown(&doc).unwrap();
9434 assert!(
9435 md.contains("annotation-id="),
9436 "JFM should contain annotation-id for date, got: {md}"
9437 );
9438
9439 let round_tripped = markdown_to_adf(&md).unwrap();
9440 let nodes = round_tripped.content[0].content.as_ref().unwrap();
9441 let date_node = nodes.iter().find(|n| n.node_type == "date").unwrap();
9442 let marks = date_node.marks.as_ref().expect("date should have marks");
9443 assert!(
9444 marks.iter().any(|m| m.mark_type == "annotation"),
9445 "date should have annotation mark, got: {marks:?}"
9446 );
9447 }
9448
9449 #[test]
9450 fn annotation_on_mention_round_trip() {
9451 let mut mention = AdfNode::mention("user-123", "@Alice");
9452 mention.marks = Some(vec![AdfMark::annotation("ann-mention-1", "inlineComment")]);
9453
9454 let doc = AdfDocument {
9455 version: 1,
9456 doc_type: "doc".to_string(),
9457 content: vec![AdfNode::paragraph(vec![mention])],
9458 };
9459 let md = adf_to_markdown(&doc).unwrap();
9460 assert!(
9461 md.contains("annotation-id="),
9462 "JFM should contain annotation-id for mention, got: {md}"
9463 );
9464
9465 let round_tripped = markdown_to_adf(&md).unwrap();
9466 let nodes = round_tripped.content[0].content.as_ref().unwrap();
9467 let mention_node = nodes.iter().find(|n| n.node_type == "mention").unwrap();
9468 let marks = mention_node
9469 .marks
9470 .as_ref()
9471 .expect("mention should have marks");
9472 assert!(
9473 marks.iter().any(|m| m.mark_type == "annotation"),
9474 "mention should have annotation mark, got: {marks:?}"
9475 );
9476 }
9477
9478 #[test]
9479 fn annotation_on_inline_card_round_trip() {
9480 let mut card = AdfNode::inline_card("https://example.com");
9481 card.marks = Some(vec![AdfMark::annotation("ann-card-1", "inlineComment")]);
9482
9483 let doc = AdfDocument {
9484 version: 1,
9485 doc_type: "doc".to_string(),
9486 content: vec![AdfNode::paragraph(vec![card])],
9487 };
9488 let md = adf_to_markdown(&doc).unwrap();
9489 assert!(
9490 md.contains("annotation-id="),
9491 "JFM should contain annotation-id for inlineCard, got: {md}"
9492 );
9493
9494 let round_tripped = markdown_to_adf(&md).unwrap();
9495 let nodes = round_tripped.content[0].content.as_ref().unwrap();
9496 let card_node = nodes.iter().find(|n| n.node_type == "inlineCard").unwrap();
9497 let marks = card_node
9498 .marks
9499 .as_ref()
9500 .expect("inlineCard should have marks");
9501 assert!(
9502 marks.iter().any(|m| m.mark_type == "annotation"),
9503 "inlineCard should have annotation mark, got: {marks:?}"
9504 );
9505 }
9506
9507 #[test]
9508 fn annotation_on_placeholder_round_trip() {
9509 let mut placeholder = AdfNode::placeholder("Enter text here");
9510 placeholder.marks = Some(vec![AdfMark::annotation("ann-ph-1", "inlineComment")]);
9511
9512 let doc = AdfDocument {
9513 version: 1,
9514 doc_type: "doc".to_string(),
9515 content: vec![AdfNode::paragraph(vec![placeholder])],
9516 };
9517 let md = adf_to_markdown(&doc).unwrap();
9518 assert!(
9519 md.contains("annotation-id="),
9520 "JFM should contain annotation-id for placeholder, got: {md}"
9521 );
9522
9523 let round_tripped = markdown_to_adf(&md).unwrap();
9524 let nodes = round_tripped.content[0].content.as_ref().unwrap();
9525 let ph_node = nodes.iter().find(|n| n.node_type == "placeholder").unwrap();
9526 let marks = ph_node
9527 .marks
9528 .as_ref()
9529 .expect("placeholder should have marks");
9530 assert!(
9531 marks.iter().any(|m| m.mark_type == "annotation"),
9532 "placeholder should have annotation mark, got: {marks:?}"
9533 );
9534 }
9535
9536 #[test]
9537 fn multiple_annotations_on_emoji_round_trip() {
9538 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
9540 {"type":"emoji","attrs":{"shortName":":fire:","text":"🔥"},"marks":[
9541 {"type":"annotation","attrs":{"id":"ann-1","annotationType":"inlineComment"}},
9542 {"type":"annotation","attrs":{"id":"ann-2","annotationType":"inlineComment"}}
9543 ]}
9544 ]}]}"#;
9545 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9546 let md = adf_to_markdown(&doc).unwrap();
9547
9548 let round_tripped = markdown_to_adf(&md).unwrap();
9549 let nodes = round_tripped.content[0].content.as_ref().unwrap();
9550 let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
9551 let marks = emoji_node.marks.as_ref().expect("emoji should have marks");
9552 let annotations: Vec<_> = marks
9553 .iter()
9554 .filter(|m| m.mark_type == "annotation")
9555 .collect();
9556 assert_eq!(
9557 annotations.len(),
9558 2,
9559 "emoji should have 2 annotation marks, got: {annotations:?}"
9560 );
9561 }
9562
9563 #[test]
9564 fn emoji_without_annotation_unchanged() {
9565 let doc = AdfDocument {
9567 version: 1,
9568 doc_type: "doc".to_string(),
9569 content: vec![AdfNode::paragraph(vec![AdfNode::emoji(":fire:")])],
9570 };
9571 let md = adf_to_markdown(&doc).unwrap();
9572 assert!(
9574 !md.contains('['),
9575 "emoji without annotation should not be wrapped in brackets, got: {md}"
9576 );
9577 assert!(md.contains(":fire:"));
9578 }
9579
9580 #[test]
9583 fn status_directive() {
9584 let doc = markdown_to_adf("The ticket is :status[In Progress]{color=blue}.").unwrap();
9585 let content = doc.content[0].content.as_ref().unwrap();
9586 assert_eq!(content[1].node_type, "status");
9587 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "In Progress");
9588 assert_eq!(content[1].attrs.as_ref().unwrap()["color"], "blue");
9589 }
9590
9591 #[test]
9592 fn adf_status_to_markdown() {
9593 let doc = AdfDocument {
9594 version: 1,
9595 doc_type: "doc".to_string(),
9596 content: vec![AdfNode::paragraph(vec![AdfNode::status("Done", "green")])],
9597 };
9598 let md = adf_to_markdown(&doc).unwrap();
9599 assert!(md.contains(":status[Done]{color=green}"));
9600 }
9601
9602 #[test]
9603 fn round_trip_status() {
9604 let md = "The ticket is :status[In Progress]{color=blue}.\n";
9605 let doc = markdown_to_adf(md).unwrap();
9606 let result = adf_to_markdown(&doc).unwrap();
9607 assert!(result.contains(":status[In Progress]{color=blue}"));
9608 }
9609
9610 #[test]
9611 fn status_with_style_and_localid_roundtrips() {
9612 let adf = AdfDocument {
9613 version: 1,
9614 doc_type: "doc".to_string(),
9615 content: vec![AdfNode::paragraph(vec![{
9616 let mut node = AdfNode::status("open", "green");
9617 node.attrs.as_mut().unwrap()["style"] =
9618 serde_json::Value::String("bold".to_string());
9619 node.attrs.as_mut().unwrap()["localId"] =
9620 serde_json::Value::String("d2205ca5-84b9-4950-a730-bfe550fc146b".to_string());
9621 node
9622 }])],
9623 };
9624
9625 let md = adf_to_markdown(&adf).unwrap();
9626 assert!(
9627 md.contains("style=bold"),
9628 "Markdown should contain style attr: {md}"
9629 );
9630 assert!(
9631 md.contains("localId=d2205ca5"),
9632 "Markdown should contain localId attr: {md}"
9633 );
9634
9635 let rt = markdown_to_adf(&md).unwrap();
9636 let status = &rt.content[0].content.as_ref().unwrap()[0];
9637 let attrs = status.attrs.as_ref().unwrap();
9638 assert_eq!(attrs["text"], "open");
9639 assert_eq!(attrs["color"], "green");
9640 assert_eq!(attrs["style"], "bold");
9641 assert_eq!(
9642 attrs["localId"], "d2205ca5-84b9-4950-a730-bfe550fc146b",
9643 "localId should be preserved, got: {}",
9644 attrs["localId"]
9645 );
9646 }
9647
9648 #[test]
9649 fn status_without_style_still_works() {
9650 let md = ":status[Done]{color=green}\n";
9651 let doc = markdown_to_adf(md).unwrap();
9652 let status = &doc.content[0].content.as_ref().unwrap()[0];
9653 let attrs = status.attrs.as_ref().unwrap();
9654 assert_eq!(attrs["text"], "Done");
9655 assert_eq!(attrs["color"], "green");
9656 assert!(
9658 attrs.get("style").is_none() || attrs["style"].is_null(),
9659 "style should not be set when not provided"
9660 );
9661 }
9662
9663 #[test]
9664 fn strip_local_ids_removes_localid_from_status() {
9665 let adf = AdfDocument {
9666 version: 1,
9667 doc_type: "doc".to_string(),
9668 content: vec![AdfNode::paragraph(vec![{
9669 let mut node = AdfNode::status("open", "green");
9670 node.attrs.as_mut().unwrap()["localId"] =
9671 serde_json::Value::String("real-uuid-here".to_string());
9672 node
9673 }])],
9674 };
9675 let opts = RenderOptions {
9676 strip_local_ids: true,
9677 };
9678 let md = adf_to_markdown_with_options(&adf, &opts).unwrap();
9679 assert!(
9680 !md.contains("localId"),
9681 "localId should be stripped, got: {md}"
9682 );
9683 assert!(md.contains("color=green"), "color should be preserved");
9684 }
9685
9686 #[test]
9687 fn strip_local_ids_removes_localid_from_table() {
9688 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"}]}]}]}]}]}"#;
9689 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9690 let opts = RenderOptions {
9691 strip_local_ids: true,
9692 };
9693 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
9694 assert!(
9695 !md.contains("localId"),
9696 "localId should be stripped from table, got: {md}"
9697 );
9698 assert!(md.contains("layout=default"), "layout should be preserved");
9699 }
9700
9701 #[test]
9702 fn default_options_preserve_localid() {
9703 let adf = AdfDocument {
9704 version: 1,
9705 doc_type: "doc".to_string(),
9706 content: vec![AdfNode::paragraph(vec![{
9707 let mut node = AdfNode::status("open", "green");
9708 node.attrs.as_mut().unwrap()["localId"] =
9709 serde_json::Value::String("real-uuid-here".to_string());
9710 node
9711 }])],
9712 };
9713 let md = adf_to_markdown(&adf).unwrap();
9714 assert!(
9715 md.contains("localId=real-uuid-here"),
9716 "Default should preserve localId, got: {md}"
9717 );
9718 }
9719
9720 #[test]
9721 fn mention_localid_roundtrip() {
9722 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
9723 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9724 let md = adf_to_markdown(&doc).unwrap();
9725 assert!(
9726 md.contains("localId=m-001"),
9727 "mention should have localId in md: {md}"
9728 );
9729 let rt = markdown_to_adf(&md).unwrap();
9730 let mention = &rt.content[0].content.as_ref().unwrap()[0];
9731 assert_eq!(mention.attrs.as_ref().unwrap()["localId"], "m-001");
9732 }
9733
9734 #[test]
9735 fn date_localid_roundtrip() {
9736 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
9737 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9738 let md = adf_to_markdown(&doc).unwrap();
9739 assert!(
9740 md.contains("localId=d-001"),
9741 "date should have localId in md: {md}"
9742 );
9743 let rt = markdown_to_adf(&md).unwrap();
9744 let date = &rt.content[0].content.as_ref().unwrap()[0];
9745 assert_eq!(date.attrs.as_ref().unwrap()["localId"], "d-001");
9746 }
9747
9748 #[test]
9749 fn emoji_localid_roundtrip() {
9750 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"emoji","attrs":{"shortName":":smile:","localId":"e-001"}}]}]}"#;
9751 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9752 let md = adf_to_markdown(&doc).unwrap();
9753 assert!(
9754 md.contains("localId=e-001"),
9755 "emoji should have localId in md: {md}"
9756 );
9757 let rt = markdown_to_adf(&md).unwrap();
9758 let emoji = &rt.content[0].content.as_ref().unwrap()[0];
9759 assert_eq!(emoji.attrs.as_ref().unwrap()["localId"], "e-001");
9760 }
9761
9762 #[test]
9763 fn inline_card_localid_roundtrip() {
9764 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"inlineCard","attrs":{"url":"https://example.com","localId":"c-001"}}]}]}"#;
9765 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9766 let md = adf_to_markdown(&doc).unwrap();
9767 assert!(
9768 md.contains("localId=c-001"),
9769 "inlineCard should have localId in md: {md}"
9770 );
9771 let rt = markdown_to_adf(&md).unwrap();
9772 let card = &rt.content[0].content.as_ref().unwrap()[0];
9773 assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-001");
9774 }
9775
9776 #[test]
9777 fn strip_local_ids_removes_from_mention() {
9778 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
9779 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9780 let opts = RenderOptions {
9781 strip_local_ids: true,
9782 };
9783 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
9784 assert!(
9785 !md.contains("localId"),
9786 "localId should be stripped from mention: {md}"
9787 );
9788 assert!(md.contains("id=user123"), "other attrs should be preserved");
9789 }
9790
9791 #[test]
9792 fn strip_local_ids_removes_from_date() {
9793 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
9794 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9795 let opts = RenderOptions {
9796 strip_local_ids: true,
9797 };
9798 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
9799 assert!(
9800 !md.contains("localId"),
9801 "localId should be stripped from date: {md}"
9802 );
9803 }
9804
9805 #[test]
9806 fn strip_local_ids_removes_from_block_attrs() {
9807 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"hello"}]}]}"#;
9808 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9809 let opts = RenderOptions {
9810 strip_local_ids: true,
9811 };
9812 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
9813 assert!(
9814 !md.contains("localId"),
9815 "localId should be stripped from block attrs: {md}"
9816 );
9817 }
9818
9819 #[test]
9820 fn table_cell_localid_roundtrip() {
9821 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"}]}]}]}]}]}"#;
9822 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9823 let md = adf_to_markdown(&doc).unwrap();
9824 assert!(
9825 md.contains("localId=tc-001"),
9826 "tableCell should have localId in md: {md}"
9827 );
9828 let rt = markdown_to_adf(&md).unwrap();
9829 let cell = &rt.content[0].content.as_ref().unwrap()[0]
9830 .content
9831 .as_ref()
9832 .unwrap()[0];
9833 assert_eq!(
9834 cell.attrs.as_ref().unwrap()["localId"],
9835 "tc-001",
9836 "tableCell localId should round-trip"
9837 );
9838 }
9839
9840 #[test]
9841 fn table_cell_border_mark_roundtrip() {
9842 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"}]}]}]}]}]}"##;
9843 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9844 let md = adf_to_markdown(&doc).unwrap();
9845 assert!(
9846 md.contains("border-color=#ff000033"),
9847 "tableCell should have border-color in md: {md}"
9848 );
9849 assert!(
9850 md.contains("border-size=2"),
9851 "tableCell should have border-size in md: {md}"
9852 );
9853 let rt = markdown_to_adf(&md).unwrap();
9854 let cell = &rt.content[0].content.as_ref().unwrap()[0]
9855 .content
9856 .as_ref()
9857 .unwrap()[0];
9858 let marks = cell.marks.as_ref().expect("tableCell should have marks");
9859 assert_eq!(marks.len(), 1);
9860 assert_eq!(marks[0].mark_type, "border");
9861 let attrs = marks[0].attrs.as_ref().unwrap();
9862 assert_eq!(attrs["color"], "#ff000033");
9863 assert_eq!(attrs["size"], 2);
9864 }
9865
9866 #[test]
9867 fn table_header_border_mark_roundtrip() {
9868 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"}]}]}]}]}]}"##;
9869 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9870 let md = adf_to_markdown(&doc).unwrap();
9871 assert!(md.contains("border-color=#0000ff"), "md: {md}");
9872 assert!(md.contains("border-size=3"), "md: {md}");
9873 let rt = markdown_to_adf(&md).unwrap();
9874 let cell = &rt.content[0].content.as_ref().unwrap()[0]
9875 .content
9876 .as_ref()
9877 .unwrap()[0];
9878 assert_eq!(cell.node_type, "tableHeader");
9879 let marks = cell.marks.as_ref().expect("tableHeader should have marks");
9880 assert_eq!(marks[0].mark_type, "border");
9881 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#0000ff");
9882 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
9883 }
9884
9885 #[test]
9886 fn table_cell_border_mark_with_attrs_roundtrip() {
9887 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"}]}]}]}]}]}"##;
9888 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9889 let md = adf_to_markdown(&doc).unwrap();
9890 assert!(md.contains("bg=#e6fcff"), "md: {md}");
9891 assert!(md.contains("colspan=2"), "md: {md}");
9892 assert!(md.contains("border-color=#ff000033"), "md: {md}");
9893 let rt = markdown_to_adf(&md).unwrap();
9894 let cell = &rt.content[0].content.as_ref().unwrap()[0]
9895 .content
9896 .as_ref()
9897 .unwrap()[0];
9898 assert_eq!(cell.attrs.as_ref().unwrap()["background"], "#e6fcff");
9899 assert_eq!(cell.attrs.as_ref().unwrap()["colspan"], 2);
9900 let marks = cell.marks.as_ref().expect("should have marks");
9901 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff000033");
9902 }
9903
9904 #[test]
9905 fn table_cell_no_border_mark_unchanged() {
9906 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"}]}]}]}]}]}"#;
9907 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9908 let md = adf_to_markdown(&doc).unwrap();
9909 assert!(
9910 !md.contains("border-color"),
9911 "no border attrs expected: {md}"
9912 );
9913 let rt = markdown_to_adf(&md).unwrap();
9914 let cell = &rt.content[0].content.as_ref().unwrap()[0]
9915 .content
9916 .as_ref()
9917 .unwrap()[0];
9918 assert!(cell.marks.is_none(), "no marks expected on plain cell");
9919 }
9920
9921 #[test]
9922 fn table_cell_border_size_only_defaults_color() {
9923 let md = "::::table\n:::tr\n:::td{border-size=3}\ncell\n:::\n:::\n::::\n";
9926 let doc = markdown_to_adf(md).unwrap();
9927 let cell = &doc.content[0].content.as_ref().unwrap()[0]
9928 .content
9929 .as_ref()
9930 .unwrap()[0];
9931 let marks = cell.marks.as_ref().expect("should have border mark");
9932 assert_eq!(marks[0].mark_type, "border");
9933 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
9934 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
9935 }
9936
9937 #[test]
9938 fn table_cell_border_color_only_defaults_size() {
9939 let md = "::::table\n:::tr\n:::td{border-color=#ff0000}\ncell\n:::\n:::\n::::\n";
9941 let doc = markdown_to_adf(md).unwrap();
9942 let cell = &doc.content[0].content.as_ref().unwrap()[0]
9943 .content
9944 .as_ref()
9945 .unwrap()[0];
9946 let marks = cell.marks.as_ref().expect("should have border mark");
9947 assert_eq!(marks[0].mark_type, "border");
9948 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
9949 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
9950 }
9951
9952 #[test]
9953 fn media_file_border_mark_roundtrip() {
9954 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}}]}]}]}"##;
9955 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9956 let md = adf_to_markdown(&doc).unwrap();
9957 assert!(
9958 md.contains("border-color=#091e4224"),
9959 "media should have border-color in md: {md}"
9960 );
9961 assert!(
9962 md.contains("border-size=2"),
9963 "media should have border-size in md: {md}"
9964 );
9965 let rt = markdown_to_adf(&md).unwrap();
9966 let media_single = &rt.content[0];
9967 let media = &media_single.content.as_ref().unwrap()[0];
9968 assert_eq!(media.node_type, "media");
9969 let marks = media.marks.as_ref().expect("media should have marks");
9970 assert_eq!(marks.len(), 1);
9971 assert_eq!(marks[0].mark_type, "border");
9972 let attrs = marks[0].attrs.as_ref().unwrap();
9973 assert_eq!(attrs["color"], "#091e4224");
9974 assert_eq!(attrs["size"], 2);
9975 }
9976
9977 #[test]
9978 fn media_external_border_mark_roundtrip() {
9979 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}}]}]}]}"##;
9980 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9981 let md = adf_to_markdown(&doc).unwrap();
9982 assert!(
9983 md.contains("border-color=#ff0000"),
9984 "external media should have border-color in md: {md}"
9985 );
9986 assert!(
9987 md.contains("border-size=3"),
9988 "external media should have border-size in md: {md}"
9989 );
9990 let rt = markdown_to_adf(&md).unwrap();
9991 let media = &rt.content[0].content.as_ref().unwrap()[0];
9992 let marks = media.marks.as_ref().expect("media should have marks");
9993 assert_eq!(marks[0].mark_type, "border");
9994 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
9995 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
9996 }
9997
9998 #[test]
9999 fn media_file_no_border_mark_unchanged() {
10000 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}}]}]}"#;
10001 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10002 let md = adf_to_markdown(&doc).unwrap();
10003 assert!(
10004 !md.contains("border-color"),
10005 "no border attrs expected: {md}"
10006 );
10007 let rt = markdown_to_adf(&md).unwrap();
10008 let media = &rt.content[0].content.as_ref().unwrap()[0];
10009 assert!(media.marks.is_none(), "no marks expected on plain media");
10010 }
10011
10012 #[test]
10013 fn media_border_size_only_defaults_color() {
10014 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}}]}]}]}"##;
10015 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10016 let md = adf_to_markdown(&doc).unwrap();
10017 assert!(md.contains("border-size=4"), "md: {md}");
10018 let rt = markdown_to_adf(&md).unwrap();
10019 let media = &rt.content[0].content.as_ref().unwrap()[0];
10020 let marks = media.marks.as_ref().expect("should have border mark");
10021 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
10022 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 4);
10023 }
10024
10025 #[test]
10026 fn media_border_color_only_defaults_size() {
10027 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"}}]}]}]}"##;
10028 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10029 let md = adf_to_markdown(&doc).unwrap();
10030 assert!(md.contains("border-color=#00ff00"), "md: {md}");
10031 let rt = markdown_to_adf(&md).unwrap();
10032 let media = &rt.content[0].content.as_ref().unwrap()[0];
10033 let marks = media.marks.as_ref().expect("should have border mark");
10034 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#00ff00");
10035 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
10036 }
10037
10038 #[test]
10039 fn media_border_with_other_attrs_roundtrip() {
10040 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}}]}]}]}"##;
10041 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10042 let md = adf_to_markdown(&doc).unwrap();
10043 assert!(md.contains("layout=wide"), "md: {md}");
10044 assert!(md.contains("mediaWidth=600"), "md: {md}");
10045 assert!(md.contains("border-color=#091e4224"), "md: {md}");
10046 assert!(md.contains("border-size=2"), "md: {md}");
10047 let rt = markdown_to_adf(&md).unwrap();
10048 let ms = &rt.content[0];
10049 assert_eq!(ms.attrs.as_ref().unwrap()["layout"], "wide");
10050 let media = &ms.content.as_ref().unwrap()[0];
10051 let marks = media.marks.as_ref().expect("should have marks");
10052 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#091e4224");
10053 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 2);
10054 }
10055
10056 #[test]
10057 fn table_row_localid_roundtrip() {
10058 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"}]}]}]}]}]}"#;
10059 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10060 let md = adf_to_markdown(&doc).unwrap();
10061 assert!(
10062 md.contains("localId=tr-001"),
10063 "tableRow should have localId in md: {md}"
10064 );
10065 let rt = markdown_to_adf(&md).unwrap();
10066 let row = &rt.content[0].content.as_ref().unwrap()[0];
10067 assert_eq!(
10068 row.attrs.as_ref().unwrap()["localId"],
10069 "tr-001",
10070 "tableRow localId should round-trip"
10071 );
10072 }
10073
10074 #[test]
10075 fn list_item_localid_roundtrip() {
10076 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"}]}]}]}]}"#;
10078 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10079 let md = adf_to_markdown(&doc).unwrap();
10080 assert!(
10081 md.contains("localId=li-001"),
10082 "listItem should have localId in md: {md}"
10083 );
10084 let rt = markdown_to_adf(&md).unwrap();
10086 let list = &rt.content[0];
10087 assert!(
10088 list.attrs.is_none() || list.attrs.as_ref().unwrap().get("localId").is_none(),
10089 "bulletList should NOT have localId: {:?}",
10090 list.attrs
10091 );
10092 let item = &list.content.as_ref().unwrap()[0];
10093 assert_eq!(
10094 item.attrs.as_ref().unwrap()["localId"],
10095 "li-001",
10096 "listItem should have localId=li-001"
10097 );
10098 }
10099
10100 #[test]
10101 fn list_item_localid_not_promoted_to_parent() {
10102 let md = "- item {localId=li-002}\n";
10104 let doc = markdown_to_adf(md).unwrap();
10105 let list = &doc.content[0];
10106 assert!(
10107 list.attrs.is_none(),
10108 "bulletList should have no attrs: {:?}",
10109 list.attrs
10110 );
10111 let item = &list.content.as_ref().unwrap()[0];
10112 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "li-002");
10113 }
10114
10115 #[test]
10116 fn ordered_list_item_localid_roundtrip() {
10117 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"}]}]}]}]}"#;
10118 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10119 let md = adf_to_markdown(&doc).unwrap();
10120 assert!(md.contains("localId=oli-001"), "md: {md}");
10121 let rt = markdown_to_adf(&md).unwrap();
10122 let item = &rt.content[0].content.as_ref().unwrap()[0];
10123 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
10124 }
10125
10126 #[test]
10127 fn task_item_localid_roundtrip() {
10128 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"}]}]}]}]}"#;
10129 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10130 let md = adf_to_markdown(&doc).unwrap();
10131 assert!(md.contains("localId=ti-001"), "md: {md}");
10132 let rt = markdown_to_adf(&md).unwrap();
10133 let item = &rt.content[0].content.as_ref().unwrap()[0];
10134 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "ti-001");
10135 }
10136
10137 #[test]
10140 fn task_list_short_localid_roundtrip() {
10141 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"}]}]}]}"#;
10142 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10143 let md = adf_to_markdown(&doc).unwrap();
10144 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
10146 assert!(md.contains("localId=99"), "localId=99 missing: {md}");
10147 assert!(
10149 !md.contains("localId=}"),
10150 "empty localId should not be emitted: {md}"
10151 );
10152 let rt = markdown_to_adf(&md).unwrap();
10153 let task_list = &rt.content[0];
10154 assert_eq!(task_list.node_type, "taskList");
10155 assert_eq!(rt.content.len(), 1, "should be exactly one top-level node");
10157 let items = task_list.content.as_ref().unwrap();
10158 assert_eq!(items.len(), 2);
10159 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
10161 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
10162 assert!(
10163 items[0].content.is_none(),
10164 "empty taskItem should have no content: {:?}",
10165 items[0].content
10166 );
10167 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "99");
10169 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
10170 let content = items[1].content.as_ref().unwrap();
10171 assert_eq!(content.len(), 1);
10172 assert_eq!(content[0].text.as_deref(), Some("done task"));
10173 }
10174
10175 #[test]
10179 fn task_item_numeric_localid_with_hardbreak_roundtrip() {
10180 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!!)"}]}]}]}]}"#;
10181 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10182 let md = adf_to_markdown(&doc).unwrap();
10183 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
10185 let rt = markdown_to_adf(&md).unwrap();
10187 assert_eq!(rt.content.len(), 1, "exactly one top-level node");
10188 let task_list = &rt.content[0];
10189 assert_eq!(task_list.node_type, "taskList");
10190 let items = task_list.content.as_ref().unwrap();
10191 assert_eq!(items.len(), 1);
10192 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
10194 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
10195 let para = &items[0].content.as_ref().unwrap()[0];
10197 assert_eq!(para.node_type, "paragraph");
10198 let inlines = para.content.as_ref().unwrap();
10199 assert_eq!(inlines[0].node_type, "text");
10200 assert_eq!(
10201 inlines[0].text.as_deref(),
10202 Some("Engineering Onboarding Link")
10203 );
10204 assert_eq!(inlines[1].node_type, "hardBreak");
10205 assert_eq!(inlines[2].node_type, "text");
10206 assert_eq!(
10207 inlines[2].text.as_deref(),
10208 Some("(This has links to all the various useful tools!!)")
10209 );
10210 let rt_json = serde_json::to_string(&rt).unwrap();
10212 assert!(
10213 !rt_json.contains("{localId="),
10214 "localId attr syntax should not leak into ADF text: {rt_json}"
10215 );
10216 }
10217
10218 #[test]
10220 fn task_item_multiple_hardbreak_localids_roundtrip() {
10221 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"}]}]}]}]}"#;
10222 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10223 let md = adf_to_markdown(&doc).unwrap();
10224 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
10225 assert!(md.contains("localId=67"), "localId=67 missing: {md}");
10226 let rt = markdown_to_adf(&md).unwrap();
10227 let items = rt.content[0].content.as_ref().unwrap();
10228 assert_eq!(items.len(), 2);
10229 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
10230 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "67");
10231 for item in items {
10233 let para = &item.content.as_ref().unwrap()[0];
10234 assert_eq!(para.node_type, "paragraph");
10235 let inlines = para.content.as_ref().unwrap();
10236 assert_eq!(inlines[1].node_type, "hardBreak");
10237 }
10238 }
10239
10240 #[test]
10245 fn task_item_sibling_localid_hardbreak_unwrapped_roundtrip() {
10246 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"}]}]}]}"#;
10247 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10248 let md = adf_to_markdown(&doc).unwrap();
10249 assert!(
10251 md.contains(" (parenthetical"),
10252 "continuation line should be 2-space indented: {md}"
10253 );
10254 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
10255 assert!(md.contains("localId=69"), "localId=69 missing: {md}");
10256 let rt = markdown_to_adf(&md).unwrap();
10257 assert_eq!(
10259 rt.content.len(),
10260 1,
10261 "should be one taskList: {:#?}",
10262 rt.content
10263 );
10264 assert_eq!(rt.content[0].node_type, "taskList");
10265 let items = rt.content[0].content.as_ref().unwrap();
10266 assert_eq!(items.len(), 2, "should have 2 taskItems");
10267 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
10268 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
10269 let first_content = items[0].content.as_ref().unwrap();
10271 assert!(
10272 first_content.iter().any(|n| n.node_type == "hardBreak"),
10273 "first item should contain hardBreak"
10274 );
10275 let second_content = items[1].content.as_ref().unwrap();
10277 assert_eq!(second_content[0].node_type, "text");
10278 assert_eq!(
10279 second_content[0].text.as_deref().unwrap(),
10280 "second task item"
10281 );
10282 }
10283
10284 #[test]
10287 fn task_item_sibling_localid_hardbreak_paragraph_roundtrip() {
10288 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"}]}]}]}]}"#;
10289 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10290 let md = adf_to_markdown(&doc).unwrap();
10291 let rt = markdown_to_adf(&md).unwrap();
10292 assert_eq!(
10293 rt.content.len(),
10294 1,
10295 "should be one taskList: {:#?}",
10296 rt.content
10297 );
10298 let items = rt.content[0].content.as_ref().unwrap();
10299 assert_eq!(items.len(), 2);
10300 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
10301 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
10302 }
10303
10304 #[test]
10307 fn task_item_three_siblings_middle_hardbreak_roundtrip() {
10308 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"}]}]}]}"#;
10309 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10310 let md = adf_to_markdown(&doc).unwrap();
10311 let rt = markdown_to_adf(&md).unwrap();
10312 assert_eq!(rt.content.len(), 1);
10313 let items = rt.content[0].content.as_ref().unwrap();
10314 assert_eq!(items.len(), 3);
10315 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "10");
10316 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "20");
10317 assert_eq!(items[2].attrs.as_ref().unwrap()["localId"], "30");
10318 let mid_content = items[1].content.as_ref().unwrap();
10320 assert!(mid_content.iter().any(|n| n.node_type == "hardBreak"));
10321 }
10322
10323 #[test]
10326 fn task_list_empty_localid_no_spurious_paragraph() {
10327 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"}]}]}]}"#;
10328 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10329 let md = adf_to_markdown(&doc).unwrap();
10330 assert!(
10331 !md.contains("{localId=}"),
10332 "empty localId should not be emitted: {md}"
10333 );
10334 let rt = markdown_to_adf(&md).unwrap();
10335 assert_eq!(
10336 rt.content.len(),
10337 1,
10338 "no spurious paragraph: {:#?}",
10339 rt.content
10340 );
10341 assert_eq!(rt.content[0].node_type, "taskList");
10342 }
10343
10344 #[test]
10346 fn task_list_localid_stripped() {
10347 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"}]}]}]}]}"#;
10348 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10349 let opts = RenderOptions {
10350 strip_local_ids: true,
10351 };
10352 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
10353 assert!(!md.contains("localId"), "localId should be stripped: {md}");
10354 }
10355
10356 #[test]
10358 fn task_item_no_content_emits_localid() {
10359 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"}}]}]}"#;
10360 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10361 let md = adf_to_markdown(&doc).unwrap();
10362 assert!(
10363 md.contains("localId=abc"),
10364 "localId should be emitted even without content: {md}"
10365 );
10366 let rt = markdown_to_adf(&md).unwrap();
10367 let item = &rt.content[0].content.as_ref().unwrap()[0];
10368 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "abc");
10369 assert!(item.content.is_none(), "should have no content");
10370 }
10371
10372 #[test]
10374 fn task_list_localid_roundtrip() {
10375 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"}]}]}]}]}"#;
10376 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10377 let md = adf_to_markdown(&doc).unwrap();
10378 assert!(
10379 md.contains("localId=tl-xyz"),
10380 "taskList localId missing: {md}"
10381 );
10382 let rt = markdown_to_adf(&md).unwrap();
10383 assert_eq!(
10384 rt.content[0].attrs.as_ref().unwrap()["localId"],
10385 "tl-xyz",
10386 "taskList localId should survive round-trip"
10387 );
10388 }
10389
10390 #[test]
10392 fn task_item_paragraph_wrapper_roundtrip_no_localid() {
10393 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"}]}]}]}]}"#;
10394 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10395 let md = adf_to_markdown(&doc).unwrap();
10396 assert!(
10397 md.contains("paraLocalId=_"),
10398 "should emit paraLocalId=_ sentinel: {md}"
10399 );
10400 let rt = markdown_to_adf(&md).unwrap();
10401 let item = &rt.content[0].content.as_ref().unwrap()[0];
10402 let content = item.content.as_ref().unwrap();
10403 assert_eq!(content.len(), 1, "should have one child: {content:#?}");
10404 assert_eq!(
10405 content[0].node_type, "paragraph",
10406 "child should be a paragraph: {content:#?}"
10407 );
10408 let para_content = content[0].content.as_ref().unwrap();
10409 assert_eq!(
10410 para_content[0].text.as_deref(),
10411 Some("A task with paragraph wrapper")
10412 );
10413 assert!(
10415 content[0].attrs.is_none(),
10416 "paragraph should have no attrs: {:?}",
10417 content[0].attrs
10418 );
10419 }
10420
10421 #[test]
10423 fn task_item_paragraph_wrapper_roundtrip_with_localid() {
10424 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"}]}]}]}]}"#;
10425 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10426 let md = adf_to_markdown(&doc).unwrap();
10427 assert!(
10428 md.contains("paraLocalId=p-001"),
10429 "should emit paraLocalId=p-001: {md}"
10430 );
10431 let rt = markdown_to_adf(&md).unwrap();
10432 let item = &rt.content[0].content.as_ref().unwrap()[0];
10433 let content = item.content.as_ref().unwrap();
10434 assert_eq!(content[0].node_type, "paragraph");
10435 assert_eq!(
10436 content[0].attrs.as_ref().unwrap()["localId"],
10437 "p-001",
10438 "paragraph localId should be preserved"
10439 );
10440 }
10441
10442 #[test]
10444 fn task_item_unwrapped_inline_no_paragraph_on_roundtrip() {
10445 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"}]}]}]}"#;
10446 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10447 let md = adf_to_markdown(&doc).unwrap();
10448 assert!(
10449 !md.contains("paraLocalId"),
10450 "should NOT emit paraLocalId for unwrapped inline: {md}"
10451 );
10452 let rt = markdown_to_adf(&md).unwrap();
10453 let item = &rt.content[0].content.as_ref().unwrap()[0];
10454 let content = item.content.as_ref().unwrap();
10455 assert_eq!(
10456 content[0].node_type, "text",
10457 "should remain unwrapped: {content:#?}"
10458 );
10459 }
10460
10461 #[test]
10463 fn task_item_done_paragraph_wrapper_roundtrip() {
10464 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"}]}]}]}]}"#;
10465 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10466 let md = adf_to_markdown(&doc).unwrap();
10467 assert!(md.contains("- [x]"), "should render as done: {md}");
10468 let rt = markdown_to_adf(&md).unwrap();
10469 let item = &rt.content[0].content.as_ref().unwrap()[0];
10470 assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
10471 let content = item.content.as_ref().unwrap();
10472 assert_eq!(content[0].node_type, "paragraph");
10473 }
10474
10475 #[test]
10477 fn task_item_mixed_paragraph_and_unwrapped_roundtrip() {
10478 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"}]}]}]}"#;
10479 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10480 let md = adf_to_markdown(&doc).unwrap();
10481 let rt = markdown_to_adf(&md).unwrap();
10482 let items = rt.content[0].content.as_ref().unwrap();
10483 assert_eq!(items.len(), 2);
10484 let c1 = items[0].content.as_ref().unwrap();
10486 assert_eq!(
10487 c1[0].node_type, "paragraph",
10488 "first item should have paragraph wrapper"
10489 );
10490 let c2 = items[1].content.as_ref().unwrap();
10492 assert_eq!(
10493 c2[0].node_type, "text",
10494 "second item should remain unwrapped"
10495 );
10496 }
10497
10498 #[test]
10500 fn task_item_paragraph_wrapper_with_marks_roundtrip() {
10501 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"}]}]}]}]}]}"#;
10502 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10503 let md = adf_to_markdown(&doc).unwrap();
10504 let rt = markdown_to_adf(&md).unwrap();
10505 let item = &rt.content[0].content.as_ref().unwrap()[0];
10506 let content = item.content.as_ref().unwrap();
10507 assert_eq!(content[0].node_type, "paragraph");
10508 let para_children = content[0].content.as_ref().unwrap();
10509 assert!(
10510 para_children.len() >= 2,
10511 "paragraph should contain multiple inline nodes"
10512 );
10513 }
10514
10515 #[test]
10517 fn task_item_paragraph_wrapper_stripped_with_option() {
10518 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"}]}]}]}]}"#;
10519 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10520 let opts = RenderOptions {
10521 strip_local_ids: true,
10522 };
10523 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
10524 assert!(
10525 !md.contains("paraLocalId"),
10526 "paraLocalId should be stripped: {md}"
10527 );
10528 assert!(
10529 !md.contains("localId"),
10530 "all localIds should be stripped: {md}"
10531 );
10532 }
10533
10534 #[test]
10535 fn trailing_space_preserved_with_hex_localid() {
10536 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 "}]}]}]}]}"#;
10539 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10540 let md = adf_to_markdown(&doc).unwrap();
10541 let rt = markdown_to_adf(&md).unwrap();
10542 let item = &rt.content[0].content.as_ref().unwrap()[0];
10543 assert_eq!(
10544 item.attrs.as_ref().unwrap()["localId"],
10545 "aabb112233cc",
10546 "localId should round-trip"
10547 );
10548 let para = &item.content.as_ref().unwrap()[0];
10549 let inlines = para.content.as_ref().unwrap();
10550 let last = inlines.last().unwrap();
10551 assert!(
10552 last.text.as_deref().unwrap_or("").ends_with(' '),
10553 "trailing space should be preserved, got nodes: {:?}",
10554 inlines
10555 .iter()
10556 .map(|n| (&n.node_type, &n.text))
10557 .collect::<Vec<_>>()
10558 );
10559 }
10560
10561 #[test]
10562 fn extract_trailing_local_id_preserves_trailing_space() {
10563 let (before, lid, _) = extract_trailing_local_id("trailing space {localId=aabb112233cc}");
10565 assert_eq!(before, "trailing space ");
10566 assert_eq!(lid.as_deref(), Some("aabb112233cc"));
10567 }
10568
10569 #[test]
10570 fn extract_trailing_local_id_no_trailing_space() {
10571 let (before, lid, _) = extract_trailing_local_id("text {localId=abc123}");
10572 assert_eq!(before, "text");
10573 assert_eq!(lid.as_deref(), Some("abc123"));
10574 }
10575
10576 #[test]
10577 fn extract_trailing_local_id_no_attrs() {
10578 let (before, lid, pid) = extract_trailing_local_id("plain text");
10579 assert_eq!(before, "plain text");
10580 assert!(lid.is_none());
10581 assert!(pid.is_none());
10582 }
10583
10584 #[test]
10585 fn list_item_localid_stripped() {
10586 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"}]}]}]}]}"#;
10587 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10588 let opts = RenderOptions {
10589 strip_local_ids: true,
10590 };
10591 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
10592 assert!(!md.contains("localId"), "localId should be stripped: {md}");
10593 }
10594
10595 #[test]
10596 fn paragraph_localid_in_list_item_roundtrip() {
10597 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"}]}]}]}]}"#;
10599 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10600 let md = adf_to_markdown(&doc).unwrap();
10601 assert!(
10602 md.contains("paraLocalId=para-001"),
10603 "paragraph localId should be in md: {md}"
10604 );
10605 let rt = markdown_to_adf(&md).unwrap();
10606 let item = &rt.content[0].content.as_ref().unwrap()[0];
10607 assert_eq!(
10608 item.attrs.as_ref().unwrap()["localId"],
10609 "item-001",
10610 "listItem localId should survive"
10611 );
10612 let para = &item.content.as_ref().unwrap()[0];
10613 assert_eq!(
10614 para.attrs.as_ref().unwrap()["localId"],
10615 "para-001",
10616 "paragraph localId should survive round-trip"
10617 );
10618 }
10619
10620 #[test]
10621 fn paragraph_localid_in_ordered_list_item_roundtrip() {
10622 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"}]}]}]}]}"#;
10624 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10625 let md = adf_to_markdown(&doc).unwrap();
10626 assert!(md.contains("paraLocalId=op-001"), "md: {md}");
10627 let rt = markdown_to_adf(&md).unwrap();
10628 let item = &rt.content[0].content.as_ref().unwrap()[0];
10629 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
10630 let para = &item.content.as_ref().unwrap()[0];
10631 assert_eq!(para.attrs.as_ref().unwrap()["localId"], "op-001");
10632 }
10633
10634 #[test]
10635 fn paragraph_localid_only_in_list_item() {
10636 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"}]}]}]}]}"#;
10638 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10639 let md = adf_to_markdown(&doc).unwrap();
10640 assert!(
10641 md.contains("paraLocalId=para-only"),
10642 "paragraph localId should be emitted: {md}"
10643 );
10644 let rt = markdown_to_adf(&md).unwrap();
10645 let item = &rt.content[0].content.as_ref().unwrap()[0];
10646 assert!(item.attrs.is_none(), "listItem should have no attrs");
10647 let para = &item.content.as_ref().unwrap()[0];
10648 assert_eq!(para.attrs.as_ref().unwrap()["localId"], "para-only");
10649 }
10650
10651 #[test]
10652 fn paragraph_localid_in_table_header_roundtrip() {
10653 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"}]}]}]}]}]}"#;
10655 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10656 let md = adf_to_markdown(&doc).unwrap();
10657 assert!(
10659 md.contains("localId=aaaa-aaaa"),
10660 "paragraph localId should be in md: {md}"
10661 );
10662 let rt = markdown_to_adf(&md).unwrap();
10663 let cell = &rt.content[0].content.as_ref().unwrap()[0]
10664 .content
10665 .as_ref()
10666 .unwrap()[0];
10667 let para = &cell.content.as_ref().unwrap()[0];
10668 assert_eq!(
10669 para.attrs.as_ref().unwrap()["localId"],
10670 "aaaa-aaaa",
10671 "paragraph localId should survive round-trip in tableHeader"
10672 );
10673 }
10674
10675 #[test]
10676 fn paragraph_localid_in_table_cell_roundtrip() {
10677 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"}]}]}]}]}]}"#;
10679 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10680 let md = adf_to_markdown(&doc).unwrap();
10681 assert!(
10682 md.contains("localId=cell-para"),
10683 "paragraph localId should be in md: {md}"
10684 );
10685 let rt = markdown_to_adf(&md).unwrap();
10686 let cell = &rt.content[0].content.as_ref().unwrap()[1]
10688 .content
10689 .as_ref()
10690 .unwrap()[0];
10691 let para = &cell.content.as_ref().unwrap()[0];
10692 assert_eq!(
10693 para.attrs.as_ref().unwrap()["localId"],
10694 "cell-para",
10695 "paragraph localId should survive round-trip in tableCell"
10696 );
10697 }
10698
10699 #[test]
10700 fn nbsp_paragraph_with_localid_roundtrip() {
10701 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"nbsp-para"},"content":[{"type":"text","text":"\u00a0"}]}]}"#;
10703 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10704 let md = adf_to_markdown(&doc).unwrap();
10705 assert!(
10706 md.contains("::paragraph["),
10707 "nbsp should use directive form: {md}"
10708 );
10709 assert!(
10710 md.contains("localId=nbsp-para"),
10711 "localId should be in directive: {md}"
10712 );
10713 let rt = markdown_to_adf(&md).unwrap();
10714 let para = &rt.content[0];
10715 assert_eq!(
10716 para.attrs.as_ref().unwrap()["localId"],
10717 "nbsp-para",
10718 "localId should survive round-trip"
10719 );
10720 let text = para.content.as_ref().unwrap()[0].text.as_ref().unwrap();
10721 assert_eq!(text, "\u{00a0}", "nbsp should survive");
10722 }
10723
10724 #[test]
10725 fn empty_paragraph_with_localid_roundtrip() {
10726 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"empty-para"}}]}"#;
10728 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10729 let md = adf_to_markdown(&doc).unwrap();
10730 assert!(
10731 md.contains("::paragraph{localId=empty-para}"),
10732 "empty paragraph should include localId in directive: {md}"
10733 );
10734 let rt = markdown_to_adf(&md).unwrap();
10735 assert_eq!(
10736 rt.content[0].attrs.as_ref().unwrap()["localId"],
10737 "empty-para"
10738 );
10739 }
10740
10741 #[test]
10742 fn paragraph_localid_stripped_from_list_item() {
10743 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"}]}]}]}]}"#;
10745 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10746 let opts = RenderOptions {
10747 strip_local_ids: true,
10748 };
10749 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
10750 assert!(!md.contains("localId"), "localId should be stripped: {md}");
10751 assert!(
10752 !md.contains("paraLocalId"),
10753 "paraLocalId should be stripped: {md}"
10754 );
10755 }
10756
10757 #[test]
10758 fn date_directive() {
10759 let doc = markdown_to_adf("Due by :date[2026-04-15].").unwrap();
10760 let content = doc.content[0].content.as_ref().unwrap();
10761 assert_eq!(content[1].node_type, "date");
10762 assert_eq!(
10764 content[1].attrs.as_ref().unwrap()["timestamp"],
10765 "1776211200000"
10766 );
10767 }
10768
10769 #[test]
10770 fn adf_date_to_markdown() {
10771 let doc = AdfDocument {
10773 version: 1,
10774 doc_type: "doc".to_string(),
10775 content: vec![AdfNode::paragraph(vec![AdfNode::date("1776211200000")])],
10776 };
10777 let md = adf_to_markdown(&doc).unwrap();
10778 assert!(md.contains(":date[2026-04-15]{timestamp=1776211200000}"));
10779 }
10780
10781 #[test]
10782 fn adf_date_iso_passthrough() {
10783 let doc = AdfDocument {
10785 version: 1,
10786 doc_type: "doc".to_string(),
10787 content: vec![AdfNode::paragraph(vec![AdfNode::date("2026-04-15")])],
10788 };
10789 let md = adf_to_markdown(&doc).unwrap();
10790 assert!(md.contains(":date[2026-04-15]{timestamp=2026-04-15}"));
10791 }
10792
10793 #[test]
10794 fn round_trip_date() {
10795 let md = "Due by :date[2026-04-15].\n";
10796 let doc = markdown_to_adf(md).unwrap();
10797 let result = adf_to_markdown(&doc).unwrap();
10798 assert!(result.contains(":date[2026-04-15]"));
10799 }
10800
10801 #[test]
10802 fn round_trip_date_non_midnight_timestamp() {
10803 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000"}}]}]}"#;
10805 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10806 let md = adf_to_markdown(&doc).unwrap();
10807 assert!(
10809 md.contains("timestamp=1700000000000"),
10810 "JFM should preserve original timestamp: {md}"
10811 );
10812 let doc2 = markdown_to_adf(&md).unwrap();
10814 let content = doc2.content[0].content.as_ref().unwrap();
10815 assert_eq!(
10816 content[0].attrs.as_ref().unwrap()["timestamp"],
10817 "1700000000000",
10818 "Round-trip must preserve original non-midnight timestamp"
10819 );
10820 }
10821
10822 #[test]
10823 fn date_epoch_ms_passthrough() {
10824 let doc = markdown_to_adf("Due by :date[1776211200000].").unwrap();
10826 let content = doc.content[0].content.as_ref().unwrap();
10827 assert_eq!(
10828 content[1].attrs.as_ref().unwrap()["timestamp"],
10829 "1776211200000"
10830 );
10831 }
10832
10833 #[test]
10834 fn date_timestamp_attr_preferred_over_content() {
10835 let md = ":date[2023-11-14]{timestamp=1700000000000}\n";
10837 let doc = markdown_to_adf(md).unwrap();
10838 let content = doc.content[0].content.as_ref().unwrap();
10839 assert_eq!(
10840 content[0].attrs.as_ref().unwrap()["timestamp"],
10841 "1700000000000",
10842 "timestamp attr should be used directly"
10843 );
10844 }
10845
10846 #[test]
10847 fn date_without_timestamp_attr_backward_compat() {
10848 let md = ":date[2026-04-15]\n";
10850 let doc = markdown_to_adf(md).unwrap();
10851 let content = doc.content[0].content.as_ref().unwrap();
10852 assert_eq!(
10853 content[0].attrs.as_ref().unwrap()["timestamp"],
10854 "1776211200000",
10855 "Should fall back to computing timestamp from date string"
10856 );
10857 }
10858
10859 #[test]
10860 fn date_with_local_id_and_timestamp() {
10861 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
10863 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10864 let md = adf_to_markdown(&doc).unwrap();
10865 assert!(
10866 md.contains("timestamp=1700000000000"),
10867 "Should contain timestamp: {md}"
10868 );
10869 assert!(md.contains("localId=d-001"), "Should contain localId: {md}");
10870 let doc2 = markdown_to_adf(&md).unwrap();
10872 let content = doc2.content[0].content.as_ref().unwrap();
10873 let attrs = content[0].attrs.as_ref().unwrap();
10874 assert_eq!(attrs["timestamp"], "1700000000000");
10875 assert_eq!(attrs["localId"], "d-001");
10876 }
10877
10878 #[test]
10879 fn mention_directive() {
10880 let doc = markdown_to_adf("Assigned to :mention[Alice]{id=abc123}.").unwrap();
10881 let content = doc.content[0].content.as_ref().unwrap();
10882 assert_eq!(content[1].node_type, "mention");
10883 assert_eq!(content[1].attrs.as_ref().unwrap()["id"], "abc123");
10884 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "Alice");
10885 }
10886
10887 #[test]
10888 fn adf_mention_to_markdown() {
10889 let doc = AdfDocument {
10890 version: 1,
10891 doc_type: "doc".to_string(),
10892 content: vec![AdfNode::paragraph(vec![AdfNode::mention(
10893 "abc123", "Alice",
10894 )])],
10895 };
10896 let md = adf_to_markdown(&doc).unwrap();
10897 assert!(md.contains(":mention[Alice]{id=abc123}"));
10898 }
10899
10900 #[test]
10901 fn round_trip_mention() {
10902 let md = "Assigned to :mention[Alice]{id=abc123}.\n";
10903 let doc = markdown_to_adf(md).unwrap();
10904 let result = adf_to_markdown(&doc).unwrap();
10905 assert!(result.contains(":mention[Alice]{id=abc123}"));
10906 }
10907
10908 #[test]
10909 fn mention_with_empty_access_level_round_trips() {
10910 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10912 {"type":"mention","attrs":{"id":"61921b41c15977006af2b1d1","text":"@Javier Inchausti","accessLevel":""}}
10913 ]}]}"#;
10914 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10915
10916 let md = adf_to_markdown(&doc).unwrap();
10917 let round_tripped = markdown_to_adf(&md).unwrap();
10918 let mention = &round_tripped.content[0].content.as_ref().unwrap()[0];
10919 assert_eq!(
10920 mention.node_type, "mention",
10921 "mention with empty accessLevel was not parsed as mention, got: {}",
10922 mention.node_type
10923 );
10924 }
10925
10926 #[test]
10927 fn span_with_color() {
10928 let doc = markdown_to_adf("This is :span[red text]{color=#ff5630}.").unwrap();
10929 let content = doc.content[0].content.as_ref().unwrap();
10930 assert_eq!(content[1].node_type, "text");
10931 assert_eq!(content[1].text.as_deref(), Some("red text"));
10932 let marks = content[1].marks.as_ref().unwrap();
10933 assert_eq!(marks[0].mark_type, "textColor");
10934 }
10935
10936 #[test]
10937 fn adf_text_color_to_markdown() {
10938 let doc = AdfDocument {
10939 version: 1,
10940 doc_type: "doc".to_string(),
10941 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10942 "red text",
10943 vec![AdfMark::text_color("#ff5630")],
10944 )])],
10945 };
10946 let md = adf_to_markdown(&doc).unwrap();
10947 assert!(md.contains(":span[red text]{color=#ff5630}"));
10948 }
10949
10950 #[test]
10951 fn round_trip_span_color() {
10952 let md = "This is :span[red text]{color=#ff5630}.\n";
10953 let doc = markdown_to_adf(md).unwrap();
10954 let result = adf_to_markdown(&doc).unwrap();
10955 assert!(result.contains(":span[red text]{color=#ff5630}"));
10956 }
10957
10958 #[test]
10959 fn text_color_and_link_marks_both_preserved() {
10960 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10962 {"type":"text","text":"red link","marks":[
10963 {"type":"link","attrs":{"href":"https://example.com"}},
10964 {"type":"textColor","attrs":{"color":"#ff0000"}}
10965 ]}
10966 ]}]}"##;
10967 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10968 let md = adf_to_markdown(&doc).unwrap();
10969 assert!(
10970 md.contains(":span[red link]{color=#ff0000}"),
10971 "JFM should contain span with color, got: {md}"
10972 );
10973 assert!(
10974 md.contains("](https://example.com)"),
10975 "JFM should contain link href, got: {md}"
10976 );
10977 let rt = markdown_to_adf(&md).unwrap();
10979 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
10980 let marks = text_node.marks.as_ref().expect("should have marks");
10981 assert!(
10982 marks.iter().any(|m| m.mark_type == "textColor"),
10983 "should have textColor mark, got: {:?}",
10984 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
10985 );
10986 assert!(
10987 marks.iter().any(|m| m.mark_type == "link"),
10988 "should have link mark, got: {:?}",
10989 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
10990 );
10991 let link_mark = marks.iter().find(|m| m.mark_type == "link").unwrap();
10993 assert_eq!(
10994 link_mark.attrs.as_ref().unwrap()["href"],
10995 "https://example.com"
10996 );
10997 let color_mark = marks.iter().find(|m| m.mark_type == "textColor").unwrap();
10998 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#ff0000");
10999 }
11000
11001 #[test]
11002 fn bg_color_and_link_marks_both_preserved() {
11003 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11004 {"type":"text","text":"highlighted link","marks":[
11005 {"type":"link","attrs":{"href":"https://example.com"}},
11006 {"type":"backgroundColor","attrs":{"color":"#ffff00"}}
11007 ]}
11008 ]}]}"##;
11009 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11010 let md = adf_to_markdown(&doc).unwrap();
11011 assert!(md.contains("bg=#ffff00"), "should have bg color: {md}");
11012 assert!(
11013 md.contains("](https://example.com)"),
11014 "should have link: {md}"
11015 );
11016 let rt = markdown_to_adf(&md).unwrap();
11017 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
11018 let marks = text_node.marks.as_ref().expect("should have marks");
11019 assert!(marks.iter().any(|m| m.mark_type == "backgroundColor"));
11020 assert!(marks.iter().any(|m| m.mark_type == "link"));
11021 }
11022
11023 #[test]
11024 fn text_color_link_and_strong_rendering() {
11025 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11027 {"type":"text","text":"bold red link","marks":[
11028 {"type":"strong"},
11029 {"type":"link","attrs":{"href":"https://example.com"}},
11030 {"type":"textColor","attrs":{"color":"#ff0000"}}
11031 ]}
11032 ]}]}"##;
11033 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11034 let md = adf_to_markdown(&doc).unwrap();
11035 assert!(
11036 md.starts_with("**") && md.trim().ends_with("**"),
11037 "should have bold wrapping: {md}"
11038 );
11039 assert!(md.contains("color=#ff0000"), "should have color: {md}");
11040 assert!(
11041 md.contains("](https://example.com)"),
11042 "should have link: {md}"
11043 );
11044 }
11045
11046 #[test]
11047 fn subsup_and_link_marks_both_preserved() {
11048 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11049 {"type":"text","text":"note","marks":[
11050 {"type":"link","attrs":{"href":"https://example.com"}},
11051 {"type":"subsup","attrs":{"type":"sup"}}
11052 ]}
11053 ]}]}"#;
11054 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11055 let md = adf_to_markdown(&doc).unwrap();
11056 assert!(md.contains("sup"), "should have sup: {md}");
11057 assert!(
11058 md.contains("](https://example.com)"),
11059 "should have link: {md}"
11060 );
11061 let rt = markdown_to_adf(&md).unwrap();
11062 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
11063 let marks = text_node.marks.as_ref().expect("should have marks");
11064 assert!(marks.iter().any(|m| m.mark_type == "subsup"));
11065 assert!(marks.iter().any(|m| m.mark_type == "link"));
11066 }
11067
11068 #[test]
11069 fn text_color_without_link_unchanged() {
11070 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11072 {"type":"text","text":"just red","marks":[
11073 {"type":"textColor","attrs":{"color":"#ff0000"}}
11074 ]}
11075 ]}]}"##;
11076 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11077 let md = adf_to_markdown(&doc).unwrap();
11078 assert!(md.contains(":span[just red]{color=#ff0000}"), "md: {md}");
11079 assert!(!md.contains("](http"), "should NOT have link syntax: {md}");
11080 }
11081
11082 #[test]
11083 fn inline_extension_directive() {
11084 let doc =
11085 markdown_to_adf("See :extension[fallback]{type=com.app key=widget} here.").unwrap();
11086 let content = doc.content[0].content.as_ref().unwrap();
11087 assert_eq!(content[1].node_type, "inlineExtension");
11088 assert_eq!(
11089 content[1].attrs.as_ref().unwrap()["extensionType"],
11090 "com.app"
11091 );
11092 assert_eq!(content[1].attrs.as_ref().unwrap()["extensionKey"], "widget");
11093 }
11094
11095 #[test]
11096 fn adf_inline_extension_to_markdown() {
11097 let doc = AdfDocument {
11098 version: 1,
11099 doc_type: "doc".to_string(),
11100 content: vec![AdfNode::paragraph(vec![AdfNode::inline_extension(
11101 "com.app",
11102 "widget",
11103 Some("fallback"),
11104 )])],
11105 };
11106 let md = adf_to_markdown(&doc).unwrap();
11107 assert!(md.contains(":extension[fallback]{type=com.app key=widget}"));
11108 }
11109
11110 #[test]
11113 fn parse_ordered_list_marker_valid() {
11114 let result = parse_ordered_list_marker("1. Hello");
11115 assert_eq!(result, Some((1, "Hello")));
11116 }
11117
11118 #[test]
11119 fn parse_ordered_list_marker_high_number() {
11120 let result = parse_ordered_list_marker("42. Item");
11121 assert_eq!(result, Some((42, "Item")));
11122 }
11123
11124 #[test]
11125 fn parse_ordered_list_marker_not_a_list() {
11126 assert!(parse_ordered_list_marker("not a list").is_none());
11127 assert!(parse_ordered_list_marker("1.no space").is_none());
11128 }
11129
11130 #[test]
11131 fn is_list_start_various() {
11132 assert!(is_list_start("- item"));
11133 assert!(is_list_start("* item"));
11134 assert!(is_list_start("+ item"));
11135 assert!(is_list_start("1. item"));
11136 assert!(!is_list_start("not a list"));
11137 }
11138
11139 #[test]
11140 fn is_horizontal_rule_various() {
11141 assert!(is_horizontal_rule("---"));
11142 assert!(is_horizontal_rule("***"));
11143 assert!(is_horizontal_rule("___"));
11144 assert!(is_horizontal_rule("------"));
11145 assert!(!is_horizontal_rule("--"));
11146 assert!(!is_horizontal_rule("abc"));
11147 }
11148
11149 #[test]
11150 fn is_table_separator_valid() {
11151 assert!(is_table_separator("| --- | --- |"));
11152 assert!(is_table_separator("|:---:|:---|"));
11153 assert!(!is_table_separator("no pipes here"));
11154 }
11155
11156 #[test]
11157 fn parse_table_row_cells() {
11158 let cells = parse_table_row("| A | B | C |");
11159 assert_eq!(cells, vec!["A", "B", "C"]);
11160 }
11161
11162 #[test]
11163 fn parse_image_syntax_valid() {
11164 let result = parse_image_syntax("");
11165 assert_eq!(result, Some(("alt", "url")));
11166 }
11167
11168 #[test]
11169 fn parse_image_syntax_not_image() {
11170 assert!(parse_image_syntax("not an image").is_none());
11171 }
11172
11173 #[test]
11176 fn find_closing_paren_simple() {
11177 assert_eq!(find_closing_paren("(hello)", 0), Some(6));
11178 }
11179
11180 #[test]
11181 fn find_closing_paren_nested() {
11182 assert_eq!(find_closing_paren("(a(b)c)", 0), Some(6));
11183 }
11184
11185 #[test]
11186 fn find_closing_paren_unmatched() {
11187 assert_eq!(find_closing_paren("(no close", 0), None);
11188 }
11189
11190 #[test]
11191 fn find_closing_paren_offset() {
11192 assert_eq!(find_closing_paren("xx(inner)", 2), Some(8));
11194 }
11195
11196 #[test]
11199 fn try_parse_link_url_with_parens() {
11200 let input = "[here](https://example.com/faq#access-(permissions)-rest)";
11201 let result = try_parse_link(input, 0);
11202 assert_eq!(
11203 result,
11204 Some((
11205 input.len(),
11206 "here",
11207 "https://example.com/faq#access-(permissions)-rest"
11208 ))
11209 );
11210 }
11211
11212 #[test]
11213 fn try_parse_link_url_no_parens() {
11214 let input = "[text](https://example.com)";
11215 let result = try_parse_link(input, 0);
11216 assert_eq!(result, Some((input.len(), "text", "https://example.com")));
11217 }
11218
11219 #[test]
11220 fn try_parse_link_url_with_multiple_nested_parens() {
11221 let input = "[x](http://en.wikipedia.org/wiki/Foo_(bar_(baz)))";
11222 let result = try_parse_link(input, 0);
11223 assert_eq!(
11224 result,
11225 Some((
11226 input.len(),
11227 "x",
11228 "http://en.wikipedia.org/wiki/Foo_(bar_(baz))"
11229 ))
11230 );
11231 }
11232
11233 #[test]
11234 fn parse_image_syntax_url_with_parens() {
11235 let result = parse_image_syntax(")");
11236 assert_eq!(result, Some(("alt", "https://example.com/page_(1)")));
11237 }
11238
11239 #[test]
11240 fn parse_image_syntax_url_no_parens() {
11241 let result = parse_image_syntax("");
11242 assert_eq!(result, Some(("alt", "https://example.com")));
11243 }
11244
11245 #[test]
11246 fn link_with_parens_round_trip() {
11247 let href = "https://example.com/faq#I-need-access-(permissions)-added-in-Monitor";
11248 let mut text_node = AdfNode::text("here");
11249 text_node.marks = Some(vec![AdfMark::link(href)]);
11250 let adf_input = AdfDocument {
11251 version: 1,
11252 doc_type: "doc".to_string(),
11253 content: vec![AdfNode::paragraph(vec![text_node])],
11254 };
11255
11256 let jfm = adf_to_markdown(&adf_input).unwrap();
11257 let adf_output = markdown_to_adf(&jfm).unwrap();
11258
11259 let para = &adf_output.content[0];
11261 let text_node = ¶.content.as_ref().unwrap()[0];
11262 let mark = &text_node.marks.as_ref().unwrap()[0];
11263 let result_href = mark.attrs.as_ref().unwrap()["href"].as_str().unwrap();
11264
11265 assert_eq!(result_href, href);
11266 }
11267
11268 #[test]
11269 fn flush_plain_empty_range() {
11270 let mut nodes = Vec::new();
11271 flush_plain("hello", 3, 3, &mut nodes);
11272 assert!(nodes.is_empty());
11273 }
11274
11275 #[test]
11276 fn add_mark_to_unmarked_node() {
11277 let mut node = AdfNode::text("test");
11278 add_mark(&mut node, AdfMark::strong());
11279 assert_eq!(node.marks.as_ref().unwrap().len(), 1);
11280 }
11281
11282 #[test]
11283 fn add_mark_to_marked_node() {
11284 let mut node = AdfNode::text_with_marks("test", vec![AdfMark::strong()]);
11285 add_mark(&mut node, AdfMark::em());
11286 assert_eq!(node.marks.as_ref().unwrap().len(), 2);
11287 }
11288
11289 #[test]
11292 fn directive_table_basic() {
11293 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";
11294 let doc = markdown_to_adf(md).unwrap();
11295 assert_eq!(doc.content[0].node_type, "table");
11296 let rows = doc.content[0].content.as_ref().unwrap();
11297 assert_eq!(rows.len(), 2);
11298 assert_eq!(
11299 rows[0].content.as_ref().unwrap()[0].node_type,
11300 "tableHeader"
11301 );
11302 assert_eq!(rows[1].content.as_ref().unwrap()[0].node_type, "tableCell");
11303 }
11304
11305 #[test]
11306 fn directive_table_with_block_content() {
11307 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";
11308 let doc = markdown_to_adf(md).unwrap();
11309 let rows = doc.content[0].content.as_ref().unwrap();
11310 let cell = &rows[0].content.as_ref().unwrap()[0];
11311 let content = cell.content.as_ref().unwrap();
11313 assert!(content.len() >= 2);
11314 assert_eq!(content[1].node_type, "bulletList");
11315 }
11316
11317 #[test]
11318 fn directive_table_with_cell_attrs() {
11319 let md = "::::table\n:::tr\n:::td{colspan=2 bg=#DEEBFF}\nSpanning cell\n:::\n:::\n::::\n";
11320 let doc = markdown_to_adf(md).unwrap();
11321 let cell = &doc.content[0].content.as_ref().unwrap()[0]
11322 .content
11323 .as_ref()
11324 .unwrap()[0];
11325 let attrs = cell.attrs.as_ref().unwrap();
11326 assert_eq!(attrs["colspan"], 2);
11327 assert_eq!(attrs["background"], "#DEEBFF");
11328 }
11329
11330 #[test]
11331 fn directive_table_with_css_var_background() {
11332 let bg = "var(--ds-background-accent-gray-subtlest, var(--ds-background-accent-gray-subtlest, #F1F2F4))";
11333 let md = format!("::::table\n:::tr\n:::th{{bg=\"{bg}\"}}\nHeader\n:::\n:::\n::::\n");
11334 let doc = markdown_to_adf(&md).unwrap();
11335 let row = &doc.content[0].content.as_ref().unwrap()[0];
11336 let cells = row.content.as_ref().unwrap();
11337 assert_eq!(cells.len(), 1, "row must have at least one cell");
11338 let attrs = cells[0].attrs.as_ref().unwrap();
11339 assert_eq!(attrs["background"], bg);
11340 }
11341
11342 #[test]
11343 fn css_var_background_round_trips() {
11344 let bg = "var(--ds-background-accent-gray-subtlest, #F1F2F4)";
11345 let adf = AdfDocument {
11346 version: 1,
11347 doc_type: "doc".to_string(),
11348 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
11349 AdfNode::table_header_with_attrs(
11350 vec![AdfNode::paragraph(vec![AdfNode::text("Header")])],
11351 serde_json::json!({"background": bg}),
11352 ),
11353 ])])],
11354 };
11355 let md = adf_to_markdown(&adf).unwrap();
11356 assert!(
11357 md.contains(&format!("bg=\"{bg}\"")),
11358 "bg value must be quoted in markdown: {md}"
11359 );
11360
11361 let round_tripped = markdown_to_adf(&md).unwrap();
11362 let row = &round_tripped.content[0].content.as_ref().unwrap()[0];
11363 let cells = row.content.as_ref().unwrap();
11364 assert_eq!(cells.len(), 1, "round-tripped row must have one cell");
11365 let rt_attrs = cells[0].attrs.as_ref().unwrap();
11366 assert_eq!(rt_attrs["background"], bg);
11367 }
11368
11369 #[test]
11370 fn directive_table_with_table_attrs() {
11371 let md = "::::table{layout=wide numbered}\n:::tr\n:::td\nCell\n:::\n:::\n::::\n";
11372 let doc = markdown_to_adf(md).unwrap();
11373 let attrs = doc.content[0].attrs.as_ref().unwrap();
11374 assert_eq!(attrs["layout"], "wide");
11375 assert_eq!(attrs["isNumberColumnEnabled"], true);
11376 }
11377
11378 #[test]
11379 fn adf_table_with_block_content_renders_directive_form() {
11380 let doc = AdfDocument {
11382 version: 1,
11383 doc_type: "doc".to_string(),
11384 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
11385 AdfNode::table_cell(vec![
11386 AdfNode::paragraph(vec![AdfNode::text("Cell with list:")]),
11387 AdfNode::bullet_list(vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
11388 AdfNode::text("Item 1"),
11389 ])])]),
11390 ]),
11391 ])])],
11392 };
11393 let md = adf_to_markdown(&doc).unwrap();
11394 assert!(md.contains("::::table"));
11395 assert!(md.contains(":::td"));
11396 assert!(md.contains("- Item 1"));
11397 }
11398
11399 #[test]
11400 fn adf_table_inline_only_renders_pipe_form() {
11401 let doc = AdfDocument {
11403 version: 1,
11404 doc_type: "doc".to_string(),
11405 content: vec![AdfNode::table(vec![
11406 AdfNode::table_row(vec![
11407 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
11408 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
11409 ]),
11410 AdfNode::table_row(vec![
11411 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
11412 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
11413 ]),
11414 ])],
11415 };
11416 let md = adf_to_markdown(&doc).unwrap();
11417 assert!(md.contains("| H1 | H2 |"));
11418 assert!(!md.contains("::::table"));
11419 }
11420
11421 #[test]
11422 fn adf_table_header_outside_first_row_renders_directive() {
11423 let doc = AdfDocument {
11424 version: 1,
11425 doc_type: "doc".to_string(),
11426 content: vec![AdfNode::table(vec![
11427 AdfNode::table_row(vec![
11428 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H")])]),
11429 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C")])]),
11430 ]),
11431 AdfNode::table_row(vec![
11432 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
11433 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
11434 ]),
11435 ])],
11436 };
11437 let md = adf_to_markdown(&doc).unwrap();
11438 assert!(md.contains("::::table"));
11439 assert!(md.contains(":::th"));
11440 }
11441
11442 #[test]
11443 fn adf_table_cell_attrs_rendered() {
11444 let doc = AdfDocument {
11445 version: 1,
11446 doc_type: "doc".to_string(),
11447 content: vec![AdfNode::table(vec![
11448 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
11449 AdfNode::text("H"),
11450 ])])]),
11451 AdfNode::table_row(vec![AdfNode::table_cell_with_attrs(
11452 vec![AdfNode::paragraph(vec![AdfNode::text("C")])],
11453 serde_json::json!({"background": "#DEEBFF", "colspan": 2}),
11454 )]),
11455 ])],
11456 };
11457 let md = adf_to_markdown(&doc).unwrap();
11458 assert!(md.contains("{colspan=2 bg=#DEEBFF}"));
11459 }
11460
11461 #[test]
11464 fn pipe_table_cell_attrs() {
11465 let md = "| H1 | H2 |\n|---|---|\n| {bg=#DEEBFF} highlighted | normal |\n";
11466 let doc = markdown_to_adf(md).unwrap();
11467 let rows = doc.content[0].content.as_ref().unwrap();
11468 let cell = &rows[1].content.as_ref().unwrap()[0];
11469 let attrs = cell.attrs.as_ref().unwrap();
11470 assert_eq!(attrs["background"], "#DEEBFF");
11471 }
11472
11473 #[test]
11474 fn pipe_table_cell_colspan() {
11475 let md = "| H1 | H2 |\n|---|---|\n| {colspan=2} spanning |\n";
11476 let doc = markdown_to_adf(md).unwrap();
11477 let rows = doc.content[0].content.as_ref().unwrap();
11478 let cell = &rows[1].content.as_ref().unwrap()[0];
11479 let attrs = cell.attrs.as_ref().unwrap();
11480 assert_eq!(attrs["colspan"], 2);
11481 }
11482
11483 #[test]
11484 fn trailing_space_after_mention_in_table_cell_preserved() {
11485 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":[
11487 {"type":"mention","attrs":{"id":"aaa","text":"@Rob"}},
11488 {"type":"text","text":" "}
11489 ]}]}]}]}]}"#;
11490 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11491 let md = adf_to_markdown(&doc).unwrap();
11492 let round_tripped = markdown_to_adf(&md).unwrap();
11493 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
11494 .content
11495 .as_ref()
11496 .unwrap()[0];
11497 let para = &cell.content.as_ref().unwrap()[0];
11498 let inlines = para.content.as_ref().unwrap();
11499 assert!(
11500 inlines.len() >= 2,
11501 "expected mention + text(' ') nodes, got {} nodes: {:?}",
11502 inlines.len(),
11503 inlines.iter().map(|n| &n.node_type).collect::<Vec<_>>()
11504 );
11505 assert_eq!(inlines[0].node_type, "mention");
11506 assert_eq!(inlines[1].node_type, "text");
11507 assert_eq!(inlines[1].text.as_deref(), Some(" "));
11508 }
11509
11510 #[test]
11513 fn pipe_table_column_alignment() {
11514 let md = "| Left | Center | Right |\n|:---|:---:|---:|\n| L | C | R |\n";
11515 let doc = markdown_to_adf(md).unwrap();
11516 let rows = doc.content[0].content.as_ref().unwrap();
11517 let h_cells = rows[0].content.as_ref().unwrap();
11519 assert!(h_cells[0].content.as_ref().unwrap()[0].marks.is_none());
11521 let center_marks = h_cells[1].content.as_ref().unwrap()[0]
11523 .marks
11524 .as_ref()
11525 .unwrap();
11526 assert_eq!(center_marks[0].attrs.as_ref().unwrap()["align"], "center");
11527 let right_marks = h_cells[2].content.as_ref().unwrap()[0]
11529 .marks
11530 .as_ref()
11531 .unwrap();
11532 assert_eq!(right_marks[0].attrs.as_ref().unwrap()["align"], "end");
11533 }
11534
11535 #[test]
11536 fn adf_table_alignment_roundtrip() {
11537 let doc = AdfDocument {
11538 version: 1,
11539 doc_type: "doc".to_string(),
11540 content: vec![AdfNode::table(vec![
11541 AdfNode::table_row(vec![
11542 AdfNode::table_header(vec![{
11543 let mut p = AdfNode::paragraph(vec![AdfNode::text("Center")]);
11544 p.marks = Some(vec![AdfMark::alignment("center")]);
11545 p
11546 }]),
11547 AdfNode::table_header(vec![{
11548 let mut p = AdfNode::paragraph(vec![AdfNode::text("Right")]);
11549 p.marks = Some(vec![AdfMark::alignment("end")]);
11550 p
11551 }]),
11552 ]),
11553 AdfNode::table_row(vec![
11554 AdfNode::table_cell(vec![{
11555 let mut p = AdfNode::paragraph(vec![AdfNode::text("C")]);
11556 p.marks = Some(vec![AdfMark::alignment("center")]);
11557 p
11558 }]),
11559 AdfNode::table_cell(vec![{
11560 let mut p = AdfNode::paragraph(vec![AdfNode::text("R")]);
11561 p.marks = Some(vec![AdfMark::alignment("end")]);
11562 p
11563 }]),
11564 ]),
11565 ])],
11566 };
11567 let md = adf_to_markdown(&doc).unwrap();
11568 assert!(md.contains(":---:"));
11569 assert!(md.contains("---:"));
11570 }
11571
11572 #[test]
11575 fn panel_custom_attrs_round_trip() {
11576 let md = ":::panel{type=custom icon=\":star:\" color=\"#DEEBFF\"}\nContent\n:::\n";
11577 let doc = markdown_to_adf(md).unwrap();
11578 let panel = &doc.content[0];
11579 let attrs = panel.attrs.as_ref().unwrap();
11580 assert_eq!(attrs["panelType"], "custom");
11581 assert_eq!(attrs["panelIcon"], ":star:");
11582 assert_eq!(attrs["panelColor"], "#DEEBFF");
11583
11584 let result = adf_to_markdown(&doc).unwrap();
11585 assert!(result.contains("type=custom"));
11586 assert!(result.contains("icon="));
11587 assert!(result.contains("color="));
11588 }
11589
11590 #[test]
11593 fn block_card_with_layout() {
11594 let md = "::card[https://example.com]{layout=wide}\n";
11595 let doc = markdown_to_adf(md).unwrap();
11596 let attrs = doc.content[0].attrs.as_ref().unwrap();
11597 assert_eq!(attrs["layout"], "wide");
11598
11599 let result = adf_to_markdown(&doc).unwrap();
11600 assert!(result.contains("::card[https://example.com]{layout=wide}"));
11601 }
11602
11603 #[test]
11606 fn extension_with_params() {
11607 let md = r#"::extension{type=com.atlassian.macro key=jira-chart params='{"jql":"project=PROJ"}'}"#;
11608 let doc = markdown_to_adf(&format!("{md}\n")).unwrap();
11609 let attrs = doc.content[0].attrs.as_ref().unwrap();
11610 assert_eq!(attrs["parameters"]["jql"], "project=PROJ");
11611 }
11612
11613 #[test]
11614 fn leaf_extension_layout_preserved_in_roundtrip() {
11615 let adf_json = r#"{"version":1,"type":"doc","content":[
11617 {"type":"extension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"toc","layout":"default","parameters":{}}}
11618 ]}"#;
11619 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11620 let md = adf_to_markdown(&doc).unwrap();
11621 assert!(
11622 md.contains("layout=default"),
11623 "JFM should contain layout=default, got: {md}"
11624 );
11625 let round_tripped = markdown_to_adf(&md).unwrap();
11626 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
11627 assert_eq!(attrs["layout"], "default", "layout should be preserved");
11628 assert_eq!(attrs["extensionKey"], "toc");
11629 }
11630
11631 #[test]
11632 fn bodied_extension_layout_preserved_in_roundtrip() {
11633 let adf_json = r#"{"version":1,"type":"doc","content":[
11635 {"type":"bodiedExtension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"expand","layout":"wide"},
11636 "content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}
11637 ]}"#;
11638 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11639 let md = adf_to_markdown(&doc).unwrap();
11640 assert!(
11641 md.contains("layout=wide"),
11642 "JFM should contain layout=wide, got: {md}"
11643 );
11644 let round_tripped = markdown_to_adf(&md).unwrap();
11645 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
11646 assert_eq!(attrs["layout"], "wide", "layout should be preserved");
11647 }
11648
11649 #[test]
11650 fn bodied_extension_parameters_preserved_in_roundtrip() {
11651 let adf_json = r#"{"version":1,"type":"doc","content":[
11653 {"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":{}}},
11654 "content":[{"type":"paragraph","content":[{"type":"text","text":"Content inside bodied extension"}]}]}
11655 ]}"#;
11656 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11657 let md = adf_to_markdown(&doc).unwrap();
11658 assert!(
11659 md.contains("params="),
11660 "JFM should contain params attribute, got: {md}"
11661 );
11662 let round_tripped = markdown_to_adf(&md).unwrap();
11663 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
11664 assert_eq!(
11665 attrs["parameters"]["macroMetadata"]["title"], "Page Properties",
11666 "parameters should be preserved in round-trip"
11667 );
11668 assert_eq!(attrs["extensionKey"], "details");
11669 assert_eq!(attrs["layout"], "default");
11670 assert_eq!(attrs["localId"], "aabbccdd-1234");
11671 }
11672
11673 #[test]
11674 fn bodied_extension_malformed_params_ignored() {
11675 let md = ":::extension{type=com.atlassian.macro key=details params='not-valid-json'}\nContent\n:::\n";
11677 let doc = markdown_to_adf(md).unwrap();
11678 let attrs = doc.content[0].attrs.as_ref().unwrap();
11679 assert_eq!(attrs["extensionKey"], "details");
11680 assert!(attrs.get("parameters").is_none());
11682 }
11683
11684 #[test]
11685 fn leaf_extension_localid_preserved_in_roundtrip() {
11686 let adf_json = r#"{"version":1,"type":"doc","content":[
11688 {"type":"extension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"toc","layout":"default","localId":"abc-123"}}
11689 ]}"#;
11690 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11691 let md = adf_to_markdown(&doc).unwrap();
11692 let round_tripped = markdown_to_adf(&md).unwrap();
11693 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
11694 assert_eq!(attrs["layout"], "default");
11695 assert_eq!(attrs["localId"], "abc-123");
11696 }
11697
11698 #[test]
11701 fn mention_with_user_type() {
11702 let md = "Hi :mention[Alice]{id=abc123 userType=DEFAULT}.\n";
11703 let doc = markdown_to_adf(md).unwrap();
11704 let mention = &doc.content[0].content.as_ref().unwrap()[1];
11705 assert_eq!(mention.attrs.as_ref().unwrap()["userType"], "DEFAULT");
11706
11707 let result = adf_to_markdown(&doc).unwrap();
11708 assert!(result.contains("userType=DEFAULT"));
11709 }
11710
11711 #[test]
11714 fn directive_table_colwidth() {
11715 let md = "::::table\n:::tr\n:::td{colwidth=100,200}\nCell\n:::\n:::\n::::\n";
11716 let doc = markdown_to_adf(md).unwrap();
11717 let cell = &doc.content[0].content.as_ref().unwrap()[0]
11718 .content
11719 .as_ref()
11720 .unwrap()[0];
11721 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
11722 assert_eq!(colwidth, &[serde_json::json!(100), serde_json::json!(200)]);
11723 }
11724
11725 #[test]
11726 fn directive_table_colwidth_float_roundtrip() {
11727 let adf_doc = serde_json::json!({
11730 "type": "doc",
11731 "version": 1,
11732 "content": [{
11733 "type": "table",
11734 "content": [{
11735 "type": "tableRow",
11736 "content": [
11737 {
11738 "type": "tableHeader",
11739 "attrs": { "colwidth": [157.0] },
11740 "content": [{ "type": "paragraph" }]
11741 },
11742 {
11743 "type": "tableHeader",
11744 "attrs": { "colwidth": [863.0] },
11745 "content": [{ "type": "paragraph" }]
11746 }
11747 ]
11748 }]
11749 }]
11750 });
11751 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
11752 let md = adf_to_markdown(&doc).unwrap();
11753 assert!(
11754 md.contains("colwidth=157.0"),
11755 "expected colwidth=157.0 in markdown, got: {md}"
11756 );
11757 assert!(
11758 md.contains("colwidth=863.0"),
11759 "expected colwidth=863.0 in markdown, got: {md}"
11760 );
11761 let doc2 = markdown_to_adf(&md).unwrap();
11763 let row = &doc2.content[0].content.as_ref().unwrap()[0];
11764 let header1 = &row.content.as_ref().unwrap()[0];
11765 let header2 = &row.content.as_ref().unwrap()[1];
11766 assert_eq!(
11767 header1.attrs.as_ref().unwrap()["colwidth"]
11768 .as_array()
11769 .unwrap(),
11770 &[serde_json::json!(157.0)]
11771 );
11772 assert_eq!(
11773 header2.attrs.as_ref().unwrap()["colwidth"]
11774 .as_array()
11775 .unwrap(),
11776 &[serde_json::json!(863.0)]
11777 );
11778 }
11779
11780 #[test]
11781 fn colwidth_float_preserved_in_roundtrip() {
11782 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":[]}]}]}]}]}"#;
11784 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11785 let md = adf_to_markdown(&doc).unwrap();
11786 let round_tripped = markdown_to_adf(&md).unwrap();
11787 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
11788 .content
11789 .as_ref()
11790 .unwrap()[0];
11791 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
11792 assert_eq!(
11793 colwidth,
11794 &[serde_json::json!(254.0), serde_json::json!(416.0)],
11795 "colwidth should preserve float values"
11796 );
11797 }
11798
11799 #[test]
11800 fn colwidth_integer_preserved_in_roundtrip() {
11801 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"}]}]}]}]}]}"#;
11803 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11804 let md = adf_to_markdown(&doc).unwrap();
11805 assert!(
11806 md.contains("colwidth=150"),
11807 "expected colwidth=150 (no decimal) in markdown, got: {md}"
11808 );
11809 assert!(
11810 !md.contains("colwidth=150.0"),
11811 "colwidth should not have .0 suffix for integers, got: {md}"
11812 );
11813 let round_tripped = markdown_to_adf(&md).unwrap();
11815 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
11816 .content
11817 .as_ref()
11818 .unwrap()[0];
11819 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
11820 assert_eq!(
11821 colwidth,
11822 &[serde_json::json!(150)],
11823 "colwidth should preserve integer values"
11824 );
11825 let json_output = serde_json::to_string(&round_tripped).unwrap();
11827 assert!(
11828 json_output.contains(r#""colwidth":[150]"#),
11829 "JSON should contain integer colwidth, got: {json_output}"
11830 );
11831 }
11832
11833 #[test]
11834 fn colwidth_mixed_int_and_float_roundtrip() {
11835 let int_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100,200]}}]}]}]}"#;
11838 let float_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100.0,200.0]}}]}]}]}"#;
11839
11840 let int_doc: AdfDocument = serde_json::from_str(int_json).unwrap();
11842 let int_md = adf_to_markdown(&int_doc).unwrap();
11843 assert!(
11844 int_md.contains("colwidth=100,200"),
11845 "integer colwidth in md: {int_md}"
11846 );
11847 let int_rt = markdown_to_adf(&int_md).unwrap();
11848 let int_serial = serde_json::to_string(&int_rt).unwrap();
11849 assert!(
11850 int_serial.contains(r#""colwidth":[100,200]"#),
11851 "integer colwidth in JSON: {int_serial}"
11852 );
11853
11854 let float_doc: AdfDocument = serde_json::from_str(float_json).unwrap();
11856 let float_md = adf_to_markdown(&float_doc).unwrap();
11857 assert!(
11858 float_md.contains("colwidth=100.0,200.0"),
11859 "float colwidth in md: {float_md}"
11860 );
11861 let float_rt = markdown_to_adf(&float_md).unwrap();
11862 let float_serial = serde_json::to_string(&float_rt).unwrap();
11863 assert!(
11864 float_serial.contains(r#""colwidth":[100.0,200.0]"#),
11865 "float colwidth in JSON: {float_serial}"
11866 );
11867 }
11868
11869 #[test]
11870 fn colwidth_fractional_float_preserved() {
11871 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"}]}]}]}]}]}"#;
11873 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11874 let md = adf_to_markdown(&doc).unwrap();
11875 assert!(
11876 md.contains("colwidth=100.5"),
11877 "expected colwidth=100.5 in markdown, got: {md}"
11878 );
11879 }
11880
11881 #[test]
11882 fn colwidth_non_numeric_values_skipped() {
11883 let adf_doc = serde_json::json!({
11885 "type": "doc",
11886 "version": 1,
11887 "content": [{
11888 "type": "table",
11889 "content": [{
11890 "type": "tableRow",
11891 "content": [{
11892 "type": "tableCell",
11893 "attrs": { "colwidth": ["invalid"] },
11894 "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "cell" }] }]
11895 }]
11896 }]
11897 }]
11898 });
11899 let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
11900 let md = adf_to_markdown(&doc).unwrap();
11901 assert!(
11903 !md.contains("colwidth"),
11904 "non-numeric colwidth should be filtered out, got: {md}"
11905 );
11906 }
11907
11908 #[test]
11909 fn default_rowspan_colspan_preserved_in_roundtrip() {
11910 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"}]}]}]}]}]}"#;
11912 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11913 let md = adf_to_markdown(&doc).unwrap();
11914 let round_tripped = markdown_to_adf(&md).unwrap();
11915 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
11916 .content
11917 .as_ref()
11918 .unwrap()[0];
11919 let attrs = cell.attrs.as_ref().unwrap();
11920 assert_eq!(attrs["rowspan"], 1, "rowspan=1 should be preserved");
11921 assert_eq!(attrs["colspan"], 1, "colspan=1 should be preserved");
11922 }
11923
11924 #[test]
11927 fn table_localid_preserved_in_roundtrip() {
11928 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"}]}]}]}]}]}"#;
11930 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11931 let md = adf_to_markdown(&doc).unwrap();
11932 assert!(
11933 md.contains("localId="),
11934 "JFM should contain localId, got: {md}"
11935 );
11936 let round_tripped = markdown_to_adf(&md).unwrap();
11937 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
11938 assert_eq!(
11939 attrs["localId"], "7afd4550-e66c-4b12-875f-a91c6c7b62c7",
11940 "localId should be preserved"
11941 );
11942 }
11943
11944 #[test]
11945 fn paragraph_localid_preserved_in_roundtrip() {
11946 let adf_json = r#"{"version":1,"type":"doc","content":[
11948 {"type":"paragraph","attrs":{"localId":"abc-123"},"content":[{"type":"text","text":"hello"}]}
11949 ]}"#;
11950 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11951 let md = adf_to_markdown(&doc).unwrap();
11952 assert!(
11953 md.contains("localId=abc-123"),
11954 "JFM should contain localId, got: {md}"
11955 );
11956 let round_tripped = markdown_to_adf(&md).unwrap();
11957 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
11958 assert_eq!(attrs["localId"], "abc-123", "localId should be preserved");
11959 }
11960
11961 #[test]
11962 fn heading_localid_preserved_in_roundtrip() {
11963 let adf_json = r#"{"version":1,"type":"doc","content":[
11964 {"type":"heading","attrs":{"level":2,"localId":"h-456"},"content":[{"type":"text","text":"Title"}]}
11965 ]}"#;
11966 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11967 let md = adf_to_markdown(&doc).unwrap();
11968 let round_tripped = markdown_to_adf(&md).unwrap();
11969 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
11970 assert_eq!(attrs["localId"], "h-456");
11971 }
11972
11973 #[test]
11974 fn localid_with_alignment_preserved() {
11975 let adf_json = r#"{"version":1,"type":"doc","content":[
11977 {"type":"paragraph","attrs":{"localId":"p-789"},"marks":[{"type":"alignment","attrs":{"align":"center"}}],
11978 "content":[{"type":"text","text":"centered"}]}
11979 ]}"#;
11980 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11981 let md = adf_to_markdown(&doc).unwrap();
11982 assert!(md.contains("localId=p-789"), "should have localId: {md}");
11983 assert!(md.contains("align=center"), "should have align: {md}");
11984 let round_tripped = markdown_to_adf(&md).unwrap();
11985 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
11986 assert_eq!(attrs["localId"], "p-789");
11987 let marks = round_tripped.content[0].marks.as_ref().unwrap();
11988 assert!(marks.iter().any(|m| m.mark_type == "alignment"));
11989 }
11990
11991 #[test]
11992 fn table_layout_default_preserved_in_roundtrip() {
11993 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"}]}]}]}]}]}"#;
11995 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11996 let md = adf_to_markdown(&doc).unwrap();
11997 let round_tripped = markdown_to_adf(&md).unwrap();
11998 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
11999 assert_eq!(
12000 attrs["layout"], "default",
12001 "layout='default' should be preserved"
12002 );
12003 }
12004
12005 #[test]
12006 fn table_is_number_column_enabled_false_preserved() {
12007 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"}]}]}]}]}]}"#;
12009 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12010 let md = adf_to_markdown(&doc).unwrap();
12011 let round_tripped = markdown_to_adf(&md).unwrap();
12012 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
12013 assert_eq!(
12014 attrs["isNumberColumnEnabled"], false,
12015 "isNumberColumnEnabled=false should be preserved"
12016 );
12017 }
12018
12019 #[test]
12020 fn table_is_number_column_enabled_true_preserved() {
12021 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"}]}]}]}]}]}"#;
12023 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12024 let md = adf_to_markdown(&doc).unwrap();
12025 let round_tripped = markdown_to_adf(&md).unwrap();
12026 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
12027 assert_eq!(
12028 attrs["isNumberColumnEnabled"], true,
12029 "isNumberColumnEnabled=true should be preserved"
12030 );
12031 }
12032
12033 #[test]
12034 fn directive_table_is_number_column_enabled_false_preserved() {
12035 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
12038 {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
12039 {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
12040 ]}]}]}]}"#;
12041 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12042 let md = adf_to_markdown(&doc).unwrap();
12043 assert!(md.contains("::::table"), "should use directive table form");
12044 assert!(
12045 md.contains("numbered=false"),
12046 "should contain numbered=false, got: {md}"
12047 );
12048 let round_tripped = markdown_to_adf(&md).unwrap();
12049 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
12050 assert_eq!(attrs["isNumberColumnEnabled"], false);
12051 assert_eq!(attrs["layout"], "default");
12052 }
12053
12054 #[test]
12055 fn directive_table_is_number_column_enabled_true_preserved() {
12056 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":true,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
12058 {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
12059 {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
12060 ]}]}]}]}"#;
12061 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12062 let md = adf_to_markdown(&doc).unwrap();
12063 assert!(md.contains("::::table"), "should use directive table form");
12064 assert!(
12065 md.contains("numbered}") || md.contains("numbered "),
12066 "should contain numbered flag, got: {md}"
12067 );
12068 let round_tripped = markdown_to_adf(&md).unwrap();
12069 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
12070 assert_eq!(attrs["isNumberColumnEnabled"], true);
12071 }
12072
12073 #[test]
12074 fn trailing_space_in_bullet_list_item_preserved() {
12075 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
12077 {"type":"listItem","content":[{"type":"paragraph","content":[
12078 {"type":"text","text":"Before link "},
12079 {"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
12080 {"type":"text","text":" "}
12081 ]}]}
12082 ]}]}"#;
12083 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12084 let md = adf_to_markdown(&doc).unwrap();
12085 let round_tripped = markdown_to_adf(&md).unwrap();
12086 let list = &round_tripped.content[0];
12087 let item = &list.content.as_ref().unwrap()[0];
12088 let para = &item.content.as_ref().unwrap()[0];
12089 let inlines = para.content.as_ref().unwrap();
12090 let last = inlines.last().unwrap();
12091 assert_eq!(
12092 last.text.as_deref(),
12093 Some(" "),
12094 "trailing space text node should be preserved, got nodes: {:?}",
12095 inlines
12096 .iter()
12097 .map(|n| (&n.node_type, &n.text))
12098 .collect::<Vec<_>>()
12099 );
12100 }
12101
12102 #[test]
12103 fn trailing_space_after_mention_in_bullet_list_preserved() {
12104 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
12106 {"type":"listItem","content":[{"type":"paragraph","content":[
12107 {"type":"mention","attrs":{"id":"abc","text":"@Alice"}},
12108 {"type":"text","text":" "}
12109 ]}]}
12110 ]}]}"#;
12111 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12112 let md = adf_to_markdown(&doc).unwrap();
12113 let round_tripped = markdown_to_adf(&md).unwrap();
12114 let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
12115 .content
12116 .as_ref()
12117 .unwrap()[0];
12118 let inlines = para.content.as_ref().unwrap();
12119 assert!(
12120 inlines.len() >= 2,
12121 "should have mention + trailing space, got {} nodes",
12122 inlines.len()
12123 );
12124 assert_eq!(inlines.last().unwrap().text.as_deref(), Some(" "));
12125 }
12126
12127 #[test]
12128 fn trailing_space_in_ordered_list_item_preserved() {
12129 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
12131 {"type":"listItem","content":[{"type":"paragraph","content":[
12132 {"type":"text","text":"item "},
12133 {"type":"text","text":"link","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
12134 {"type":"text","text":" "}
12135 ]}]}
12136 ]}]}"#;
12137 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12138 let md = adf_to_markdown(&doc).unwrap();
12139 let round_tripped = markdown_to_adf(&md).unwrap();
12140 let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
12141 .content
12142 .as_ref()
12143 .unwrap()[0];
12144 let inlines = para.content.as_ref().unwrap();
12145 let last = inlines.last().unwrap();
12146 assert_eq!(
12147 last.text.as_deref(),
12148 Some(" "),
12149 "trailing space should be preserved in ordered list item"
12150 );
12151 }
12152
12153 #[test]
12154 fn trailing_space_in_heading_text_preserved() {
12155 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[
12157 {"type":"text","text":"Firefighting Engineers "}
12158 ]}]}"#;
12159 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12160 let md = adf_to_markdown(&doc).unwrap();
12161 let round_tripped = markdown_to_adf(&md).unwrap();
12162 let inlines = round_tripped.content[0].content.as_ref().unwrap();
12163 assert_eq!(
12164 inlines[0].text.as_deref(),
12165 Some("Firefighting Engineers "),
12166 "trailing space in heading should be preserved"
12167 );
12168 }
12169
12170 #[test]
12171 fn trailing_space_in_heading_before_bold_preserved() {
12172 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
12174 {"type":"text","text":"Classic "},
12175 {"type":"text","text":"bold","marks":[{"type":"strong"}]}
12176 ]}]}"#;
12177 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12178 let md = adf_to_markdown(&doc).unwrap();
12179 let round_tripped = markdown_to_adf(&md).unwrap();
12180 let inlines = round_tripped.content[0].content.as_ref().unwrap();
12181 assert_eq!(
12182 inlines[0].text.as_deref(),
12183 Some("Classic "),
12184 "trailing space in heading text before bold should be preserved"
12185 );
12186 }
12187
12188 #[test]
12189 fn leading_space_in_heading_text_preserved() {
12190 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":3},"content":[
12192 {"type":"text","text":" #general-channel"}
12193 ]}]}"#;
12194 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12195 let md = adf_to_markdown(&doc).unwrap();
12196 let round_tripped = markdown_to_adf(&md).unwrap();
12197 let inlines = round_tripped.content[0].content.as_ref().unwrap();
12198 assert_eq!(
12199 inlines[0].text.as_deref(),
12200 Some(" #general-channel"),
12201 "leading spaces in heading text should be preserved"
12202 );
12203 }
12204
12205 #[test]
12206 fn leading_space_in_heading_before_bold_preserved() {
12207 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
12209 {"type":"text","text":" indented"},
12210 {"type":"text","text":" bold","marks":[{"type":"strong"}]}
12211 ]}]}"#;
12212 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12213 let md = adf_to_markdown(&doc).unwrap();
12214 let round_tripped = markdown_to_adf(&md).unwrap();
12215 let inlines = round_tripped.content[0].content.as_ref().unwrap();
12216 assert_eq!(
12217 inlines[0].text.as_deref(),
12218 Some(" indented"),
12219 "leading spaces in heading text before bold should be preserved"
12220 );
12221 }
12222
12223 #[test]
12224 fn heading_multiple_leading_spaces_markdown_parse() {
12225 let md = "### \t #general-channel";
12227 let doc = markdown_to_adf(md).unwrap();
12228 let inlines = doc.content[0].content.as_ref().unwrap();
12229 assert_eq!(
12230 inlines[0].text.as_deref(),
12231 Some("\t #general-channel"),
12232 "leading whitespace in heading text should be preserved during JFM parsing"
12233 );
12234 }
12235
12236 #[test]
12237 fn trailing_space_in_paragraph_text_preserved() {
12238 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12240 {"type":"text","text":"word followed by space "},
12241 {"type":"text","text":"next node","marks":[{"type":"strong"}]}
12242 ]}]}"#;
12243 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12244 let md = adf_to_markdown(&doc).unwrap();
12245 let round_tripped = markdown_to_adf(&md).unwrap();
12246 let inlines = round_tripped.content[0].content.as_ref().unwrap();
12247 assert_eq!(
12248 inlines[0].text.as_deref(),
12249 Some("word followed by space "),
12250 "trailing space in paragraph text should be preserved"
12251 );
12252 }
12253
12254 #[test]
12255 fn nested_bullet_list_roundtrip() {
12256 let adf_doc = serde_json::json!({
12258 "type": "doc",
12259 "version": 1,
12260 "content": [{
12261 "type": "bulletList",
12262 "content": [{
12263 "type": "listItem",
12264 "content": [
12265 {
12266 "type": "paragraph",
12267 "content": [{"type": "text", "text": "parent item"}]
12268 },
12269 {
12270 "type": "bulletList",
12271 "content": [
12272 {
12273 "type": "listItem",
12274 "content": [{
12275 "type": "paragraph",
12276 "content": [{"type": "text", "text": "sub item 1"}]
12277 }]
12278 },
12279 {
12280 "type": "listItem",
12281 "content": [{
12282 "type": "paragraph",
12283 "content": [{"type": "text", "text": "sub item 2"}]
12284 }]
12285 }
12286 ]
12287 }
12288 ]
12289 }]
12290 }]
12291 });
12292 let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
12293 let md = adf_to_markdown(&doc).unwrap();
12294 assert!(
12295 md.contains("- parent item\n"),
12296 "expected top-level item in markdown, got: {md}"
12297 );
12298 assert!(
12299 md.contains(" - sub item 1\n"),
12300 "expected indented sub item 1 in markdown, got: {md}"
12301 );
12302 assert!(
12303 md.contains(" - sub item 2\n"),
12304 "expected indented sub item 2 in markdown, got: {md}"
12305 );
12306
12307 let doc2 = markdown_to_adf(&md).unwrap();
12309 let list = &doc2.content[0];
12310 assert_eq!(list.node_type, "bulletList");
12311 let item = &list.content.as_ref().unwrap()[0];
12312 assert_eq!(item.node_type, "listItem");
12313 let item_content = item.content.as_ref().unwrap();
12314 assert_eq!(
12315 item_content.len(),
12316 2,
12317 "listItem should have paragraph + nested list"
12318 );
12319 assert_eq!(item_content[0].node_type, "paragraph");
12320 assert_eq!(item_content[1].node_type, "bulletList");
12321 let sub_items = item_content[1].content.as_ref().unwrap();
12322 assert_eq!(sub_items.len(), 2);
12323 }
12324
12325 #[test]
12326 fn nested_bullet_in_table_cell_roundtrip() {
12327 let md = "::::table\n:::tr\n:::td\n- parent\n - child\n:::\n:::\n::::\n";
12328 let doc = markdown_to_adf(md).unwrap();
12329 let table = &doc.content[0];
12330 let row = &table.content.as_ref().unwrap()[0];
12331 let cell = &row.content.as_ref().unwrap()[0];
12332 let list = &cell.content.as_ref().unwrap()[0];
12333 assert_eq!(list.node_type, "bulletList");
12334 let item = &list.content.as_ref().unwrap()[0];
12335 let item_content = item.content.as_ref().unwrap();
12336 assert_eq!(
12337 item_content.len(),
12338 2,
12339 "listItem should have paragraph + nested list"
12340 );
12341 assert_eq!(item_content[1].node_type, "bulletList");
12342
12343 let md2 = adf_to_markdown(&doc).unwrap();
12345 assert!(
12346 md2.contains(" - child"),
12347 "expected indented child in round-tripped markdown, got: {md2}"
12348 );
12349 }
12350
12351 #[test]
12352 fn nested_ordered_list_roundtrip() {
12353 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
12355 {"type":"listItem","content":[
12356 {"type":"paragraph","content":[{"type":"text","text":"Top level"}]},
12357 {"type":"orderedList","attrs":{"order":1},"content":[
12358 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 1"}]}]},
12359 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 2"}]}]}
12360 ]}
12361 ]},
12362 {"type":"listItem","content":[
12363 {"type":"paragraph","content":[{"type":"text","text":"Second top"}]}
12364 ]}
12365 ]}]}"#;
12366 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12367 let md = adf_to_markdown(&doc).unwrap();
12368 let round_tripped = markdown_to_adf(&md).unwrap();
12369
12370 let outer = &round_tripped.content[0];
12372 assert_eq!(outer.node_type, "orderedList");
12373 assert_eq!(outer.attrs.as_ref().unwrap()["order"], 1);
12374 let outer_items = outer.content.as_ref().unwrap();
12375 assert_eq!(
12376 outer_items.len(),
12377 2,
12378 "outer list should have 2 items, got {}",
12379 outer_items.len()
12380 );
12381
12382 let first_item = &outer_items[0];
12384 let first_content = first_item.content.as_ref().unwrap();
12385 assert_eq!(
12386 first_content.len(),
12387 2,
12388 "first listItem should have paragraph + nested list, got {}",
12389 first_content.len()
12390 );
12391 assert_eq!(first_content[0].node_type, "paragraph");
12392 assert_eq!(first_content[1].node_type, "orderedList");
12393 let nested_items = first_content[1].content.as_ref().unwrap();
12394 assert_eq!(nested_items.len(), 2, "nested list should have 2 items");
12395 }
12396
12397 #[test]
12398 fn nested_ordered_list_markdown_parsing() {
12399 let md = "1. Top level\n 1. Nested 1\n 2. Nested 2\n2. Second top\n";
12401 let doc = markdown_to_adf(md).unwrap();
12402 let outer = &doc.content[0];
12403 assert_eq!(outer.node_type, "orderedList");
12404 let outer_items = outer.content.as_ref().unwrap();
12405 assert_eq!(outer_items.len(), 2, "should have 2 top-level items");
12406
12407 let first_content = outer_items[0].content.as_ref().unwrap();
12408 assert_eq!(
12409 first_content.len(),
12410 2,
12411 "first item should have paragraph + nested list"
12412 );
12413 assert_eq!(first_content[1].node_type, "orderedList");
12414 }
12415
12416 #[test]
12417 fn bullet_list_nested_inside_ordered_list() {
12418 let md = "1. Ordered item\n - Bullet child 1\n - Bullet child 2\n2. Second ordered\n";
12420 let doc = markdown_to_adf(md).unwrap();
12421 let outer = &doc.content[0];
12422 assert_eq!(outer.node_type, "orderedList");
12423 let outer_items = outer.content.as_ref().unwrap();
12424 assert_eq!(outer_items.len(), 2);
12425
12426 let first_content = outer_items[0].content.as_ref().unwrap();
12427 assert_eq!(
12428 first_content.len(),
12429 2,
12430 "first item should have paragraph + nested list"
12431 );
12432 assert_eq!(first_content[1].node_type, "bulletList");
12433 let sub_items = first_content[1].content.as_ref().unwrap();
12434 assert_eq!(sub_items.len(), 2, "nested bullet list should have 2 items");
12435 }
12436
12437 #[test]
12438 fn ordered_list_order_attr_always_preserved() {
12439 let md = "1. A\n2. B\n";
12441 let doc = markdown_to_adf(md).unwrap();
12442 let attrs = doc.content[0].attrs.as_ref().unwrap();
12443 assert_eq!(attrs["order"], 1, "order=1 should be explicitly present");
12444
12445 let md2 = adf_to_markdown(&doc).unwrap();
12447 let doc2 = markdown_to_adf(&md2).unwrap();
12448 let attrs2 = doc2.content[0].attrs.as_ref().unwrap();
12449 assert_eq!(attrs2["order"], 1);
12450 }
12451
12452 #[test]
12455 fn file_media_roundtrip() {
12456 let adf_doc = serde_json::json!({
12458 "type": "doc",
12459 "version": 1,
12460 "content": [{
12461 "type": "mediaSingle",
12462 "attrs": {"layout": "center"},
12463 "content": [{
12464 "type": "media",
12465 "attrs": {
12466 "type": "file",
12467 "id": "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d",
12468 "collection": "contentId-8220672100",
12469 "height": 56,
12470 "width": 312,
12471 "alt": "Screenshot.png"
12472 }
12473 }]
12474 }]
12475 });
12476 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
12477 let md = adf_to_markdown(&doc).unwrap();
12478 assert!(
12479 md.contains("type=file"),
12480 "expected type=file in markdown, got: {md}"
12481 );
12482 assert!(
12483 md.contains("id=6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d"),
12484 "expected id in markdown, got: {md}"
12485 );
12486 assert!(
12487 md.contains("collection=contentId-8220672100"),
12488 "expected collection in markdown, got: {md}"
12489 );
12490 let doc2 = markdown_to_adf(&md).unwrap();
12492 let ms = &doc2.content[0];
12493 assert_eq!(ms.node_type, "mediaSingle");
12494 let media = &ms.content.as_ref().unwrap()[0];
12495 assert_eq!(media.node_type, "media");
12496 let attrs = media.attrs.as_ref().unwrap();
12497 assert_eq!(attrs["type"], "file");
12498 assert_eq!(attrs["id"], "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d");
12499 assert_eq!(attrs["collection"], "contentId-8220672100");
12500 assert_eq!(attrs["height"], 56);
12501 assert_eq!(attrs["width"], 312);
12502 assert_eq!(attrs["alt"], "Screenshot.png");
12503 }
12504
12505 #[test]
12508 fn media_single_caption_adf_to_markdown() {
12509 let adf_doc = serde_json::json!({
12510 "type": "doc",
12511 "version": 1,
12512 "content": [{
12513 "type": "mediaSingle",
12514 "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
12515 "content": [
12516 {
12517 "type": "media",
12518 "attrs": {
12519 "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
12520 "type": "file",
12521 "collection": "contentId-123456",
12522 "width": 800,
12523 "height": 600
12524 }
12525 },
12526 {
12527 "type": "caption",
12528 "content": [{"type": "text", "text": "An image caption here"}]
12529 }
12530 ]
12531 }]
12532 });
12533 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
12534 let md = adf_to_markdown(&doc).unwrap();
12535 assert!(
12536 md.contains(":::caption"),
12537 "expected :::caption in markdown, got: {md}"
12538 );
12539 assert!(
12540 md.contains("An image caption here"),
12541 "expected caption text in markdown, got: {md}"
12542 );
12543 }
12544
12545 #[test]
12546 fn media_single_caption_markdown_to_adf() {
12547 let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nAn image caption here\n:::\n";
12548 let doc = markdown_to_adf(md).unwrap();
12549 let ms = &doc.content[0];
12550 assert_eq!(ms.node_type, "mediaSingle");
12551 let content = ms.content.as_ref().unwrap();
12552 assert_eq!(content.len(), 2, "expected media + caption children");
12553 assert_eq!(content[0].node_type, "media");
12554 assert_eq!(content[1].node_type, "caption");
12555 let caption_content = content[1].content.as_ref().unwrap();
12556 assert_eq!(
12557 caption_content[0].text.as_deref(),
12558 Some("An image caption here")
12559 );
12560 }
12561
12562 #[test]
12563 fn media_single_caption_round_trip() {
12564 let adf_doc = serde_json::json!({
12566 "type": "doc",
12567 "version": 1,
12568 "content": [{
12569 "type": "mediaSingle",
12570 "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
12571 "content": [
12572 {
12573 "type": "media",
12574 "attrs": {
12575 "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
12576 "type": "file",
12577 "collection": "contentId-123456",
12578 "width": 800,
12579 "height": 600
12580 }
12581 },
12582 {
12583 "type": "caption",
12584 "content": [{"type": "text", "text": "An image caption here"}]
12585 }
12586 ]
12587 }]
12588 });
12589 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
12590 let md = adf_to_markdown(&doc).unwrap();
12591 let doc2 = markdown_to_adf(&md).unwrap();
12592 let ms = &doc2.content[0];
12593 assert_eq!(ms.node_type, "mediaSingle");
12594 let content = ms.content.as_ref().unwrap();
12595 assert_eq!(
12596 content.len(),
12597 2,
12598 "expected media + caption after round-trip"
12599 );
12600 assert_eq!(content[1].node_type, "caption");
12601 let caption_content = content[1].content.as_ref().unwrap();
12602 assert_eq!(
12603 caption_content[0].text.as_deref(),
12604 Some("An image caption here")
12605 );
12606 }
12607
12608 #[test]
12609 fn media_single_caption_with_inline_marks() {
12610 let adf_doc = serde_json::json!({
12611 "type": "doc",
12612 "version": 1,
12613 "content": [{
12614 "type": "mediaSingle",
12615 "attrs": {"layout": "center"},
12616 "content": [
12617 {
12618 "type": "media",
12619 "attrs": {"type": "external", "url": "https://example.com/img.png"}
12620 },
12621 {
12622 "type": "caption",
12623 "content": [
12624 {"type": "text", "text": "A "},
12625 {"type": "text", "text": "bold", "marks": [{"type": "strong"}]},
12626 {"type": "text", "text": " caption"}
12627 ]
12628 }
12629 ]
12630 }]
12631 });
12632 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
12633 let md = adf_to_markdown(&doc).unwrap();
12634 assert!(
12635 md.contains("**bold**"),
12636 "expected bold in caption, got: {md}"
12637 );
12638
12639 let doc2 = markdown_to_adf(&md).unwrap();
12640 let content = doc2.content[0].content.as_ref().unwrap();
12641 assert_eq!(content.len(), 2, "expected media + caption");
12642 assert_eq!(content[1].node_type, "caption");
12643 let caption_inlines = content[1].content.as_ref().unwrap();
12644 let bold_node = caption_inlines
12645 .iter()
12646 .find(|n| n.text.as_deref() == Some("bold"))
12647 .unwrap();
12648 let marks = bold_node.marks.as_ref().unwrap();
12649 assert_eq!(marks[0].mark_type, "strong");
12650 }
12651
12652 #[test]
12653 fn media_single_no_caption_unaffected() {
12654 let adf_doc = serde_json::json!({
12656 "type": "doc",
12657 "version": 1,
12658 "content": [{
12659 "type": "mediaSingle",
12660 "attrs": {"layout": "center"},
12661 "content": [{
12662 "type": "media",
12663 "attrs": {"type": "external", "url": "https://example.com/img.png"}
12664 }]
12665 }]
12666 });
12667 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
12668 let md = adf_to_markdown(&doc).unwrap();
12669 assert!(
12670 !md.contains(":::caption"),
12671 "should not emit caption when none present"
12672 );
12673 let doc2 = markdown_to_adf(&md).unwrap();
12674 let content = doc2.content[0].content.as_ref().unwrap();
12675 assert_eq!(content.len(), 1, "should only have media child");
12676 assert_eq!(content[0].node_type, "media");
12677 }
12678
12679 #[test]
12680 fn media_single_empty_caption_round_trip() {
12681 let adf_doc = serde_json::json!({
12683 "type": "doc",
12684 "version": 1,
12685 "content": [{
12686 "type": "mediaSingle",
12687 "attrs": {"layout": "center"},
12688 "content": [
12689 {
12690 "type": "media",
12691 "attrs": {"type": "external", "url": "https://example.com/img.png"}
12692 },
12693 {
12694 "type": "caption"
12695 }
12696 ]
12697 }]
12698 });
12699 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
12700 let md = adf_to_markdown(&doc).unwrap();
12701 assert!(
12702 md.contains(":::caption"),
12703 "expected :::caption even for empty caption, got: {md}"
12704 );
12705 assert!(
12706 md.contains(":::\n"),
12707 "expected closing ::: fence, got: {md}"
12708 );
12709 }
12710
12711 #[test]
12712 fn media_single_external_caption_round_trip() {
12713 let md = "\n:::caption\nImage description\n:::\n";
12715 let doc = markdown_to_adf(md).unwrap();
12716 let ms = &doc.content[0];
12717 assert_eq!(ms.node_type, "mediaSingle");
12718 let content = ms.content.as_ref().unwrap();
12719 assert_eq!(content.len(), 2);
12720 assert_eq!(content[0].node_type, "media");
12721 assert_eq!(content[1].node_type, "caption");
12722
12723 let md2 = adf_to_markdown(&doc).unwrap();
12724 let doc2 = markdown_to_adf(&md2).unwrap();
12725 let content2 = doc2.content[0].content.as_ref().unwrap();
12726 assert_eq!(content2.len(), 2);
12727 assert_eq!(content2[1].node_type, "caption");
12728 let caption_text = content2[1].content.as_ref().unwrap();
12729 assert_eq!(caption_text[0].text.as_deref(), Some("Image description"));
12730 }
12731
12732 #[test]
12735 fn media_single_caption_localid_roundtrip() {
12736 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"}]}]}]}"#;
12737 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12738 let md = adf_to_markdown(&doc).unwrap();
12739 assert!(
12740 md.contains("localId=9da8c2104471"),
12741 "caption localId should appear in markdown: {md}"
12742 );
12743 let rt = markdown_to_adf(&md).unwrap();
12744 let content = rt.content[0].content.as_ref().unwrap();
12745 let caption = &content[1];
12746 assert_eq!(caption.node_type, "caption");
12747 assert_eq!(
12748 caption.attrs.as_ref().unwrap()["localId"],
12749 "9da8c2104471",
12750 "caption localId should round-trip"
12751 );
12752 }
12753
12754 #[test]
12755 fn media_single_caption_without_localid() {
12756 let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nPlain caption\n:::\n";
12757 let doc = markdown_to_adf(md).unwrap();
12758 let caption = &doc.content[0].content.as_ref().unwrap()[1];
12759 assert_eq!(caption.node_type, "caption");
12760 assert!(
12761 caption.attrs.is_none(),
12762 "caption without localId should not gain attrs"
12763 );
12764 let md2 = adf_to_markdown(&doc).unwrap();
12765 assert!(
12766 !md2.contains("localId"),
12767 "no localId should appear in output: {md2}"
12768 );
12769 }
12770
12771 #[test]
12772 fn media_single_caption_localid_stripped_when_option_set() {
12773 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"}]}]}]}"#;
12774 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12775 let opts = RenderOptions {
12776 strip_local_ids: true,
12777 ..Default::default()
12778 };
12779 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12780 assert!(!md.contains("localId"), "localId should be stripped: {md}");
12781 }
12782
12783 #[test]
12784 fn table_width_roundtrip() {
12785 let adf_doc = serde_json::json!({
12787 "type": "doc",
12788 "version": 1,
12789 "content": [{
12790 "type": "table",
12791 "attrs": {"layout": "default", "width": 760.0},
12792 "content": [{
12793 "type": "tableRow",
12794 "content": [{
12795 "type": "tableHeader",
12796 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
12797 }]
12798 }]
12799 }]
12800 });
12801 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
12802 let md = adf_to_markdown(&doc).unwrap();
12803 assert!(
12804 md.contains("width=760"),
12805 "expected width=760 in markdown, got: {md}"
12806 );
12807 let doc2 = markdown_to_adf(&md).unwrap();
12809 let table = &doc2.content[0];
12810 assert_eq!(table.node_type, "table");
12811 let table_attrs = table.attrs.as_ref().unwrap();
12812 assert_eq!(table_attrs["width"], 760.0);
12813 }
12814
12815 #[test]
12816 fn file_media_width_type_roundtrip() {
12817 let adf_doc = serde_json::json!({
12819 "type": "doc",
12820 "version": 1,
12821 "content": [{
12822 "type": "mediaSingle",
12823 "attrs": {"layout": "center", "width": 312, "widthType": "pixel"},
12824 "content": [{
12825 "type": "media",
12826 "attrs": {
12827 "type": "file",
12828 "id": "abc123",
12829 "collection": "contentId-999",
12830 "height": 56,
12831 "width": 312
12832 }
12833 }]
12834 }]
12835 });
12836 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
12837 let md = adf_to_markdown(&doc).unwrap();
12838 assert!(
12839 md.contains("widthType=pixel"),
12840 "expected widthType=pixel in markdown, got: {md}"
12841 );
12842 let doc2 = markdown_to_adf(&md).unwrap();
12843 let ms = &doc2.content[0];
12844 let ms_attrs = ms.attrs.as_ref().unwrap();
12845 assert_eq!(ms_attrs["widthType"], "pixel");
12846 assert_eq!(ms_attrs["width"], 312);
12847 }
12848
12849 #[test]
12850 fn file_media_mode_roundtrip() {
12851 let adf_doc = serde_json::json!({
12853 "type": "doc",
12854 "version": 1,
12855 "content": [{
12856 "type": "mediaSingle",
12857 "attrs": {"layout": "wide", "mode": "wide", "width": 1200},
12858 "content": [{
12859 "type": "media",
12860 "attrs": {
12861 "type": "file",
12862 "id": "abc123",
12863 "collection": "test",
12864 "width": 1200,
12865 "height": 600
12866 }
12867 }]
12868 }]
12869 });
12870 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
12871 let md = adf_to_markdown(&doc).unwrap();
12872 assert!(
12873 md.contains("mode=wide"),
12874 "expected mode=wide in markdown, got: {md}"
12875 );
12876 let doc2 = markdown_to_adf(&md).unwrap();
12877 let ms = &doc2.content[0];
12878 let ms_attrs = ms.attrs.as_ref().unwrap();
12879 assert_eq!(ms_attrs["mode"], "wide");
12880 assert_eq!(ms_attrs["layout"], "wide");
12881 assert_eq!(ms_attrs["width"], 1200);
12882 }
12883
12884 #[test]
12885 fn external_media_mode_roundtrip() {
12886 let adf_doc = serde_json::json!({
12888 "type": "doc",
12889 "version": 1,
12890 "content": [{
12891 "type": "mediaSingle",
12892 "attrs": {"layout": "wide", "mode": "wide"},
12893 "content": [{
12894 "type": "media",
12895 "attrs": {
12896 "type": "external",
12897 "url": "https://example.com/image.png"
12898 }
12899 }]
12900 }]
12901 });
12902 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
12903 let md = adf_to_markdown(&doc).unwrap();
12904 assert!(
12905 md.contains("mode=wide"),
12906 "expected mode=wide in markdown, got: {md}"
12907 );
12908 let doc2 = markdown_to_adf(&md).unwrap();
12909 let ms = &doc2.content[0];
12910 let ms_attrs = ms.attrs.as_ref().unwrap();
12911 assert_eq!(ms_attrs["mode"], "wide");
12912 assert_eq!(ms_attrs["layout"], "wide");
12913 }
12914
12915 #[test]
12916 fn media_mode_only_roundtrip() {
12917 let adf_doc = serde_json::json!({
12919 "type": "doc",
12920 "version": 1,
12921 "content": [{
12922 "type": "mediaSingle",
12923 "attrs": {"layout": "center", "mode": "default"},
12924 "content": [{
12925 "type": "media",
12926 "attrs": {
12927 "type": "external",
12928 "url": "https://example.com/image.png"
12929 }
12930 }]
12931 }]
12932 });
12933 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
12934 let md = adf_to_markdown(&doc).unwrap();
12935 assert!(
12936 md.contains("mode=default"),
12937 "expected mode=default in markdown, got: {md}"
12938 );
12939 let doc2 = markdown_to_adf(&md).unwrap();
12940 let ms = &doc2.content[0];
12941 let ms_attrs = ms.attrs.as_ref().unwrap();
12942 assert_eq!(ms_attrs["mode"], "default");
12943 }
12944
12945 #[test]
12946 fn file_media_hex_localid_roundtrip() {
12947 let adf_doc = serde_json::json!({
12949 "type": "doc",
12950 "version": 1,
12951 "content": [{
12952 "type": "mediaSingle",
12953 "attrs": {"layout": "wide", "width": 1200, "widthType": "pixel"},
12954 "content": [{
12955 "type": "media",
12956 "attrs": {
12957 "type": "file",
12958 "id": "eb7a9c3b-314e-4458-8200-4b22b67b122e",
12959 "collection": "contentId-123",
12960 "height": 484,
12961 "width": 915,
12962 "alt": "image.png",
12963 "localId": "0e79f58ac382"
12964 }
12965 }]
12966 }]
12967 });
12968 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
12969 let md = adf_to_markdown(&doc).unwrap();
12970 assert!(
12971 md.contains("localId=0e79f58ac382"),
12972 "expected localId=0e79f58ac382 in markdown, got: {md}"
12973 );
12974 let doc2 = markdown_to_adf(&md).unwrap();
12975 let ms = &doc2.content[0];
12976 let media = &ms.content.as_ref().unwrap()[0];
12977 let attrs = media.attrs.as_ref().unwrap();
12978 assert_eq!(attrs["localId"], "0e79f58ac382");
12979 }
12980
12981 #[test]
12982 fn file_media_uuid_localid_roundtrip() {
12983 let adf_doc = serde_json::json!({
12985 "type": "doc",
12986 "version": 1,
12987 "content": [{
12988 "type": "mediaSingle",
12989 "attrs": {"layout": "center"},
12990 "content": [{
12991 "type": "media",
12992 "attrs": {
12993 "type": "file",
12994 "id": "abc-123",
12995 "collection": "contentId-456",
12996 "height": 100,
12997 "width": 200,
12998 "localId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
12999 }
13000 }]
13001 }]
13002 });
13003 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
13004 let md = adf_to_markdown(&doc).unwrap();
13005 assert!(
13006 md.contains("localId=a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
13007 "expected UUID localId in markdown, got: {md}"
13008 );
13009 let doc2 = markdown_to_adf(&md).unwrap();
13010 let media = &doc2.content[0].content.as_ref().unwrap()[0];
13011 let attrs = media.attrs.as_ref().unwrap();
13012 assert_eq!(attrs["localId"], "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
13013 }
13014
13015 #[test]
13016 fn file_media_null_uuid_localid_stripped() {
13017 let adf_doc = serde_json::json!({
13019 "type": "doc",
13020 "version": 1,
13021 "content": [{
13022 "type": "mediaSingle",
13023 "attrs": {"layout": "center"},
13024 "content": [{
13025 "type": "media",
13026 "attrs": {
13027 "type": "file",
13028 "id": "abc-123",
13029 "collection": "contentId-456",
13030 "height": 100,
13031 "width": 200,
13032 "localId": "00000000-0000-0000-0000-000000000000"
13033 }
13034 }]
13035 }]
13036 });
13037 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
13038 let md = adf_to_markdown(&doc).unwrap();
13039 assert!(
13040 !md.contains("localId="),
13041 "null UUID localId should be stripped, got: {md}"
13042 );
13043 }
13044
13045 #[test]
13046 fn file_media_localid_stripped_when_option_set() {
13047 let adf_doc = serde_json::json!({
13049 "type": "doc",
13050 "version": 1,
13051 "content": [{
13052 "type": "mediaSingle",
13053 "attrs": {"layout": "center"},
13054 "content": [{
13055 "type": "media",
13056 "attrs": {
13057 "type": "file",
13058 "id": "abc-123",
13059 "collection": "contentId-456",
13060 "height": 100,
13061 "width": 200,
13062 "localId": "0e79f58ac382"
13063 }
13064 }]
13065 }]
13066 });
13067 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
13068 let opts = RenderOptions {
13069 strip_local_ids: true,
13070 ..Default::default()
13071 };
13072 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13073 assert!(
13074 !md.contains("localId="),
13075 "localId should be stripped with strip_local_ids, got: {md}"
13076 );
13077 }
13078
13079 #[test]
13080 fn external_media_localid_roundtrip() {
13081 let adf_doc = serde_json::json!({
13083 "type": "doc",
13084 "version": 1,
13085 "content": [{
13086 "type": "mediaSingle",
13087 "attrs": {"layout": "center"},
13088 "content": [{
13089 "type": "media",
13090 "attrs": {
13091 "type": "external",
13092 "url": "https://example.com/image.png",
13093 "alt": "test",
13094 "localId": "deadbeef1234"
13095 }
13096 }]
13097 }]
13098 });
13099 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
13100 let md = adf_to_markdown(&doc).unwrap();
13101 assert!(
13102 md.contains("localId=deadbeef1234"),
13103 "expected localId in markdown for external media, got: {md}"
13104 );
13105 let doc2 = markdown_to_adf(&md).unwrap();
13106 let media = &doc2.content[0].content.as_ref().unwrap()[0];
13107 let attrs = media.attrs.as_ref().unwrap();
13108 assert_eq!(attrs["localId"], "deadbeef1234");
13109 }
13110
13111 #[test]
13112 fn bracket_in_text_not_parsed_as_link() {
13113 let md = ":check_mark: [Task] Unable to start trial ([Link](https://example.com/link))";
13115 let doc = markdown_to_adf(md).unwrap();
13116 let para = &doc.content[0];
13117 assert_eq!(para.node_type, "paragraph");
13118 let content = para.content.as_ref().unwrap();
13119 let text_nodes: Vec<_> = content.iter().filter(|n| n.node_type == "text").collect();
13121 let has_task_bracket = text_nodes
13122 .iter()
13123 .any(|n| n.text.as_deref().unwrap_or("").contains("[Task]"));
13124 assert!(
13125 has_task_bracket,
13126 "expected [Task] in plain text, nodes: {content:?}"
13127 );
13128 let link_nodes: Vec<_> = content
13130 .iter()
13131 .filter(|n| {
13132 n.marks
13133 .as_ref()
13134 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
13135 })
13136 .collect();
13137 assert!(!link_nodes.is_empty(), "expected a link node");
13138 assert_eq!(
13139 link_nodes[0].text.as_deref(),
13140 Some("Link"),
13141 "link text should be 'Link'"
13142 );
13143 }
13144
13145 #[test]
13146 fn empty_paragraph_roundtrip() {
13147 let mut adf_in = AdfDocument::new();
13149 adf_in.content = vec![
13150 AdfNode::paragraph(vec![AdfNode::text("before")]),
13151 AdfNode::paragraph(vec![]),
13152 AdfNode::paragraph(vec![AdfNode::text("after")]),
13153 ];
13154 let md = adf_to_markdown(&adf_in).unwrap();
13155 let adf_out = markdown_to_adf(&md).unwrap();
13156 assert_eq!(
13157 adf_out.content.len(),
13158 3,
13159 "should have 3 blocks, markdown:\n{md}"
13160 );
13161 assert_eq!(adf_out.content[0].node_type, "paragraph");
13162 assert_eq!(adf_out.content[1].node_type, "paragraph");
13163 assert!(
13164 adf_out.content[1].content.is_none(),
13165 "middle paragraph should be empty"
13166 );
13167 assert_eq!(adf_out.content[2].node_type, "paragraph");
13168 }
13169
13170 #[test]
13171 fn nbsp_paragraph_roundtrip() {
13172 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}";
13174 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13175 let md = adf_to_markdown(&doc).unwrap();
13176 assert!(
13177 md.contains("::paragraph["),
13178 "NBSP paragraph should use directive form: {md}"
13179 );
13180 let rt = markdown_to_adf(&md).unwrap();
13181 assert_eq!(rt.content.len(), 1, "should have 1 block");
13182 assert_eq!(rt.content[0].node_type, "paragraph");
13183 let text = rt.content[0].content.as_ref().unwrap()[0]
13184 .text
13185 .as_deref()
13186 .unwrap_or("");
13187 assert_eq!(text, "\u{00a0}", "NBSP should survive round-trip");
13188 }
13189
13190 #[test]
13191 fn nbsp_in_nested_expand_roundtrip() {
13192 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"nestedExpand\",\"attrs\":{\"title\":\"Section\"},\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}]}";
13194 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13195 let md = adf_to_markdown(&doc).unwrap();
13196 let rt = markdown_to_adf(&md).unwrap();
13197 let ne = &rt.content[0];
13198 assert_eq!(ne.node_type, "nestedExpand");
13199 let inner = ne.content.as_ref().unwrap();
13200 assert_eq!(inner.len(), 1, "should have 1 inner block");
13201 assert_eq!(inner[0].node_type, "paragraph");
13202 let content = inner[0].content.as_ref().unwrap();
13203 assert!(!content.is_empty(), "paragraph should not be empty");
13204 let text = content[0].text.as_deref().unwrap_or("");
13205 assert_eq!(text, "\u{00a0}", "NBSP should survive in nestedExpand");
13206 }
13207
13208 #[test]
13209 fn nbsp_followed_by_content() {
13210 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\"}]}]}";
13212 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13213 let md = adf_to_markdown(&doc).unwrap();
13214 let rt = markdown_to_adf(&md).unwrap();
13215 assert!(rt.content.len() >= 2, "should have at least 2 blocks");
13216 let after_para = rt.content.iter().find(|n| {
13218 n.node_type == "paragraph"
13219 && n.content
13220 .as_ref()
13221 .and_then(|c| c.first())
13222 .and_then(|n| n.text.as_deref())
13223 .map_or(false, |t| t.contains("after"))
13224 });
13225 assert!(after_para.is_some(), "should have paragraph with 'after'");
13226 }
13227
13228 #[test]
13229 fn nbsp_paragraph_with_marks_survives() {
13230 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\",\"marks\":[{\"type\":\"strong\"}]}]}]}";
13233 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13234 let md = adf_to_markdown(&doc).unwrap();
13235 assert!(md.contains("**"), "should have bold markers: {md}");
13236 let rt = markdown_to_adf(&md).unwrap();
13237 let content = rt.content[0].content.as_ref().unwrap();
13238 assert!(!content.is_empty(), "should preserve content");
13239 }
13240
13241 #[test]
13242 fn regular_paragraph_unchanged() {
13243 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}"#;
13245 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13246 let md = adf_to_markdown(&doc).unwrap();
13247 assert!(
13248 !md.contains("::paragraph"),
13249 "regular paragraphs should not use directive form: {md}"
13250 );
13251 assert!(md.contains("hello"));
13252 }
13253
13254 #[test]
13255 fn paragraph_directive_with_content_parsed() {
13256 let md = "::paragraph[\u{00a0}]\n";
13258 let doc = markdown_to_adf(md).unwrap();
13259 assert_eq!(doc.content.len(), 1);
13260 assert_eq!(doc.content[0].node_type, "paragraph");
13261 let content = doc.content[0].content.as_ref().unwrap();
13262 assert!(!content.is_empty(), "should have inline content");
13263 assert_eq!(content[0].text.as_deref().unwrap(), "\u{00a0}");
13264 }
13265
13266 #[test]
13267 fn nbsp_paragraph_in_list_item_with_nested_list() {
13268 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"}]}]}]}]}]}]}"#;
13270 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13271 let md = adf_to_markdown(&doc).unwrap();
13272 let rt = markdown_to_adf(&md).unwrap();
13273 let list = &rt.content[0];
13274 assert_eq!(list.node_type, "bulletList");
13275 let item = &list.content.as_ref().unwrap()[0];
13276 let item_content = item.content.as_ref().unwrap();
13277 assert_eq!(
13278 item_content.len(),
13279 2,
13280 "listItem should have paragraph + nested list, got: {item_content:?}"
13281 );
13282 let para = &item_content[0];
13283 assert_eq!(para.node_type, "paragraph");
13284 let para_content = para
13285 .content
13286 .as_ref()
13287 .expect("paragraph should have content");
13288 assert!(
13289 !para_content.is_empty(),
13290 "NBSP paragraph content should not be empty"
13291 );
13292 assert_eq!(
13293 para_content[0].text.as_deref().unwrap(),
13294 "\u{00a0}",
13295 "NBSP should survive round-trip inside listItem"
13296 );
13297 }
13298
13299 #[test]
13300 fn nbsp_paragraph_in_list_item_with_local_ids() {
13301 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"}]}]}]}]}]}]}"#;
13303 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13304 let md = adf_to_markdown(&doc).unwrap();
13305 let rt = markdown_to_adf(&md).unwrap();
13306 let list = &rt.content[0];
13307 let item = &list.content.as_ref().unwrap()[0];
13308 assert_eq!(
13310 item.attrs.as_ref().unwrap()["localId"],
13311 "li-001",
13312 "listItem localId should survive"
13313 );
13314 let item_content = item.content.as_ref().unwrap();
13315 assert_eq!(item_content.len(), 2);
13316 let para = &item_content[0];
13318 assert_eq!(
13319 para.attrs.as_ref().unwrap()["localId"],
13320 "p-001",
13321 "paragraph localId should survive"
13322 );
13323 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
13324 assert_eq!(text, "\u{00a0}", "NBSP should survive with localIds");
13325 }
13326
13327 #[test]
13328 fn nbsp_paragraph_in_list_item_without_nested_list() {
13329 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"}]}]}]}]}"#;
13331 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13332 let md = adf_to_markdown(&doc).unwrap();
13333 let rt = markdown_to_adf(&md).unwrap();
13334 let list = &rt.content[0];
13335 let item = &list.content.as_ref().unwrap()[0];
13336 let para = &item.content.as_ref().unwrap()[0];
13337 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
13338 assert_eq!(text, "\u{00a0}", "NBSP should survive in simple list item");
13339 }
13340
13341 #[test]
13342 fn nbsp_paragraph_in_ordered_list_item_with_nested_list() {
13343 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"}]}]}]}]}]}]}"#;
13345 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13346 let md = adf_to_markdown(&doc).unwrap();
13347 let rt = markdown_to_adf(&md).unwrap();
13348 let list = &rt.content[0];
13349 let item = &list.content.as_ref().unwrap()[0];
13350 let item_content = item.content.as_ref().unwrap();
13351 assert_eq!(item_content.len(), 2);
13352 let para = &item_content[0];
13353 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
13354 assert_eq!(text, "\u{00a0}", "NBSP should survive in ordered list item");
13355 }
13356
13357 #[test]
13358 fn list_item_leading_space_preserved() {
13359 let md = "- hello world\n- - text";
13361 let doc = markdown_to_adf(md).unwrap();
13362 let list = &doc.content[0];
13363 assert_eq!(list.node_type, "bulletList");
13364 let items = list.content.as_ref().unwrap();
13365 let first_para = &items[0].content.as_ref().unwrap()[0];
13367 let first_text = &first_para.content.as_ref().unwrap()[0];
13368 assert_eq!(first_text.text.as_deref(), Some("hello world"));
13369 }
13370
13371 #[test]
13372 fn list_item_leading_space_not_stripped() {
13373 let md = "- leading space text";
13376 let doc = markdown_to_adf(md).unwrap();
13377 let list = &doc.content[0];
13378 let items = list.content.as_ref().unwrap();
13379 let para = &items[0].content.as_ref().unwrap()[0];
13380 let text_node = ¶.content.as_ref().unwrap()[0];
13381 assert_eq!(
13383 text_node.text.as_deref(),
13384 Some(" leading space text"),
13385 "leading space should be preserved"
13386 );
13387 }
13388
13389 #[test]
13394 fn hardbreak_in_cell_uses_directive_table() {
13395 let adf = AdfDocument {
13398 version: 1,
13399 doc_type: "doc".to_string(),
13400 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
13401 AdfNode::table_cell(vec![AdfNode::paragraph(vec![
13402 AdfNode::text("before"),
13403 AdfNode::hard_break(),
13404 AdfNode::text("after"),
13405 ])]),
13406 ])])],
13407 };
13408 let md = adf_to_markdown(&adf).unwrap();
13409 assert!(
13411 md.contains(":::td") || md.contains("::::table"),
13412 "Table with hardBreak should use directive form, got:\n{md}"
13413 );
13414 assert!(
13415 !md.contains("| before"),
13416 "Should NOT use pipe syntax with hardBreak"
13417 );
13418 }
13419
13420 #[test]
13421 fn hardbreak_in_cell_roundtrips() {
13422 let adf = AdfDocument {
13424 version: 1,
13425 doc_type: "doc".to_string(),
13426 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
13427 AdfNode::table_cell(vec![AdfNode::paragraph(vec![
13428 AdfNode::text("line one"),
13429 AdfNode::hard_break(),
13430 AdfNode::text("line two"),
13431 ])]),
13432 ])])],
13433 };
13434 let md = adf_to_markdown(&adf).unwrap();
13435 let roundtripped = markdown_to_adf(&md).unwrap();
13436
13437 assert_eq!(roundtripped.content.len(), 1);
13439 assert_eq!(roundtripped.content[0].node_type, "table");
13440 let rows = roundtripped.content[0].content.as_ref().unwrap();
13441 assert_eq!(
13442 rows.len(),
13443 1,
13444 "Should have exactly 1 row, got {}",
13445 rows.len()
13446 );
13447 }
13448
13449 #[test]
13450 fn hardbreak_in_paragraph_roundtrips() {
13451 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13453 {"type":"text","text":"line one"},
13454 {"type":"hardBreak"},
13455 {"type":"text","text":"line two"}
13456 ]}]}"#;
13457 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13458 let md = adf_to_markdown(&doc).unwrap();
13459 let round_tripped = markdown_to_adf(&md).unwrap();
13460 let inlines = round_tripped.content[0].content.as_ref().unwrap();
13461 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
13462 assert_eq!(
13463 types,
13464 vec!["text", "hardBreak", "text"],
13465 "hardBreak should be preserved, got: {types:?}"
13466 );
13467 assert_eq!(inlines[0].text.as_deref(), Some("line one"));
13468 assert_eq!(inlines[2].text.as_deref(), Some("line two"));
13469 }
13470
13471 #[test]
13472 fn consecutive_hardbreaks_in_paragraph_roundtrip() {
13473 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13475 {"type":"text","text":"before"},
13476 {"type":"hardBreak"},
13477 {"type":"hardBreak"},
13478 {"type":"text","text":"after"}
13479 ]}]}"#;
13480 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13481 let md = adf_to_markdown(&doc).unwrap();
13482 let round_tripped = markdown_to_adf(&md).unwrap();
13483 assert_eq!(
13484 round_tripped.content.len(),
13485 1,
13486 "Should remain a single paragraph, got {} blocks",
13487 round_tripped.content.len()
13488 );
13489 let inlines = round_tripped.content[0].content.as_ref().unwrap();
13490 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
13491 assert_eq!(
13492 types,
13493 vec!["text", "hardBreak", "hardBreak", "text"],
13494 "Both hardBreaks should be preserved, got: {types:?}"
13495 );
13496 assert_eq!(inlines[0].text.as_deref(), Some("before"));
13497 assert_eq!(inlines[3].text.as_deref(), Some("after"));
13498 }
13499
13500 #[test]
13501 fn hardbreak_only_paragraph_roundtrips() {
13502 let adf_json = r#"{"version":1,"type":"doc","content":[
13504 {"type":"paragraph","content":[{"type":"hardBreak"}]}
13505 ]}"#;
13506 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13507 let md = adf_to_markdown(&doc).unwrap();
13508 let round_tripped = markdown_to_adf(&md).unwrap();
13509 assert_eq!(
13510 round_tripped.content.len(),
13511 1,
13512 "Paragraph should not be dropped, got {} blocks",
13513 round_tripped.content.len()
13514 );
13515 let inlines = round_tripped.content[0].content.as_ref().unwrap();
13516 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
13517 assert_eq!(
13518 types,
13519 vec!["hardBreak"],
13520 "hardBreak-only paragraph should preserve its content, got: {types:?}"
13521 );
13522 }
13523
13524 #[test]
13525 fn issue_410_full_reproducer_roundtrips() {
13526 let adf_json = r#"{"version":1,"type":"doc","content":[
13528 {"type":"paragraph","content":[
13529 {"type":"text","text":"before"},
13530 {"type":"hardBreak"},
13531 {"type":"hardBreak"},
13532 {"type":"text","text":"after"}
13533 ]},
13534 {"type":"paragraph","content":[
13535 {"type":"hardBreak"}
13536 ]}
13537 ]}"#;
13538 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13539 let md = adf_to_markdown(&doc).unwrap();
13540 let round_tripped = markdown_to_adf(&md).unwrap();
13541 assert_eq!(
13542 round_tripped.content.len(),
13543 2,
13544 "Should have exactly 2 paragraphs, got {}",
13545 round_tripped.content.len()
13546 );
13547 let p1 = round_tripped.content[0].content.as_ref().unwrap();
13549 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
13550 assert_eq!(types1, vec!["text", "hardBreak", "hardBreak", "text"]);
13551 let p2 = round_tripped.content[1].content.as_ref().unwrap();
13553 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
13554 assert_eq!(types2, vec!["hardBreak"]);
13555 }
13556
13557 #[test]
13558 fn trailing_space_hardbreak_still_parsed() {
13559 let md = "line one \nline two\n";
13561 let doc = markdown_to_adf(md).unwrap();
13562 let inlines = doc.content[0].content.as_ref().unwrap();
13563 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
13564 assert_eq!(
13565 types,
13566 vec!["text", "hardBreak", "text"],
13567 "Trailing-space hardBreak should still parse, got: {types:?}"
13568 );
13569 }
13570
13571 #[test]
13572 fn trailing_hardbreak_at_end_of_paragraph_roundtrips() {
13573 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13575 {"type":"text","text":"text"},
13576 {"type":"hardBreak"}
13577 ]}]}"#;
13578 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13579 let md = adf_to_markdown(&doc).unwrap();
13580 let round_tripped = markdown_to_adf(&md).unwrap();
13581 let inlines = round_tripped.content[0].content.as_ref().unwrap();
13582 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
13583 assert_eq!(
13584 types,
13585 vec!["text", "hardBreak"],
13586 "Trailing hardBreak should be preserved, got: {types:?}"
13587 );
13588 }
13589
13590 #[test]
13591 #[test]
13592 fn table_with_header_row_uses_pipe_syntax() {
13593 let adf = AdfDocument {
13595 version: 1,
13596 doc_type: "doc".to_string(),
13597 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
13598 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("header cell")])]),
13599 ])])],
13600 };
13601 let md = adf_to_markdown(&adf).unwrap();
13602 assert!(
13603 md.contains("| header cell |"),
13604 "Table with header row should use pipe syntax, got:\n{md}"
13605 );
13606 }
13607
13608 #[test]
13609 fn table_without_header_row_uses_directive_syntax() {
13610 let adf = AdfDocument {
13613 version: 1,
13614 doc_type: "doc".to_string(),
13615 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
13616 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("simple cell")])]),
13617 ])])],
13618 };
13619 let md = adf_to_markdown(&adf).unwrap();
13620 assert!(
13621 md.contains("::::table"),
13622 "Table without header row should use directive syntax, got:\n{md}"
13623 );
13624 }
13625
13626 #[test]
13627 fn tablecell_first_row_preserved_on_roundtrip() {
13628 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[
13630 {"type":"tableRow","content":[
13631 {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row1 cell"}]}]}
13632 ]},
13633 {"type":"tableRow","content":[
13634 {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row2 cell"}]}]}
13635 ]}
13636 ]}]}"#;
13637 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13638 let md = adf_to_markdown(&doc).unwrap();
13639 let round_tripped = markdown_to_adf(&md).unwrap();
13640 let rows = round_tripped.content[0].content.as_ref().unwrap();
13641 let row0_cell = &rows[0].content.as_ref().unwrap()[0];
13642 assert_eq!(
13643 row0_cell.node_type, "tableCell",
13644 "first row cell should remain tableCell, got: {}",
13645 row0_cell.node_type
13646 );
13647 let row1_cell = &rows[1].content.as_ref().unwrap()[0];
13648 assert_eq!(row1_cell.node_type, "tableCell");
13649 }
13650
13651 #[test]
13652 fn mixed_header_and_cell_first_row_uses_pipe() {
13653 let adf = AdfDocument {
13655 version: 1,
13656 doc_type: "doc".to_string(),
13657 content: vec![AdfNode::table(vec![
13658 AdfNode::table_row(vec![
13659 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
13660 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
13661 ]),
13662 AdfNode::table_row(vec![
13663 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
13664 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
13665 ]),
13666 ])],
13667 };
13668 let md = adf_to_markdown(&adf).unwrap();
13669 assert!(
13670 md.contains("| H1 |"),
13671 "Table with header first row should use pipe syntax, got:\n{md}"
13672 );
13673 assert!(!md.contains("::::table"), "should not use directive syntax");
13674 }
13675
13676 #[test]
13677 fn cell_contains_hard_break_true() {
13678 let para = AdfNode::paragraph(vec![
13679 AdfNode::text("a"),
13680 AdfNode::hard_break(),
13681 AdfNode::text("b"),
13682 ]);
13683 assert!(cell_contains_hard_break(¶));
13684 }
13685
13686 #[test]
13687 fn cell_contains_hard_break_false() {
13688 let para = AdfNode::paragraph(vec![AdfNode::text("no break here")]);
13689 assert!(!cell_contains_hard_break(¶));
13690 }
13691
13692 #[test]
13693 fn cell_contains_hard_break_empty() {
13694 let para = AdfNode::paragraph(vec![]);
13695 assert!(!cell_contains_hard_break(¶));
13696 }
13697
13698 #[test]
13701 fn multi_paragraph_panel_roundtrips() {
13702 let adf = AdfDocument {
13703 version: 1,
13704 doc_type: "doc".to_string(),
13705 content: vec![AdfNode {
13706 node_type: "panel".to_string(),
13707 attrs: Some(serde_json::json!({"panelType": "info"})),
13708 content: Some(vec![
13709 AdfNode::paragraph(vec![AdfNode::text("First paragraph.")]),
13710 AdfNode::paragraph(vec![AdfNode::text("Second paragraph.")]),
13711 ]),
13712 text: None,
13713 marks: None,
13714 local_id: None,
13715 parameters: None,
13716 }],
13717 };
13718
13719 let md = adf_to_markdown(&adf).unwrap();
13720 assert!(
13722 md.contains("First paragraph.\n\nSecond paragraph."),
13723 "Panel should have blank line between paragraphs, got:\n{md}"
13724 );
13725
13726 let roundtripped = markdown_to_adf(&md).unwrap();
13728 assert_eq!(roundtripped.content.len(), 1);
13729 assert_eq!(roundtripped.content[0].node_type, "panel");
13730 let panel_content = roundtripped.content[0].content.as_ref().unwrap();
13731 assert_eq!(
13732 panel_content.len(),
13733 2,
13734 "Panel should have 2 paragraphs after round-trip, got {}",
13735 panel_content.len()
13736 );
13737 }
13738
13739 #[test]
13740 fn multi_paragraph_expand_roundtrips() {
13741 let adf = AdfDocument {
13742 version: 1,
13743 doc_type: "doc".to_string(),
13744 content: vec![AdfNode {
13745 node_type: "expand".to_string(),
13746 attrs: Some(serde_json::json!({"title": "Details"})),
13747 content: Some(vec![
13748 AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
13749 AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
13750 ]),
13751 text: None,
13752 marks: None,
13753 local_id: None,
13754 parameters: None,
13755 }],
13756 };
13757
13758 let md = adf_to_markdown(&adf).unwrap();
13759 let roundtripped = markdown_to_adf(&md).unwrap();
13760 let expand_content = roundtripped.content[0].content.as_ref().unwrap();
13761 assert_eq!(
13762 expand_content.len(),
13763 2,
13764 "Expand should have 2 paragraphs after round-trip, got {}",
13765 expand_content.len()
13766 );
13767 }
13768
13769 #[test]
13770 fn consecutive_nested_expands_in_table_cell_roundtrip() {
13771 let cell_content = vec![
13772 AdfNode {
13773 node_type: "nestedExpand".to_string(),
13774 attrs: Some(serde_json::json!({"title": "First"})),
13775 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 1")])]),
13776 text: None,
13777 marks: None,
13778 local_id: None,
13779 parameters: None,
13780 },
13781 AdfNode {
13782 node_type: "nestedExpand".to_string(),
13783 attrs: Some(serde_json::json!({"title": "Second"})),
13784 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 2")])]),
13785 text: None,
13786 marks: None,
13787 local_id: None,
13788 parameters: None,
13789 },
13790 ];
13791 let adf = AdfDocument {
13792 version: 1,
13793 doc_type: "doc".to_string(),
13794 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
13795 AdfNode::table_cell(cell_content),
13796 ])])],
13797 };
13798
13799 let md = adf_to_markdown(&adf).unwrap();
13800 assert!(
13801 md.contains(":::\n\n:::nested-expand"),
13802 "Should have blank line between consecutive nested-expands in cell, got:\n{md}"
13803 );
13804
13805 let rt = markdown_to_adf(&md).unwrap();
13806 let cell = &rt.content[0].content.as_ref().unwrap()[0]
13807 .content
13808 .as_ref()
13809 .unwrap()[0];
13810 let cell_nodes = cell.content.as_ref().unwrap();
13811 let expand_count = cell_nodes
13812 .iter()
13813 .filter(|n| n.node_type == "nestedExpand")
13814 .count();
13815 assert_eq!(
13816 expand_count, 2,
13817 "Both nested-expands should survive round-trip, got {expand_count}"
13818 );
13819 }
13820
13821 #[test]
13822 fn multi_paragraph_in_table_cell_roundtrip() {
13823 let adf = AdfDocument {
13825 version: 1,
13826 doc_type: "doc".to_string(),
13827 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
13828 AdfNode::table_cell(vec![
13829 AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
13830 AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
13831 ]),
13832 ])])],
13833 };
13834
13835 let md = adf_to_markdown(&adf).unwrap();
13836 assert!(
13837 md.contains("Para one.\n\nPara two."),
13838 "Should have blank line between paragraphs in cell, got:\n{md}"
13839 );
13840
13841 let rt = markdown_to_adf(&md).unwrap();
13842 let cell = &rt.content[0].content.as_ref().unwrap()[0]
13843 .content
13844 .as_ref()
13845 .unwrap()[0];
13846 let para_count = cell
13847 .content
13848 .as_ref()
13849 .unwrap()
13850 .iter()
13851 .filter(|n| n.node_type == "paragraph")
13852 .count();
13853 assert_eq!(para_count, 2, "Both paragraphs should survive round-trip");
13854 }
13855
13856 #[test]
13857 fn panel_inside_table_cell_roundtrip() {
13858 let adf = AdfDocument {
13860 version: 1,
13861 doc_type: "doc".to_string(),
13862 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
13863 AdfNode::table_cell(vec![
13864 AdfNode::paragraph(vec![AdfNode::text("Before panel.")]),
13865 AdfNode {
13866 node_type: "panel".to_string(),
13867 attrs: Some(serde_json::json!({"panelType": "info"})),
13868 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text(
13869 "Panel content",
13870 )])]),
13871 text: None,
13872 marks: None,
13873 local_id: None,
13874 parameters: None,
13875 },
13876 ]),
13877 ])])],
13878 };
13879
13880 let md = adf_to_markdown(&adf).unwrap();
13881 assert!(
13882 md.contains(":::panel"),
13883 "Should contain panel directive, got:\n{md}"
13884 );
13885
13886 let rt = markdown_to_adf(&md).unwrap();
13887 let cell = &rt.content[0].content.as_ref().unwrap()[0]
13888 .content
13889 .as_ref()
13890 .unwrap()[0];
13891 let has_panel = cell
13892 .content
13893 .as_ref()
13894 .unwrap()
13895 .iter()
13896 .any(|n| n.node_type == "panel");
13897 assert!(has_panel, "Panel should survive round-trip in table cell");
13898 }
13899
13900 #[test]
13901 fn three_consecutive_expands_in_table_cell() {
13902 let make_expand = |title: &str| AdfNode {
13903 node_type: "nestedExpand".to_string(),
13904 attrs: Some(serde_json::json!({"title": title})),
13905 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("content")])]),
13906 text: None,
13907 marks: None,
13908 local_id: None,
13909 parameters: None,
13910 };
13911 let adf = AdfDocument {
13912 version: 1,
13913 doc_type: "doc".to_string(),
13914 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
13915 AdfNode::table_cell(vec![
13916 make_expand("First"),
13917 make_expand("Second"),
13918 make_expand("Third"),
13919 ]),
13920 ])])],
13921 };
13922
13923 let md = adf_to_markdown(&adf).unwrap();
13924 let rt = markdown_to_adf(&md).unwrap();
13925 let cell = &rt.content[0].content.as_ref().unwrap()[0]
13926 .content
13927 .as_ref()
13928 .unwrap()[0];
13929 let expand_count = cell
13930 .content
13931 .as_ref()
13932 .unwrap()
13933 .iter()
13934 .filter(|n| n.node_type == "nestedExpand")
13935 .count();
13936 assert_eq!(expand_count, 3, "All 3 expands should survive round-trip");
13937 }
13938
13939 #[test]
13942 fn nested_expand_inside_panel() {
13943 let md = ":::panel{type=info}\n:::expand{title=\"Details\"}\nHidden content\n:::\nMore panel content\n:::";
13944 let adf = markdown_to_adf(md).unwrap();
13945
13946 assert_eq!(adf.content.len(), 1);
13948 assert_eq!(adf.content[0].node_type, "panel");
13949
13950 let panel_content = adf.content[0].content.as_ref().unwrap();
13952 assert!(
13953 panel_content.len() >= 2,
13954 "Panel should contain expand + paragraph, got {} nodes",
13955 panel_content.len()
13956 );
13957 }
13958
13959 #[test]
13960 fn nested_expand_inside_table_cell() {
13961 let md = "::::table\n:::tr\n:::td\n:::expand{title=\"Details\"}\nExpand content\n:::\n:::\n:::\n::::";
13962 let adf = markdown_to_adf(md).unwrap();
13963
13964 assert_eq!(adf.content.len(), 1);
13966 assert_eq!(adf.content[0].node_type, "table");
13967
13968 let rows = adf.content[0].content.as_ref().unwrap();
13970 assert_eq!(rows.len(), 1);
13971 let cells = rows[0].content.as_ref().unwrap();
13972 assert_eq!(cells.len(), 1);
13973 let cell_content = cells[0].content.as_ref().unwrap();
13974 assert!(
13975 cell_content.iter().any(|n| n.node_type == "expand"),
13976 "Cell should contain an expand node, got: {:?}",
13977 cell_content
13978 .iter()
13979 .map(|n| &n.node_type)
13980 .collect::<Vec<_>>()
13981 );
13982 }
13983
13984 #[test]
13985 fn nested_expand_inside_layout_column() {
13986 let md = ":::layout\n:::column{width=100}\n:::expand{title=\"Col Expand\"}\nExpanded\n:::\n:::\n:::";
13987 let adf = markdown_to_adf(md).unwrap();
13988
13989 assert_eq!(adf.content.len(), 1);
13990 assert_eq!(adf.content[0].node_type, "layoutSection");
13991
13992 let columns = adf.content[0].content.as_ref().unwrap();
13993 assert_eq!(columns.len(), 1);
13994 let col_content = columns[0].content.as_ref().unwrap();
13995 assert!(
13996 col_content.iter().any(|n| n.node_type == "expand"),
13997 "Column should contain an expand node, got: {:?}",
13998 col_content.iter().map(|n| &n.node_type).collect::<Vec<_>>()
13999 );
14000 }
14001
14002 #[test]
14003 fn expand_localid_in_directive_attrs() {
14004 let adf_json = r#"{"version":1,"type":"doc","content":[
14006 {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
14007 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
14008 ]}
14009 ]}"#;
14010 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14011 let md = adf_to_markdown(&doc).unwrap();
14012 assert!(
14013 md.contains("localId=exp-001"),
14014 "should contain localId: {md}"
14015 );
14016 assert!(
14017 md.contains(":::expand{"),
14018 "should have expand directive with attrs: {md}"
14019 );
14020 assert!(
14021 !md.contains(":::\n{localId="),
14022 "localId should NOT be trailing: {md}"
14023 );
14024 }
14025
14026 #[test]
14027 fn expand_localid_roundtrip() {
14028 let adf_json = r#"{"version":1,"type":"doc","content":[
14029 {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
14030 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
14031 ]}
14032 ]}"#;
14033 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14034 let md = adf_to_markdown(&doc).unwrap();
14035 let rt = markdown_to_adf(&md).unwrap();
14036 let expand = &rt.content[0];
14037 assert_eq!(expand.node_type, "expand");
14038 assert_eq!(
14039 expand.local_id.as_deref(),
14040 Some("exp-001"),
14041 "expand localId should survive round-trip"
14042 );
14043 assert_eq!(
14044 expand.attrs.as_ref().unwrap()["title"],
14045 "Details",
14046 "expand title should survive round-trip"
14047 );
14048 }
14049
14050 #[test]
14051 fn nested_expand_localid_roundtrip() {
14052 let adf_json = r#"{"version":1,"type":"doc","content":[
14053 {"type":"nestedExpand","attrs":{"localId":"ne-001","title":"S"},"content":[
14054 {"type":"paragraph","content":[{"type":"text","text":"content"}]}
14055 ]}
14056 ]}"#;
14057 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14058 let md = adf_to_markdown(&doc).unwrap();
14059 assert!(
14060 md.contains(":::nested-expand{"),
14061 "should have directive: {md}"
14062 );
14063 assert!(md.contains("localId=ne-001"), "should have localId: {md}");
14064 let rt = markdown_to_adf(&md).unwrap();
14065 let ne = &rt.content[0];
14066 assert_eq!(ne.node_type, "nestedExpand");
14067 assert_eq!(ne.local_id.as_deref(), Some("ne-001"));
14068 }
14069
14070 #[test]
14071 fn nested_expand_localid_followed_by_content() {
14072 let adf_json = "{\
14074 \"version\":1,\"type\":\"doc\",\"content\":[\
14075 {\"type\":\"nestedExpand\",\"attrs\":{\"localId\":\"exp-001\",\"title\":\"S\"},\"content\":[\
14076 {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}\
14077 ]},\
14078 {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]}\
14079 ]}";
14080 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14081 let md = adf_to_markdown(&doc).unwrap();
14082 let rt = markdown_to_adf(&md).unwrap();
14083 let ne = &rt.content[0];
14085 assert_eq!(ne.node_type, "nestedExpand");
14086 assert_eq!(
14087 ne.local_id.as_deref(),
14088 Some("exp-001"),
14089 "nestedExpand should preserve localId"
14090 );
14091 let para = &rt.content[1];
14093 assert_eq!(para.node_type, "paragraph");
14094 let text = para.content.as_ref().unwrap()[0]
14095 .text
14096 .as_deref()
14097 .unwrap_or("");
14098 assert!(
14099 !text.contains("localId"),
14100 "following paragraph should not contain localId: {text}"
14101 );
14102 assert!(
14103 text.contains("after"),
14104 "following paragraph should contain 'after': {text}"
14105 );
14106 }
14107
14108 #[test]
14109 fn expand_localid_without_title() {
14110 let adf_json = r#"{"version":1,"type":"doc","content":[
14111 {"type":"expand","attrs":{"localId":"exp-002"},"content":[
14112 {"type":"paragraph","content":[{"type":"text","text":"no title"}]}
14113 ]}
14114 ]}"#;
14115 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14116 let md = adf_to_markdown(&doc).unwrap();
14117 assert!(
14118 md.contains(":::expand{localId=exp-002}"),
14119 "should have localId without title: {md}"
14120 );
14121 let rt = markdown_to_adf(&md).unwrap();
14122 assert_eq!(rt.content[0].local_id.as_deref(), Some("exp-002"));
14123 }
14124
14125 #[test]
14126 fn expand_localid_stripped() {
14127 let adf_json = r#"{"version":1,"type":"doc","content":[
14128 {"type":"expand","attrs":{"localId":"exp-001","title":"X"},"content":[
14129 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
14130 ]}
14131 ]}"#;
14132 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14133 let opts = RenderOptions {
14134 strip_local_ids: true,
14135 };
14136 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
14137 assert!(!md.contains("localId"), "localId should be stripped: {md}");
14138 assert!(
14139 md.contains(":::expand{title=\"X\"}"),
14140 "title should remain: {md}"
14141 );
14142 }
14143
14144 #[test]
14147 fn expand_top_level_localid_roundtrip() {
14148 let adf_json = r#"{"version":1,"type":"doc","content":[
14150 {"type":"expand","attrs":{"title":"My Section"},"localId":"abc-123","content":[
14151 {"type":"paragraph","content":[{"type":"text","text":"hello"}]}
14152 ]}
14153 ]}"#;
14154 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14155 assert_eq!(doc.content[0].local_id.as_deref(), Some("abc-123"));
14156 let md = adf_to_markdown(&doc).unwrap();
14157 assert!(
14158 md.contains("localId=abc-123"),
14159 "JFM should contain localId: {md}"
14160 );
14161 let rt = markdown_to_adf(&md).unwrap();
14162 let expand = &rt.content[0];
14163 assert_eq!(expand.node_type, "expand");
14164 assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
14165 assert_eq!(
14166 expand.attrs.as_ref().unwrap()["title"],
14167 "My Section",
14168 "title should survive round-trip"
14169 );
14170 }
14171
14172 #[test]
14173 fn expand_parameters_roundtrip() {
14174 let adf_json = r#"{"version":1,"type":"doc","content":[
14176 {"type":"expand","attrs":{"title":"Props"},"parameters":{"macroMetadata":{"macroId":{"value":"m-001"},"schemaVersion":{"value":"1"}}},"content":[
14177 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
14178 ]}
14179 ]}"#;
14180 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14181 assert!(doc.content[0].parameters.is_some());
14182 let md = adf_to_markdown(&doc).unwrap();
14183 assert!(md.contains("params="), "JFM should contain params: {md}");
14184 let rt = markdown_to_adf(&md).unwrap();
14185 let expand = &rt.content[0];
14186 let params = expand
14187 .parameters
14188 .as_ref()
14189 .expect("parameters should survive round-trip");
14190 assert_eq!(params["macroMetadata"]["macroId"]["value"], "m-001");
14191 assert_eq!(params["macroMetadata"]["schemaVersion"]["value"], "1");
14192 }
14193
14194 #[test]
14195 fn expand_localid_and_parameters_roundtrip() {
14196 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"}]}]}]}"#;
14198 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14199 let md = adf_to_markdown(&doc).unwrap();
14200 let rt = markdown_to_adf(&md).unwrap();
14201 let expand = &rt.content[0];
14202 assert_eq!(expand.node_type, "expand");
14203 assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
14204 assert_eq!(expand.attrs.as_ref().unwrap()["title"], "My Section");
14205 let params = expand
14206 .parameters
14207 .as_ref()
14208 .expect("parameters should survive");
14209 assert_eq!(params["macroMetadata"]["macroId"]["value"], "macro-001");
14210 assert_eq!(params["macroMetadata"]["title"], "Page Properties");
14211 }
14212
14213 #[test]
14214 fn nested_expand_top_level_localid_and_parameters_roundtrip() {
14215 let adf_json = r#"{"version":1,"type":"doc","content":[
14216 {"type":"nestedExpand","attrs":{"title":"Nested"},"localId":"ne-100","parameters":{"macroMetadata":{"macroId":{"value":"nm-001"}}},"content":[
14217 {"type":"paragraph","content":[{"type":"text","text":"inner"}]}
14218 ]}
14219 ]}"#;
14220 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14221 let md = adf_to_markdown(&doc).unwrap();
14222 assert!(
14223 md.contains(":::nested-expand{"),
14224 "should use nested-expand: {md}"
14225 );
14226 assert!(md.contains("localId=ne-100"), "should have localId: {md}");
14227 assert!(md.contains("params="), "should have params: {md}");
14228 let rt = markdown_to_adf(&md).unwrap();
14229 let ne = &rt.content[0];
14230 assert_eq!(ne.node_type, "nestedExpand");
14231 assert_eq!(ne.local_id.as_deref(), Some("ne-100"));
14232 assert_eq!(
14233 ne.parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
14234 "nm-001"
14235 );
14236 }
14237
14238 #[test]
14239 fn expand_top_level_localid_stripped() {
14240 let adf_json = r#"{"version":1,"type":"doc","content":[
14242 {"type":"expand","attrs":{"title":"X"},"localId":"exp-strip","content":[
14243 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
14244 ]}
14245 ]}"#;
14246 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14247 let opts = RenderOptions {
14248 strip_local_ids: true,
14249 };
14250 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
14251 assert!(!md.contains("localId"), "localId should be stripped: {md}");
14252 assert!(
14253 md.contains(":::expand{title=\"X\"}"),
14254 "title should remain: {md}"
14255 );
14256 }
14257
14258 #[test]
14259 fn expand_parameters_without_localid() {
14260 let adf_json = r#"{"version":1,"type":"doc","content":[
14262 {"type":"expand","attrs":{"title":"P"},"parameters":{"macroMetadata":{"macroId":{"value":"solo"}}},"content":[
14263 {"type":"paragraph","content":[{"type":"text","text":"data"}]}
14264 ]}
14265 ]}"#;
14266 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14267 let md = adf_to_markdown(&doc).unwrap();
14268 assert!(!md.contains("localId"), "no localId: {md}");
14269 assert!(md.contains("params="), "has params: {md}");
14270 let rt = markdown_to_adf(&md).unwrap();
14271 assert!(rt.content[0].local_id.is_none());
14272 assert_eq!(
14273 rt.content[0].parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
14274 "solo"
14275 );
14276 }
14277
14278 #[test]
14279 fn expand_localid_without_parameters() {
14280 let adf_json = r#"{"version":1,"type":"doc","content":[
14282 {"type":"expand","attrs":{"title":"L"},"localId":"lid-only","content":[
14283 {"type":"paragraph","content":[{"type":"text","text":"txt"}]}
14284 ]}
14285 ]}"#;
14286 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14287 let md = adf_to_markdown(&doc).unwrap();
14288 assert!(md.contains("localId=lid-only"), "has localId: {md}");
14289 assert!(!md.contains("params="), "no params: {md}");
14290 let rt = markdown_to_adf(&md).unwrap();
14291 assert_eq!(rt.content[0].local_id.as_deref(), Some("lid-only"));
14292 assert!(rt.content[0].parameters.is_none());
14293 }
14294
14295 #[test]
14296 fn nested_panel_inside_panel() {
14297 let md = ":::panel{type=info}\n:::panel{type=warning}\nInner warning\n:::\n:::";
14298 let adf = markdown_to_adf(md).unwrap();
14299
14300 assert_eq!(adf.content.len(), 1);
14302 assert_eq!(adf.content[0].node_type, "panel");
14303
14304 let panel_content = adf.content[0].content.as_ref().unwrap();
14306 assert!(
14307 panel_content.iter().any(|n| n.node_type == "panel"),
14308 "Outer panel should contain an inner panel, got: {:?}",
14309 panel_content
14310 .iter()
14311 .map(|n| &n.node_type)
14312 .collect::<Vec<_>>()
14313 );
14314 }
14315
14316 #[test]
14317 fn content_after_directive_table_is_preserved() {
14318 let md = "\
14320## Before table
14321
14322::::table{layout=default}
14323:::tr
14324:::th{}
14325Cell
14326:::
14327:::
14328::::
14329
14330## After table
14331
14332Paragraph after.";
14333 let adf = markdown_to_adf(md).unwrap();
14334 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
14335 assert_eq!(
14336 types,
14337 vec!["heading", "table", "heading", "paragraph"],
14338 "Content after table was dropped: got {types:?}"
14339 );
14340 }
14341
14342 #[test]
14343 fn paragraph_after_directive_table_is_preserved() {
14344 let md = "\
14346::::table{layout=default}
14347:::tr
14348:::th{}
14349Header
14350:::
14351:::
14352::::
14353
14354Just a paragraph.";
14355 let adf = markdown_to_adf(md).unwrap();
14356 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
14357 assert_eq!(
14358 types,
14359 vec!["table", "paragraph"],
14360 "Paragraph after table was dropped: got {types:?}"
14361 );
14362 }
14363
14364 #[test]
14365 fn extension_after_directive_table_is_preserved() {
14366 let md = "\
14368::::table{layout=default}
14369:::tr
14370:::th{}
14371Header
14372:::
14373:::
14374::::
14375
14376::extension{type=com.atlassian.confluence.macro.core key=toc}";
14377 let adf = markdown_to_adf(md).unwrap();
14378 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
14379 assert_eq!(
14380 types,
14381 vec!["table", "extension"],
14382 "Extension after table was dropped: got {types:?}"
14383 );
14384 }
14385
14386 #[test]
14387 fn multiple_blocks_after_directive_table() {
14388 let md = "\
14390## Heading 1
14391
14392::::table{layout=default}
14393:::tr
14394:::td{}
14395A
14396:::
14397:::td{}
14398B
14399:::
14400:::
14401::::
14402
14403## Heading 2
14404
14405Some text.
14406
14407---
14408
14409::::table{layout=default}
14410:::tr
14411:::th{}
14412C
14413:::
14414:::
14415::::
14416
14417## Heading 3";
14418 let adf = markdown_to_adf(md).unwrap();
14419 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
14420 assert_eq!(
14421 types,
14422 vec![
14423 "heading",
14424 "table",
14425 "heading",
14426 "paragraph",
14427 "rule",
14428 "table",
14429 "heading"
14430 ],
14431 "Content after tables was dropped: got {types:?}"
14432 );
14433 }
14434
14435 #[test]
14438 fn adf_table_caption_to_markdown() {
14439 let doc = AdfDocument {
14440 version: 1,
14441 doc_type: "doc".to_string(),
14442 content: vec![AdfNode::table(vec![
14443 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
14444 AdfNode::text("cell"),
14445 ])])]),
14446 AdfNode::caption(vec![AdfNode::text("Table caption")]),
14447 ])],
14448 };
14449 let md = adf_to_markdown(&doc).unwrap();
14450 assert!(
14451 md.contains("::::table"),
14452 "table with caption must use directive form"
14453 );
14454 assert!(
14455 md.contains(":::caption"),
14456 "caption directive missing, got: {md}"
14457 );
14458 assert!(
14459 md.contains("Table caption"),
14460 "caption text missing, got: {md}"
14461 );
14462 }
14463
14464 #[test]
14465 fn directive_table_caption_parses() {
14466 let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nTable caption\n:::\n::::\n";
14467 let doc = markdown_to_adf(md).unwrap();
14468 let table = &doc.content[0];
14469 assert_eq!(table.node_type, "table");
14470 let children = table.content.as_ref().unwrap();
14471 assert_eq!(children.len(), 2, "expected row + caption");
14472 assert_eq!(children[0].node_type, "tableRow");
14473 assert_eq!(children[1].node_type, "caption");
14474 let caption_content = children[1].content.as_ref().unwrap();
14475 assert_eq!(caption_content[0].text.as_deref(), Some("Table caption"));
14476 }
14477
14478 #[test]
14479 fn table_caption_round_trip_from_adf_json() {
14480 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
14481 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
14482 {"type":"caption","content":[{"type":"text","text":"Table caption"}]}
14483 ]}]}"#;
14484 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14485 let md = adf_to_markdown(&doc).unwrap();
14486 assert!(md.contains("Table caption"), "caption text lost in ADF→JFM");
14487 let round_tripped = markdown_to_adf(&md).unwrap();
14488 let children = round_tripped.content[0].content.as_ref().unwrap();
14489 let caption = children.iter().find(|n| n.node_type == "caption");
14490 assert!(caption.is_some(), "caption lost on round-trip");
14491 let caption_text = caption.unwrap().content.as_ref().unwrap();
14492 assert_eq!(caption_text[0].text.as_deref(), Some("Table caption"));
14493 }
14494
14495 #[test]
14496 fn table_caption_with_inline_marks_round_trips() {
14497 let doc = AdfDocument {
14498 version: 1,
14499 doc_type: "doc".to_string(),
14500 content: vec![AdfNode::table(vec![
14501 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
14502 AdfNode::text("data"),
14503 ])])]),
14504 AdfNode::caption(vec![
14505 AdfNode::text("Caption with "),
14506 AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
14507 ]),
14508 ])],
14509 };
14510 let md = adf_to_markdown(&doc).unwrap();
14511 assert!(md.contains("**bold**"), "bold mark missing in caption");
14512 let round_tripped = markdown_to_adf(&md).unwrap();
14513 let caption = round_tripped.content[0]
14514 .content
14515 .as_ref()
14516 .unwrap()
14517 .iter()
14518 .find(|n| n.node_type == "caption")
14519 .expect("caption node missing after round-trip");
14520 let inlines = caption.content.as_ref().unwrap();
14521 let bold_node = inlines.iter().find(|n| {
14522 n.marks
14523 .as_ref()
14524 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
14525 });
14526 assert!(bold_node.is_some(), "bold mark lost in caption round-trip");
14527 }
14528
14529 #[test]
14532 fn table_caption_localid_roundtrip() {
14533 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
14534 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
14535 {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Table with localId"}]}
14536 ]}]}"#;
14537 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14538 let md = adf_to_markdown(&doc).unwrap();
14539 assert!(
14540 md.contains("localId=abcdef123456"),
14541 "table caption localId should appear in markdown: {md}"
14542 );
14543 let rt = markdown_to_adf(&md).unwrap();
14544 let caption = rt.content[0]
14545 .content
14546 .as_ref()
14547 .unwrap()
14548 .iter()
14549 .find(|n| n.node_type == "caption")
14550 .expect("caption should survive round-trip");
14551 assert_eq!(
14552 caption.attrs.as_ref().unwrap()["localId"],
14553 "abcdef123456",
14554 "table caption localId should round-trip"
14555 );
14556 }
14557
14558 #[test]
14559 fn table_caption_without_localid_unchanged() {
14560 let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nPlain caption\n:::\n::::\n";
14561 let doc = markdown_to_adf(md).unwrap();
14562 let caption = doc.content[0]
14563 .content
14564 .as_ref()
14565 .unwrap()
14566 .iter()
14567 .find(|n| n.node_type == "caption")
14568 .unwrap();
14569 assert!(
14570 caption.attrs.is_none(),
14571 "table caption without localId should not gain attrs"
14572 );
14573 let md2 = adf_to_markdown(&doc).unwrap();
14574 assert!(!md2.contains("localId"), "no localId should appear: {md2}");
14575 }
14576
14577 #[test]
14578 fn table_caption_localid_stripped_when_option_set() {
14579 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
14580 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
14581 {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Stripped"}]}
14582 ]}]}"#;
14583 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14584 let opts = RenderOptions {
14585 strip_local_ids: true,
14586 ..Default::default()
14587 };
14588 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
14589 assert!(
14590 !md.contains("localId"),
14591 "table caption localId should be stripped: {md}"
14592 );
14593 }
14594
14595 #[test]
14596 #[test]
14597 fn tablecell_empty_attrs_preserved_on_roundtrip() {
14598 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"}]}]}]}]}]}"#;
14600 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14601 let md = adf_to_markdown(&doc).unwrap();
14602 let round_tripped = markdown_to_adf(&md).unwrap();
14603 let rows = round_tripped.content[0].content.as_ref().unwrap();
14604 let cell = &rows[0].content.as_ref().unwrap()[0];
14605 assert!(
14606 cell.attrs.is_some(),
14607 "tableCell attrs should be preserved, got None"
14608 );
14609 assert_eq!(
14610 cell.attrs.as_ref().unwrap(),
14611 &serde_json::json!({}),
14612 "tableCell attrs should be an empty object"
14613 );
14614 }
14615
14616 #[test]
14617 fn tablecell_empty_attrs_serialized_in_json() {
14618 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"}]}]}]}]}]}"#;
14620 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14621 let md = adf_to_markdown(&doc).unwrap();
14622 let round_tripped = markdown_to_adf(&md).unwrap();
14623 let json = serde_json::to_string(&round_tripped).unwrap();
14624 assert!(
14625 json.contains(r#""attrs":{}"#),
14626 "serialized JSON should contain \"attrs\":{{}}, got: {json}"
14627 );
14628 }
14629
14630 #[test]
14631 fn tablecell_empty_attrs_renders_braces_in_markdown() {
14632 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"}]}]}]}]}]}"#;
14634 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14635 let md = adf_to_markdown(&doc).unwrap();
14636 assert!(
14638 md.contains("{} hello"),
14639 "cell with empty attrs should render '{{}} hello', got: {md}"
14640 );
14641 assert!(
14642 !md.contains("{} world"),
14643 "cell without attrs should not render '{{}}', got: {md}"
14644 );
14645 }
14646
14647 #[test]
14648 fn tablecell_no_attrs_unchanged_on_roundtrip() {
14649 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"}]}]}]}]}]}"#;
14651 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14652 let md = adf_to_markdown(&doc).unwrap();
14653 let round_tripped = markdown_to_adf(&md).unwrap();
14654 let rows = round_tripped.content[0].content.as_ref().unwrap();
14655 let cell = &rows[0].content.as_ref().unwrap()[0];
14656 assert!(
14657 cell.attrs.is_none(),
14658 "tableCell without attrs should stay None, got: {:?}",
14659 cell.attrs
14660 );
14661 }
14662
14663 #[test]
14664 fn tablecell_nonempty_attrs_preserved_on_roundtrip() {
14665 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"}]}]}]}]}]}"##;
14667 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14668 let md = adf_to_markdown(&doc).unwrap();
14669 let round_tripped = markdown_to_adf(&md).unwrap();
14670 let rows = round_tripped.content[0].content.as_ref().unwrap();
14671 let cell = &rows[1].content.as_ref().unwrap()[0];
14672 let attrs = cell.attrs.as_ref().unwrap();
14673 assert_eq!(attrs["background"], "#DEEBFF");
14674 assert_eq!(attrs["colspan"], 2);
14675 }
14676
14677 #[test]
14678 fn pipe_table_not_used_when_caption_present() {
14679 let doc = AdfDocument {
14680 version: 1,
14681 doc_type: "doc".to_string(),
14682 content: vec![AdfNode::table(vec![
14683 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
14684 AdfNode::text("H"),
14685 ])])]),
14686 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
14687 AdfNode::text("D"),
14688 ])])]),
14689 AdfNode::caption(vec![AdfNode::text("cap")]),
14690 ])],
14691 };
14692 let md = adf_to_markdown(&doc).unwrap();
14693 assert!(
14694 md.contains("::::table"),
14695 "pipe syntax should not be used when caption is present"
14696 );
14697 }
14698
14699 #[test]
14702 fn hardbreak_with_ordered_marker_in_bullet_item_roundtrips() {
14703 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
14706 {"type":"listItem","content":[{"type":"paragraph","content":[
14707 {"type":"text","text":"1. First item"},
14708 {"type":"hardBreak"},
14709 {"type":"text","text":"2. Honouring existing commitments"}
14710 ]}]}
14711 ]}]}"#;
14712 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14713 let md = adf_to_markdown(&doc).unwrap();
14714
14715 assert!(
14717 md.contains(" 2. Honouring"),
14718 "Continuation line should be indented, got:\n{md}"
14719 );
14720
14721 let rt = markdown_to_adf(&md).unwrap();
14723 let list = &rt.content[0];
14724 assert_eq!(list.node_type, "bulletList");
14725 let items = list.content.as_ref().unwrap();
14726 assert_eq!(
14727 items.len(),
14728 1,
14729 "Should be one list item, got {}",
14730 items.len()
14731 );
14732
14733 let para = &items[0].content.as_ref().unwrap()[0];
14734 let inlines = para.content.as_ref().unwrap();
14735 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
14736 assert_eq!(
14737 types,
14738 vec!["text", "hardBreak", "text"],
14739 "Expected text+hardBreak+text, got {types:?}"
14740 );
14741 assert_eq!(
14742 inlines[2].text.as_deref().unwrap(),
14743 "2. Honouring existing commitments"
14744 );
14745 }
14746
14747 #[test]
14748 fn hardbreak_with_ordered_marker_in_ordered_item_roundtrips() {
14749 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
14751 {"type":"listItem","content":[{"type":"paragraph","content":[
14752 {"type":"text","text":"Introduction "},
14753 {"type":"hardBreak"},
14754 {"type":"text","text":"3. Third point"}
14755 ]}]}
14756 ]}]}"#;
14757 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14758 let md = adf_to_markdown(&doc).unwrap();
14759 let rt = markdown_to_adf(&md).unwrap();
14760
14761 let list = &rt.content[0];
14762 assert_eq!(list.node_type, "orderedList");
14763 let items = list.content.as_ref().unwrap();
14764 assert_eq!(items.len(), 1);
14765
14766 let para = &items[0].content.as_ref().unwrap()[0];
14767 let inlines = para.content.as_ref().unwrap();
14768 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
14769 assert_eq!(types, vec!["text", "hardBreak", "text"]);
14770 assert_eq!(inlines[2].text.as_deref().unwrap(), "3. Third point");
14771 }
14772
14773 #[test]
14774 fn hardbreak_with_bullet_marker_in_bullet_item_roundtrips() {
14775 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
14777 {"type":"listItem","content":[{"type":"paragraph","content":[
14778 {"type":"text","text":"Header "},
14779 {"type":"hardBreak"},
14780 {"type":"text","text":"- not a sub-item"}
14781 ]}]}
14782 ]}]}"#;
14783 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14784 let md = adf_to_markdown(&doc).unwrap();
14785 let rt = markdown_to_adf(&md).unwrap();
14786
14787 let list = &rt.content[0];
14788 assert_eq!(list.node_type, "bulletList");
14789 let items = list.content.as_ref().unwrap();
14790 assert_eq!(
14791 items.len(),
14792 1,
14793 "Should be one list item, not {}",
14794 items.len()
14795 );
14796
14797 let para = &items[0].content.as_ref().unwrap()[0];
14798 let inlines = para.content.as_ref().unwrap();
14799 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
14800 assert_eq!(types, vec!["text", "hardBreak", "text"]);
14801 assert_eq!(inlines[2].text.as_deref().unwrap(), "- not a sub-item");
14802 }
14803
14804 #[test]
14805 fn hardbreak_continuation_followed_by_sub_list() {
14806 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
14808 {"type":"listItem","content":[
14809 {"type":"paragraph","content":[
14810 {"type":"text","text":"Main item "},
14811 {"type":"hardBreak"},
14812 {"type":"text","text":"continued here"}
14813 ]},
14814 {"type":"bulletList","content":[
14815 {"type":"listItem","content":[{"type":"paragraph","content":[
14816 {"type":"text","text":"sub-item"}
14817 ]}]}
14818 ]}
14819 ]}
14820 ]}]}"#;
14821 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14822 let md = adf_to_markdown(&doc).unwrap();
14823 let rt = markdown_to_adf(&md).unwrap();
14824
14825 let list = &rt.content[0];
14826 let items = list.content.as_ref().unwrap();
14827 assert_eq!(items.len(), 1);
14828
14829 let item_content = items[0].content.as_ref().unwrap();
14830 assert_eq!(item_content.len(), 2, "Expected paragraph + nested list");
14831 assert_eq!(item_content[0].node_type, "paragraph");
14832 assert_eq!(item_content[1].node_type, "bulletList");
14833
14834 let inlines = item_content[0].content.as_ref().unwrap();
14836 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
14837 assert_eq!(types, vec!["text", "hardBreak", "text"]);
14838 }
14839
14840 #[test]
14841 fn multiple_hardbreaks_with_numbered_text_roundtrip() {
14842 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
14844 {"type":"listItem","content":[{"type":"paragraph","content":[
14845 {"type":"text","text":"Preamble "},
14846 {"type":"hardBreak"},
14847 {"type":"text","text":"1. Alpha "},
14848 {"type":"hardBreak"},
14849 {"type":"text","text":"2. Bravo"}
14850 ]}]}
14851 ]}]}"#;
14852 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14853 let md = adf_to_markdown(&doc).unwrap();
14854 let rt = markdown_to_adf(&md).unwrap();
14855
14856 let items = rt.content[0].content.as_ref().unwrap();
14857 assert_eq!(items.len(), 1);
14858
14859 let inlines = items[0].content.as_ref().unwrap()[0]
14860 .content
14861 .as_ref()
14862 .unwrap();
14863 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
14864 assert_eq!(
14865 types,
14866 vec!["text", "hardBreak", "text", "hardBreak", "text"]
14867 );
14868 }
14869
14870 #[test]
14871 fn trailing_hardbreak_in_bullet_item_roundtrips() {
14872 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
14876 {"type":"listItem","content":[{"type":"paragraph","content":[
14877 {"type":"text","text":"ends with break"},
14878 {"type":"hardBreak"}
14879 ]}]}
14880 ]}]}"#;
14881 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14882 let md = adf_to_markdown(&doc).unwrap();
14883 let rt = markdown_to_adf(&md).unwrap();
14884
14885 let list = &rt.content[0];
14886 assert_eq!(list.node_type, "bulletList");
14887 let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
14888 .content
14889 .as_ref()
14890 .unwrap();
14891 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
14892 assert_eq!(types, vec!["text", "hardBreak"]);
14893 }
14894
14895 #[test]
14896 fn trailing_hardbreak_in_ordered_item_roundtrips() {
14897 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
14900 {"type":"listItem","content":[{"type":"paragraph","content":[
14901 {"type":"text","text":"ends with break"},
14902 {"type":"hardBreak"}
14903 ]}]}
14904 ]}]}"#;
14905 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14906 let md = adf_to_markdown(&doc).unwrap();
14907 let rt = markdown_to_adf(&md).unwrap();
14908
14909 let list = &rt.content[0];
14910 assert_eq!(list.node_type, "orderedList");
14911 let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
14912 .content
14913 .as_ref()
14914 .unwrap();
14915 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
14916 assert_eq!(types, vec!["text", "hardBreak"]);
14917 }
14918
14919 #[test]
14920 fn trailing_space_hardbreak_continuation_in_bullet_item() {
14921 let md = "- first line \n 2. continued\n";
14925 let doc = markdown_to_adf(md).unwrap();
14926
14927 let list = &doc.content[0];
14928 assert_eq!(list.node_type, "bulletList");
14929 let items = list.content.as_ref().unwrap();
14930 assert_eq!(
14931 items.len(),
14932 1,
14933 "Should be one list item, got {}",
14934 items.len()
14935 );
14936
14937 let para = &items[0].content.as_ref().unwrap()[0];
14938 let inlines = para.content.as_ref().unwrap();
14939 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
14940 assert_eq!(types, vec!["text", "hardBreak", "text"]);
14941 assert_eq!(inlines[2].text.as_deref().unwrap(), "2. continued");
14942 }
14943
14944 #[test]
14945 fn trailing_space_hardbreak_continuation_in_ordered_item() {
14946 let md = "1. first line \n - continued\n";
14949 let doc = markdown_to_adf(md).unwrap();
14950
14951 let list = &doc.content[0];
14952 assert_eq!(list.node_type, "orderedList");
14953 let items = list.content.as_ref().unwrap();
14954 assert_eq!(
14955 items.len(),
14956 1,
14957 "Should be one list item, got {}",
14958 items.len()
14959 );
14960
14961 let para = &items[0].content.as_ref().unwrap()[0];
14962 let inlines = para.content.as_ref().unwrap();
14963 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
14964 assert_eq!(types, vec!["text", "hardBreak", "text"]);
14965 assert_eq!(inlines[2].text.as_deref().unwrap(), "- continued");
14966 }
14967
14968 #[test]
14969 fn multi_paragraph_list_item_with_ordered_marker_roundtrips() {
14970 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
14973 {"type":"listItem","content":[
14974 {"type":"paragraph","content":[{"type":"text","text":"some preamble"}]},
14975 {"type":"paragraph","content":[{"type":"text","text":"2. Honouring existing commitments"}]}
14976 ]}
14977 ]}]}"#;
14978 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14979 let md = adf_to_markdown(&doc).unwrap();
14980 let rt = markdown_to_adf(&md).unwrap();
14981
14982 assert_eq!(rt.content.len(), 1, "Should be one top-level block");
14983 let list = &rt.content[0];
14984 assert_eq!(list.node_type, "bulletList");
14985 let items = list.content.as_ref().unwrap();
14986 assert_eq!(items.len(), 1);
14987 let item_content = items[0].content.as_ref().unwrap();
14988 assert_eq!(
14989 item_content.len(),
14990 2,
14991 "Expected 2 paragraphs inside the list item, got {}",
14992 item_content.len()
14993 );
14994 assert_eq!(item_content[0].node_type, "paragraph");
14995 assert_eq!(item_content[1].node_type, "paragraph");
14996 let text = item_content[1].content.as_ref().unwrap()[0]
14997 .text
14998 .as_deref()
14999 .unwrap();
15000 assert_eq!(text, "2. Honouring existing commitments");
15001 }
15002
15003 #[test]
15004 fn multi_paragraph_list_item_with_bullet_marker_roundtrips() {
15005 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15007 {"type":"listItem","content":[
15008 {"type":"paragraph","content":[{"type":"text","text":"preamble"}]},
15009 {"type":"paragraph","content":[{"type":"text","text":"- not a sub-item"}]}
15010 ]}
15011 ]}]}"#;
15012 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15013 let md = adf_to_markdown(&doc).unwrap();
15014 let rt = markdown_to_adf(&md).unwrap();
15015
15016 let items = rt.content[0].content.as_ref().unwrap();
15017 assert_eq!(items.len(), 1);
15018 let item_content = items[0].content.as_ref().unwrap();
15019 assert_eq!(item_content.len(), 2);
15020 assert_eq!(item_content[1].node_type, "paragraph");
15021 let text = item_content[1].content.as_ref().unwrap()[0]
15022 .text
15023 .as_deref()
15024 .unwrap();
15025 assert_eq!(text, "- not a sub-item");
15026 }
15027
15028 #[test]
15029 fn backslash_escape_in_inline_text() {
15030 let nodes = parse_inline(r"2\. text");
15032 assert_eq!(nodes.len(), 1, "Should be one text node");
15033 assert_eq!(nodes[0].text.as_deref().unwrap(), "2. text");
15034 }
15035
15036 #[test]
15037 fn escape_list_marker_ordered() {
15038 assert_eq!(escape_list_marker("2. text"), r"2\. text");
15039 assert_eq!(escape_list_marker("10. tenth"), r"10\. tenth");
15040 }
15041
15042 #[test]
15043 fn escape_list_marker_bullet() {
15044 assert_eq!(escape_list_marker("- text"), r"\- text");
15045 assert_eq!(escape_list_marker("* text"), r"\* text");
15046 assert_eq!(escape_list_marker("+ text"), r"\+ text");
15047 }
15048
15049 #[test]
15050 fn escape_list_marker_plain() {
15051 assert_eq!(escape_list_marker("plain text"), "plain text");
15052 assert_eq!(escape_list_marker("no. marker"), "no. marker");
15053 }
15054
15055 #[test]
15056 fn escape_emoji_shortcodes_basic() {
15057 assert_eq!(escape_emoji_shortcodes(":fire:"), r"\:fire:");
15058 assert_eq!(
15059 escape_emoji_shortcodes("hello :wave: world"),
15060 r"hello \:wave: world"
15061 );
15062 }
15063
15064 #[test]
15065 fn escape_emoji_shortcodes_double_colon() {
15066 assert_eq!(
15068 escape_emoji_shortcodes("Status::Active::Running"),
15069 r"Status:\:Active::Running"
15070 );
15071 }
15072
15073 #[test]
15074 fn escape_emoji_shortcodes_no_match() {
15075 assert_eq!(escape_emoji_shortcodes("Time is 10:30"), "Time is 10:30");
15077 assert_eq!(escape_emoji_shortcodes("no colons here"), "no colons here");
15078 assert_eq!(escape_emoji_shortcodes("trailing:"), "trailing:");
15079 assert_eq!(escape_emoji_shortcodes(":"), ":");
15080 }
15081
15082 #[test]
15083 fn escape_emoji_shortcodes_mixed() {
15084 assert_eq!(
15085 escape_emoji_shortcodes("Alert :fire: on pod:pod42"),
15086 r"Alert \:fire: on pod:pod42"
15087 );
15088 }
15089
15090 #[test]
15091 fn merge_adjacent_text_nodes() {
15092 let mut nodes = vec![AdfNode::text("a"), AdfNode::text("b"), AdfNode::text("c")];
15093 merge_adjacent_text(&mut nodes);
15094 assert_eq!(nodes.len(), 1);
15095 assert_eq!(nodes[0].text.as_deref().unwrap(), "abc");
15096 }
15097
15098 #[test]
15101 fn issue_455_paragraph_hardbreak_ordered_marker_roundtrips() {
15102 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15105 {"type":"text","text":"Introduction: "},
15106 {"type":"hardBreak"},
15107 {"type":"text","text":"1. This text follows a hardBreak"}
15108 ]}]}"#;
15109 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15110 let md = adf_to_markdown(&doc).unwrap();
15111 let rt = markdown_to_adf(&md).unwrap();
15112
15113 assert_eq!(rt.content.len(), 1, "Should remain one block");
15114 assert_eq!(rt.content[0].node_type, "paragraph");
15115 let inlines = rt.content[0].content.as_ref().unwrap();
15116 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
15117 assert_eq!(types, vec!["text", "hardBreak", "text"]);
15118 assert_eq!(
15119 inlines[2].text.as_deref(),
15120 Some("1. This text follows a hardBreak")
15121 );
15122 }
15123
15124 #[test]
15125 fn issue_455_paragraph_hardbreak_bullet_marker_roundtrips() {
15126 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15128 {"type":"text","text":"Intro"},
15129 {"type":"hardBreak"},
15130 {"type":"text","text":"- not a list item"}
15131 ]}]}"#;
15132 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15133 let md = adf_to_markdown(&doc).unwrap();
15134 let rt = markdown_to_adf(&md).unwrap();
15135
15136 assert_eq!(rt.content.len(), 1);
15137 assert_eq!(rt.content[0].node_type, "paragraph");
15138 let inlines = rt.content[0].content.as_ref().unwrap();
15139 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
15140 assert_eq!(types, vec!["text", "hardBreak", "text"]);
15141 assert_eq!(inlines[2].text.as_deref(), Some("- not a list item"));
15142 }
15143
15144 #[test]
15145 fn issue_455_paragraph_hardbreak_heading_marker_roundtrips() {
15146 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15148 {"type":"text","text":"Intro"},
15149 {"type":"hardBreak"},
15150 {"type":"text","text":"# not a heading"}
15151 ]}]}"##;
15152 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15153 let md = adf_to_markdown(&doc).unwrap();
15154 let rt = markdown_to_adf(&md).unwrap();
15155
15156 assert_eq!(rt.content.len(), 1);
15157 assert_eq!(rt.content[0].node_type, "paragraph");
15158 let inlines = rt.content[0].content.as_ref().unwrap();
15159 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
15160 assert_eq!(types, vec!["text", "hardBreak", "text"]);
15161 assert_eq!(inlines[2].text.as_deref(), Some("# not a heading"));
15162 }
15163
15164 #[test]
15165 fn issue_455_paragraph_hardbreak_blockquote_marker_roundtrips() {
15166 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15168 {"type":"text","text":"Intro"},
15169 {"type":"hardBreak"},
15170 {"type":"text","text":"> not a blockquote"}
15171 ]}]}"#;
15172 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15173 let md = adf_to_markdown(&doc).unwrap();
15174 let rt = markdown_to_adf(&md).unwrap();
15175
15176 assert_eq!(rt.content.len(), 1);
15177 assert_eq!(rt.content[0].node_type, "paragraph");
15178 let inlines = rt.content[0].content.as_ref().unwrap();
15179 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
15180 assert_eq!(types, vec!["text", "hardBreak", "text"]);
15181 assert_eq!(inlines[2].text.as_deref(), Some("> not a blockquote"));
15182 }
15183
15184 #[test]
15185 fn issue_455_paragraph_multiple_hardbreaks_with_ordered_markers() {
15186 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15188 {"type":"text","text":"Preamble"},
15189 {"type":"hardBreak"},
15190 {"type":"text","text":"1. First"},
15191 {"type":"hardBreak"},
15192 {"type":"text","text":"2. Second"},
15193 {"type":"hardBreak"},
15194 {"type":"text","text":"3. Third"}
15195 ]}]}"#;
15196 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15197 let md = adf_to_markdown(&doc).unwrap();
15198 let rt = markdown_to_adf(&md).unwrap();
15199
15200 assert_eq!(rt.content.len(), 1);
15201 assert_eq!(rt.content[0].node_type, "paragraph");
15202 let inlines = rt.content[0].content.as_ref().unwrap();
15203 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
15204 assert_eq!(
15205 types,
15206 vec![
15207 "text",
15208 "hardBreak",
15209 "text",
15210 "hardBreak",
15211 "text",
15212 "hardBreak",
15213 "text"
15214 ]
15215 );
15216 assert_eq!(inlines[2].text.as_deref(), Some("1. First"));
15217 assert_eq!(inlines[4].text.as_deref(), Some("2. Second"));
15218 assert_eq!(inlines[6].text.as_deref(), Some("3. Third"));
15219 }
15220
15221 #[test]
15222 fn issue_455_paragraph_hardbreak_jfm_indentation() {
15223 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15225 {"type":"text","text":"Intro"},
15226 {"type":"hardBreak"},
15227 {"type":"text","text":"1. continued"}
15228 ]}]}"#;
15229 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15230 let md = adf_to_markdown(&doc).unwrap();
15231 assert!(
15232 md.contains("Intro\\\n 1. continued"),
15233 "Continuation should be 2-space-indented, got: {md:?}"
15234 );
15235 }
15236
15237 #[test]
15238 fn issue_455_paragraph_hardbreak_from_jfm() {
15239 let md = "Intro\\\n 1. This is continuation text\n";
15242 let doc = markdown_to_adf(md).unwrap();
15243
15244 assert_eq!(doc.content.len(), 1);
15245 assert_eq!(doc.content[0].node_type, "paragraph");
15246 let inlines = doc.content[0].content.as_ref().unwrap();
15247 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
15248 assert_eq!(types, vec!["text", "hardBreak", "text"]);
15249 assert_eq!(
15250 inlines[2].text.as_deref(),
15251 Some("1. This is continuation text")
15252 );
15253 }
15254
15255 #[test]
15256 fn issue_455_paragraph_starts_with_ordered_marker_and_hardbreak() {
15257 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15261 {"type":"text","text":"1. Starting with a number"},
15262 {"type":"hardBreak"},
15263 {"type":"text","text":"continuation after break"}
15264 ]}]}"#;
15265 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15266 let md = adf_to_markdown(&doc).unwrap();
15267 assert!(
15269 md.contains(r"1\. Starting with a number"),
15270 "First line should have escaped list marker, got: {md:?}"
15271 );
15272 let rt = markdown_to_adf(&md).unwrap();
15273
15274 assert_eq!(rt.content.len(), 1);
15275 assert_eq!(rt.content[0].node_type, "paragraph");
15276 let inlines = rt.content[0].content.as_ref().unwrap();
15277 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
15278 assert_eq!(types, vec!["text", "hardBreak", "text"]);
15279 assert_eq!(
15280 inlines[0].text.as_deref(),
15281 Some("1. Starting with a number")
15282 );
15283 assert_eq!(inlines[2].text.as_deref(), Some("continuation after break"));
15284 }
15285
15286 #[test]
15287 fn ordered_marker_paragraph_in_table_cell_roundtrips() {
15288 let adf_json = r#"{"version":1,"type":"doc","content":[{
15291 "type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},
15292 "content":[{"type":"tableRow","content":[{
15293 "type":"tableCell","attrs":{"colspan":1,"rowspan":1},
15294 "content":[{"type":"paragraph","content":[
15295 {"type":"text","text":"2. Honouring existing commitments"}
15296 ]}]
15297 }]}]
15298 }]}"#;
15299 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15300 let md = adf_to_markdown(&doc).unwrap();
15301 let rt = markdown_to_adf(&md).unwrap();
15302
15303 let table = &rt.content[0];
15304 let cell = &table.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0];
15305 let para = &cell.content.as_ref().unwrap()[0];
15306 assert_eq!(para.node_type, "paragraph");
15307 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
15308 assert_eq!(text, "2. Honouring existing commitments");
15309 }
15310
15311 #[test]
15312 fn bullet_marker_paragraph_standalone_roundtrips() {
15313 let adf_json = r#"{"version":1,"type":"doc","content":[
15316 {"type":"paragraph","content":[
15317 {"type":"text","text":"- not a list item"}
15318 ]}
15319 ]}"#;
15320 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15321 let md = adf_to_markdown(&doc).unwrap();
15322 assert!(
15323 md.contains(r"\- not a list item"),
15324 "Should escape the leading dash, got:\n{md}"
15325 );
15326 let rt = markdown_to_adf(&md).unwrap();
15327 assert_eq!(rt.content[0].node_type, "paragraph");
15328 let text = rt.content[0].content.as_ref().unwrap()[0]
15329 .text
15330 .as_deref()
15331 .unwrap();
15332 assert_eq!(text, "- not a list item");
15333 }
15334
15335 #[test]
15336 fn merge_adjacent_text_skips_non_text_nodes() {
15337 let mut nodes = vec![
15340 AdfNode::text("a"),
15341 AdfNode::hard_break(),
15342 AdfNode::text("b"),
15343 ];
15344 merge_adjacent_text(&mut nodes);
15345 assert_eq!(nodes.len(), 3);
15346 }
15347
15348 #[test]
15349 fn star_bullet_paragraph_roundtrips() {
15350 let adf_json = r#"{"version":1,"type":"doc","content":[
15353 {"type":"paragraph","content":[
15354 {"type":"text","text":"* starred"}
15355 ]}
15356 ]}"#;
15357 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15358 let md = adf_to_markdown(&doc).unwrap();
15359 let rt = markdown_to_adf(&md).unwrap();
15360 assert_eq!(rt.content[0].node_type, "paragraph");
15361 assert_eq!(
15362 rt.content[0].content.as_ref().unwrap()[0]
15363 .text
15364 .as_deref()
15365 .unwrap(),
15366 "* starred"
15367 );
15368 }
15369
15370 #[test]
15373 fn issue_388_ordered_list_with_strong_hardbreak_roundtrips() {
15374 let adf_json = r#"{"version":1,"type":"doc","content":[
15377 {"type":"orderedList","attrs":{"order":1},"content":[
15378 {"type":"listItem","content":[
15379 {"type":"paragraph","content":[
15380 {"type":"text","text":"Bold heading","marks":[{"type":"strong"}]},
15381 {"type":"hardBreak"},
15382 {"type":"text","text":"Content after break"}
15383 ]}
15384 ]},
15385 {"type":"listItem","content":[
15386 {"type":"paragraph","content":[
15387 {"type":"text","text":"Second item","marks":[{"type":"strong"}]},
15388 {"type":"hardBreak"},
15389 {"type":"text","text":"More content"}
15390 ]}
15391 ]}
15392 ]}
15393 ]}"#;
15394 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15395 let md = adf_to_markdown(&doc).unwrap();
15396 let rt = markdown_to_adf(&md).unwrap();
15397
15398 assert_eq!(
15400 rt.content.len(),
15401 1,
15402 "Should be 1 block (orderedList), got {}",
15403 rt.content.len()
15404 );
15405 assert_eq!(rt.content[0].node_type, "orderedList");
15406 let items = rt.content[0].content.as_ref().unwrap();
15407 assert_eq!(
15408 items.len(),
15409 2,
15410 "Should have 2 listItems, got {}",
15411 items.len()
15412 );
15413
15414 let p1 = items[0].content.as_ref().unwrap()[0]
15416 .content
15417 .as_ref()
15418 .unwrap();
15419 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
15420 assert_eq!(types1, vec!["text", "hardBreak", "text"]);
15421 assert_eq!(p1[0].text.as_deref(), Some("Bold heading"));
15422 assert_eq!(p1[2].text.as_deref(), Some("Content after break"));
15423
15424 let p2 = items[1].content.as_ref().unwrap()[0]
15426 .content
15427 .as_ref()
15428 .unwrap();
15429 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
15430 assert_eq!(types2, vec!["text", "hardBreak", "text"]);
15431 assert_eq!(p2[0].text.as_deref(), Some("Second item"));
15432 assert_eq!(p2[2].text.as_deref(), Some("More content"));
15433 }
15434
15435 #[test]
15436 fn issue_388_bullet_list_with_strong_hardbreak_roundtrips() {
15437 let adf_json = r#"{"version":1,"type":"doc","content":[
15439 {"type":"bulletList","content":[
15440 {"type":"listItem","content":[
15441 {"type":"paragraph","content":[
15442 {"type":"text","text":"First","marks":[{"type":"strong"}]},
15443 {"type":"hardBreak"},
15444 {"type":"text","text":"details"}
15445 ]}
15446 ]},
15447 {"type":"listItem","content":[
15448 {"type":"paragraph","content":[
15449 {"type":"text","text":"Second","marks":[{"type":"em"}]},
15450 {"type":"hardBreak"},
15451 {"type":"text","text":"more details"}
15452 ]}
15453 ]}
15454 ]}
15455 ]}"#;
15456 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15457 let md = adf_to_markdown(&doc).unwrap();
15458 let rt = markdown_to_adf(&md).unwrap();
15459
15460 assert_eq!(rt.content.len(), 1);
15461 assert_eq!(rt.content[0].node_type, "bulletList");
15462 let items = rt.content[0].content.as_ref().unwrap();
15463 assert_eq!(items.len(), 2);
15464
15465 let p1 = items[0].content.as_ref().unwrap()[0]
15466 .content
15467 .as_ref()
15468 .unwrap();
15469 assert_eq!(p1[0].text.as_deref(), Some("First"));
15470 assert_eq!(p1[2].text.as_deref(), Some("details"));
15471
15472 let p2 = items[1].content.as_ref().unwrap()[0]
15473 .content
15474 .as_ref()
15475 .unwrap();
15476 assert_eq!(p2[0].text.as_deref(), Some("Second"));
15477 assert_eq!(p2[2].text.as_deref(), Some("more details"));
15478 }
15479
15480 #[test]
15481 fn issue_388_ordered_list_hardbreak_jfm_indentation() {
15482 let adf_json = r#"{"version":1,"type":"doc","content":[
15484 {"type":"orderedList","attrs":{"order":1},"content":[
15485 {"type":"listItem","content":[
15486 {"type":"paragraph","content":[
15487 {"type":"text","text":"heading","marks":[{"type":"strong"}]},
15488 {"type":"hardBreak"},
15489 {"type":"text","text":"body"}
15490 ]}
15491 ]}
15492 ]}
15493 ]}"#;
15494 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15495 let md = adf_to_markdown(&doc).unwrap();
15496 assert!(
15497 md.contains("1. **heading**\\\n body"),
15498 "Continuation should be indented, got:\n{md}"
15499 );
15500 }
15501
15502 #[test]
15503 fn issue_388_ordered_list_hardbreak_from_jfm() {
15504 let md = "1. **bold**\\\n continued\n2. **also bold**\\\n also continued\n";
15506 let doc = markdown_to_adf(md).unwrap();
15507
15508 assert_eq!(doc.content.len(), 1);
15509 assert_eq!(doc.content[0].node_type, "orderedList");
15510 let items = doc.content[0].content.as_ref().unwrap();
15511 assert_eq!(items.len(), 2);
15512
15513 let p1 = items[0].content.as_ref().unwrap()[0]
15514 .content
15515 .as_ref()
15516 .unwrap();
15517 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
15518 assert_eq!(types1, vec!["text", "hardBreak", "text"]);
15519 assert_eq!(p1[0].text.as_deref(), Some("bold"));
15520 assert_eq!(p1[2].text.as_deref(), Some("continued"));
15521
15522 let p2 = items[1].content.as_ref().unwrap()[0]
15523 .content
15524 .as_ref()
15525 .unwrap();
15526 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
15527 assert_eq!(types2, vec!["text", "hardBreak", "text"]);
15528 }
15529
15530 #[test]
15531 fn issue_388_bullet_list_hardbreak_from_jfm() {
15532 let md = "- first\\\n second\n- third\\\n fourth\n";
15534 let doc = markdown_to_adf(md).unwrap();
15535
15536 assert_eq!(doc.content.len(), 1);
15537 assert_eq!(doc.content[0].node_type, "bulletList");
15538 let items = doc.content[0].content.as_ref().unwrap();
15539 assert_eq!(items.len(), 2);
15540
15541 for (i, expected) in [("first", "second"), ("third", "fourth")]
15542 .iter()
15543 .enumerate()
15544 {
15545 let p = items[i].content.as_ref().unwrap()[0]
15546 .content
15547 .as_ref()
15548 .unwrap();
15549 let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
15550 assert_eq!(types, vec!["text", "hardBreak", "text"]);
15551 assert_eq!(p[0].text.as_deref(), Some(expected.0));
15552 assert_eq!(p[2].text.as_deref(), Some(expected.1));
15553 }
15554 }
15555
15556 #[test]
15557 fn issue_433_heading_hardbreak_roundtrips() {
15558 let adf_json = r#"{"version":1,"type":"doc","content":[{
15560 "type":"heading",
15561 "attrs":{"level":1},
15562 "content":[
15563 {"type":"text","text":"Line one"},
15564 {"type":"hardBreak"},
15565 {"type":"text","text":"Line two"}
15566 ]
15567 }]}"#;
15568 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15569 let md = adf_to_markdown(&doc).unwrap();
15570 let rt = markdown_to_adf(&md).unwrap();
15571
15572 assert_eq!(
15573 rt.content.len(),
15574 1,
15575 "Should remain a single heading, got {} blocks",
15576 rt.content.len()
15577 );
15578 assert_eq!(rt.content[0].node_type, "heading");
15579 let inlines = rt.content[0].content.as_ref().unwrap();
15580 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
15581 assert_eq!(
15582 types,
15583 vec!["text", "hardBreak", "text"],
15584 "hardBreak should be preserved, got: {types:?}"
15585 );
15586 assert_eq!(inlines[0].text.as_deref(), Some("Line one"));
15587 assert_eq!(inlines[2].text.as_deref(), Some("Line two"));
15588 }
15589
15590 #[test]
15591 fn issue_433_heading_hardbreak_jfm_indentation() {
15592 let adf_json = r#"{"version":1,"type":"doc","content":[{
15594 "type":"heading",
15595 "attrs":{"level":2},
15596 "content":[
15597 {"type":"text","text":"Title"},
15598 {"type":"hardBreak"},
15599 {"type":"text","text":"Subtitle"}
15600 ]
15601 }]}"#;
15602 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15603 let md = adf_to_markdown(&doc).unwrap();
15604 assert!(
15605 md.contains("## Title\\\n Subtitle"),
15606 "Continuation should be indented, got:\n{md}"
15607 );
15608 }
15609
15610 #[test]
15611 fn issue_433_heading_hardbreak_from_jfm() {
15612 let md = "# First\\\n Second\n";
15614 let doc = markdown_to_adf(md).unwrap();
15615
15616 assert_eq!(doc.content.len(), 1);
15617 assert_eq!(doc.content[0].node_type, "heading");
15618 let inlines = doc.content[0].content.as_ref().unwrap();
15619 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
15620 assert_eq!(types, vec!["text", "hardBreak", "text"]);
15621 assert_eq!(inlines[0].text.as_deref(), Some("First"));
15622 assert_eq!(inlines[2].text.as_deref(), Some("Second"));
15623 }
15624
15625 #[test]
15626 fn issue_433_heading_consecutive_hardbreaks_roundtrip() {
15627 let adf_json = r#"{"version":1,"type":"doc","content":[{
15629 "type":"heading",
15630 "attrs":{"level":3},
15631 "content":[
15632 {"type":"text","text":"A"},
15633 {"type":"hardBreak"},
15634 {"type":"hardBreak"},
15635 {"type":"text","text":"B"}
15636 ]
15637 }]}"#;
15638 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15639 let md = adf_to_markdown(&doc).unwrap();
15640 let rt = markdown_to_adf(&md).unwrap();
15641
15642 assert_eq!(rt.content.len(), 1, "Should remain a single heading");
15643 assert_eq!(rt.content[0].node_type, "heading");
15644 let inlines = rt.content[0].content.as_ref().unwrap();
15645 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
15646 assert_eq!(types, vec!["text", "hardBreak", "hardBreak", "text"]);
15647 }
15648
15649 #[test]
15650 fn issue_433_heading_with_strong_and_hardbreak_roundtrips() {
15651 let adf_json = r#"{"version":1,"type":"doc","content":[{
15653 "type":"heading",
15654 "attrs":{"level":1},
15655 "content":[
15656 {"type":"text","text":"Bold title","marks":[{"type":"strong"}]},
15657 {"type":"hardBreak"},
15658 {"type":"text","text":"plain continuation"}
15659 ]
15660 }]}"#;
15661 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15662 let md = adf_to_markdown(&doc).unwrap();
15663 let rt = markdown_to_adf(&md).unwrap();
15664
15665 assert_eq!(rt.content.len(), 1);
15666 assert_eq!(rt.content[0].node_type, "heading");
15667 let inlines = rt.content[0].content.as_ref().unwrap();
15668 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
15669 assert_eq!(types, vec!["text", "hardBreak", "text"]);
15670 assert_eq!(inlines[0].text.as_deref(), Some("Bold title"));
15671 assert_eq!(inlines[2].text.as_deref(), Some("plain continuation"));
15672 }
15673
15674 #[test]
15675 fn issue_433_heading_with_link_and_hardbreak_roundtrips() {
15676 let adf_json = r#"{"version":1,"type":"doc","content":[{
15678 "type":"heading",
15679 "attrs":{"level":1},
15680 "content":[
15681 {"type":"text","text":"Click here","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
15682 {"type":"hardBreak"},
15683 {"type":"text","text":"Subtitle text"}
15684 ]
15685 }]}"#;
15686 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15687 let md = adf_to_markdown(&doc).unwrap();
15688 let rt = markdown_to_adf(&md).unwrap();
15689
15690 assert_eq!(rt.content.len(), 1);
15691 assert_eq!(rt.content[0].node_type, "heading");
15692 let inlines = rt.content[0].content.as_ref().unwrap();
15693 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
15694 assert_eq!(types, vec!["text", "hardBreak", "text"]);
15695 assert_eq!(inlines[2].text.as_deref(), Some("Subtitle text"));
15696 }
15697
15698 #[test]
15699 fn has_trailing_hard_break_backslash() {
15700 assert!(has_trailing_hard_break("text\\"));
15701 assert!(has_trailing_hard_break("**bold**\\"));
15702 }
15703
15704 #[test]
15705 fn has_trailing_hard_break_trailing_spaces() {
15706 assert!(has_trailing_hard_break("text "));
15707 assert!(has_trailing_hard_break("word "));
15708 }
15709
15710 #[test]
15711 fn has_trailing_hard_break_false() {
15712 assert!(!has_trailing_hard_break("plain text"));
15713 assert!(!has_trailing_hard_break("text "));
15714 assert!(!has_trailing_hard_break(""));
15715 }
15716
15717 #[test]
15718 fn collect_hardbreak_continuations_collects_indented() {
15719 let input = "first\\\n second\n third\n";
15722 let mut parser = MarkdownParser::new(input);
15723 parser.advance(); let mut text = "first\\".to_string();
15725 parser.collect_hardbreak_continuations(&mut text);
15726 assert_eq!(text, "first\\\nsecond");
15727 }
15728
15729 #[test]
15730 fn collect_hardbreak_continuations_stops_at_non_indented() {
15731 let input = "first\\\nnot indented\n";
15732 let mut parser = MarkdownParser::new(input);
15733 parser.advance();
15734 let mut text = "first\\".to_string();
15735 parser.collect_hardbreak_continuations(&mut text);
15736 assert_eq!(text, "first\\");
15738 }
15739
15740 #[test]
15741 fn collect_hardbreak_continuations_no_trailing_break() {
15742 let input = "plain\n indented\n";
15744 let mut parser = MarkdownParser::new(input);
15745 parser.advance();
15746 let mut text = "plain".to_string();
15747 parser.collect_hardbreak_continuations(&mut text);
15748 assert_eq!(text, "plain");
15749 }
15750
15751 #[test]
15752 fn collect_hardbreak_continuations_chained() {
15753 let input = "a\\\n b\\\n c\\\n d\n";
15755 let mut parser = MarkdownParser::new(input);
15756 parser.advance();
15757 let mut text = "a\\".to_string();
15758 parser.collect_hardbreak_continuations(&mut text);
15759 assert_eq!(text, "a\\\nb\\\nc\\\nd");
15760 }
15761
15762 #[test]
15763 fn collect_hardbreak_continuations_stops_before_image_line() {
15764 let input = "text\\\n {type=file id=x}\n";
15767 let mut parser = MarkdownParser::new(input);
15768 parser.advance(); let mut text = "text\\".to_string();
15770 parser.collect_hardbreak_continuations(&mut text);
15771 assert_eq!(text, "text\\");
15773 assert!(!parser.at_end());
15775 assert!(parser.current_line().contains(""));
15776 }
15777
15778 #[test]
15779 fn ordered_list_with_sub_content_after_hardbreak() {
15780 let adf_json = r#"{"version":1,"type":"doc","content":[
15783 {"type":"orderedList","attrs":{"order":1},"content":[
15784 {"type":"listItem","content":[
15785 {"type":"paragraph","content":[
15786 {"type":"text","text":"parent"},
15787 {"type":"hardBreak"},
15788 {"type":"text","text":"continued"}
15789 ]},
15790 {"type":"bulletList","content":[
15791 {"type":"listItem","content":[
15792 {"type":"paragraph","content":[
15793 {"type":"text","text":"child"}
15794 ]}
15795 ]}
15796 ]}
15797 ]}
15798 ]}
15799 ]}"#;
15800 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15801 let md = adf_to_markdown(&doc).unwrap();
15802 let rt = markdown_to_adf(&md).unwrap();
15803
15804 assert_eq!(rt.content.len(), 1);
15805 assert_eq!(rt.content[0].node_type, "orderedList");
15806 let item_content = rt.content[0].content.as_ref().unwrap()[0]
15807 .content
15808 .as_ref()
15809 .unwrap();
15810 let p = item_content[0].content.as_ref().unwrap();
15812 let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
15813 assert_eq!(types, vec!["text", "hardBreak", "text"]);
15814 assert_eq!(p[0].text.as_deref(), Some("parent"));
15815 assert_eq!(p[2].text.as_deref(), Some("continued"));
15816 assert_eq!(item_content[1].node_type, "bulletList");
15818 }
15819
15820 #[test]
15821 fn render_list_item_content_no_content() {
15822 let item = AdfNode {
15824 node_type: "listItem".to_string(),
15825 attrs: None,
15826 content: None,
15827 text: None,
15828 marks: None,
15829 local_id: None,
15830 parameters: None,
15831 };
15832 let mut output = String::new();
15833 let opts = RenderOptions::default();
15834 render_list_item_content(&item, &mut output, &opts);
15835 assert_eq!(output, "\n");
15836 }
15837
15838 #[test]
15839 fn render_list_item_content_empty_content() {
15840 let item = AdfNode::list_item(vec![]);
15842 let mut output = String::new();
15843 let opts = RenderOptions::default();
15844 render_list_item_content(&item, &mut output, &opts);
15845 assert_eq!(output, "\n");
15846 }
15847
15848 #[test]
15849 fn plus_bullet_paragraph_roundtrips() {
15850 let adf_json = r#"{"version":1,"type":"doc","content":[
15853 {"type":"paragraph","content":[
15854 {"type":"text","text":"+ plus"}
15855 ]}
15856 ]}"#;
15857 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15858 let md = adf_to_markdown(&doc).unwrap();
15859 let rt = markdown_to_adf(&md).unwrap();
15860 assert_eq!(rt.content[0].node_type, "paragraph");
15861 assert_eq!(
15862 rt.content[0].content.as_ref().unwrap()[0]
15863 .text
15864 .as_deref()
15865 .unwrap(),
15866 "+ plus"
15867 );
15868 }
15869
15870 #[test]
15873 fn issue_430_file_media_in_bullet_list_roundtrip() {
15874 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15877 {"type":"listItem","content":[{
15878 "type":"mediaSingle",
15879 "attrs":{"layout":"center","width":1009,"widthType":"pixel"},
15880 "content":[{
15881 "type":"media",
15882 "attrs":{"collection":"contentId-123","height":576,"id":"00066e8e-554e-4d7e-af59-a0ef2888bdb6","type":"file","width":1009}
15883 }]
15884 }]}
15885 ]}]}"#;
15886 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15887 let md = adf_to_markdown(&doc).unwrap();
15888 let rt = markdown_to_adf(&md).unwrap();
15889
15890 let list = &rt.content[0];
15891 assert_eq!(list.node_type, "bulletList");
15892 let item = &list.content.as_ref().unwrap()[0];
15893 assert_eq!(item.node_type, "listItem");
15894 let ms = &item.content.as_ref().unwrap()[0];
15895 assert_eq!(ms.node_type, "mediaSingle");
15896 let ms_attrs = ms.attrs.as_ref().unwrap();
15897 assert_eq!(ms_attrs["layout"], "center");
15898 assert_eq!(ms_attrs["width"], 1009);
15899 assert_eq!(ms_attrs["widthType"], "pixel");
15900 let media = &ms.content.as_ref().unwrap()[0];
15901 assert_eq!(media.node_type, "media");
15902 let m_attrs = media.attrs.as_ref().unwrap();
15903 assert_eq!(m_attrs["type"], "file");
15904 assert_eq!(m_attrs["id"], "00066e8e-554e-4d7e-af59-a0ef2888bdb6");
15905 assert_eq!(m_attrs["collection"], "contentId-123");
15906 assert_eq!(m_attrs["height"], 576);
15907 assert_eq!(m_attrs["width"], 1009);
15908 }
15909
15910 #[test]
15911 fn issue_430_file_media_in_ordered_list_roundtrip() {
15912 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15914 {"type":"listItem","content":[{
15915 "type":"mediaSingle",
15916 "attrs":{"layout":"center"},
15917 "content":[{
15918 "type":"media",
15919 "attrs":{"type":"file","id":"abc-123","collection":"contentId-456","height":100,"width":200}
15920 }]
15921 }]}
15922 ]}]}"#;
15923 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15924 let md = adf_to_markdown(&doc).unwrap();
15925 let rt = markdown_to_adf(&md).unwrap();
15926
15927 let list = &rt.content[0];
15928 assert_eq!(list.node_type, "orderedList");
15929 let item = &list.content.as_ref().unwrap()[0];
15930 assert_eq!(item.node_type, "listItem");
15931 let ms = &item.content.as_ref().unwrap()[0];
15932 assert_eq!(ms.node_type, "mediaSingle");
15933 let media = &ms.content.as_ref().unwrap()[0];
15934 assert_eq!(media.node_type, "media");
15935 let m_attrs = media.attrs.as_ref().unwrap();
15936 assert_eq!(m_attrs["type"], "file");
15937 assert_eq!(m_attrs["id"], "abc-123");
15938 assert_eq!(m_attrs["collection"], "contentId-456");
15939 }
15940
15941 #[test]
15942 fn issue_430_external_media_in_bullet_list_roundtrip() {
15943 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15945 {"type":"listItem","content":[{
15946 "type":"mediaSingle",
15947 "attrs":{"layout":"center"},
15948 "content":[{
15949 "type":"media",
15950 "attrs":{"type":"external","url":"https://example.com/img.png","alt":"Photo"}
15951 }]
15952 }]}
15953 ]}]}"#;
15954 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15955 let md = adf_to_markdown(&doc).unwrap();
15956 let rt = markdown_to_adf(&md).unwrap();
15957
15958 let list = &rt.content[0];
15959 assert_eq!(list.node_type, "bulletList");
15960 let item = &list.content.as_ref().unwrap()[0];
15961 let ms = &item.content.as_ref().unwrap()[0];
15962 assert_eq!(ms.node_type, "mediaSingle");
15963 let media = &ms.content.as_ref().unwrap()[0];
15964 assert_eq!(media.node_type, "media");
15965 let m_attrs = media.attrs.as_ref().unwrap();
15966 assert_eq!(m_attrs["type"], "external");
15967 assert_eq!(m_attrs["url"], "https://example.com/img.png");
15968 }
15969
15970 #[test]
15971 fn issue_430_media_with_paragraph_siblings_in_list_item() {
15972 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15975 {"type":"listItem","content":[
15976 {"type":"paragraph","content":[{"type":"text","text":"Caption:"}]},
15977 {"type":"mediaSingle","attrs":{"layout":"center"},
15978 "content":[{"type":"media","attrs":{"type":"file","id":"img-001","collection":"col-1","height":50,"width":100}}]}
15979 ]}
15980 ]}]}"#;
15981 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15982 let md = adf_to_markdown(&doc).unwrap();
15983 let rt = markdown_to_adf(&md).unwrap();
15984
15985 let item = &rt.content[0].content.as_ref().unwrap()[0];
15986 let children = item.content.as_ref().unwrap();
15987 assert_eq!(children.len(), 2, "expected 2 children in listItem");
15988 assert_eq!(children[0].node_type, "paragraph");
15989 assert_eq!(children[1].node_type, "mediaSingle");
15990 let media = &children[1].content.as_ref().unwrap()[0];
15991 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-001");
15992 }
15993
15994 #[test]
15995 fn issue_430_multiple_media_in_list_items() {
15996 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15998 {"type":"listItem","content":[{
15999 "type":"mediaSingle","attrs":{"layout":"center"},
16000 "content":[{"type":"media","attrs":{"type":"file","id":"img-a","collection":"c1","height":10,"width":20}}]
16001 }]},
16002 {"type":"listItem","content":[{
16003 "type":"mediaSingle","attrs":{"layout":"center"},
16004 "content":[{"type":"media","attrs":{"type":"file","id":"img-b","collection":"c2","height":30,"width":40}}]
16005 }]}
16006 ]}]}"#;
16007 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16008 let md = adf_to_markdown(&doc).unwrap();
16009 let rt = markdown_to_adf(&md).unwrap();
16010
16011 let items = rt.content[0].content.as_ref().unwrap();
16012 assert_eq!(items.len(), 2);
16013 for (i, expected_id) in [("img-a", "c1"), ("img-b", "c2")].iter().enumerate() {
16014 let ms = &items[i].content.as_ref().unwrap()[0];
16015 assert_eq!(ms.node_type, "mediaSingle");
16016 let m_attrs = ms.content.as_ref().unwrap()[0].attrs.as_ref().unwrap();
16017 assert_eq!(m_attrs["id"], expected_id.0);
16018 assert_eq!(m_attrs["collection"], expected_id.1);
16019 }
16020 }
16021
16022 #[test]
16023 fn issue_430_jfm_to_adf_media_in_bullet_item() {
16024 let md = "- ![](){type=file id=test-id collection=col-1 height=100 width=200}\n";
16027 let doc = markdown_to_adf(md).unwrap();
16028
16029 let list = &doc.content[0];
16030 assert_eq!(list.node_type, "bulletList");
16031 let item = &list.content.as_ref().unwrap()[0];
16032 let ms = &item.content.as_ref().unwrap()[0];
16033 assert_eq!(
16034 ms.node_type, "mediaSingle",
16035 "expected mediaSingle, got {}",
16036 ms.node_type
16037 );
16038 let media = &ms.content.as_ref().unwrap()[0];
16039 assert_eq!(media.node_type, "media");
16040 let m_attrs = media.attrs.as_ref().unwrap();
16041 assert_eq!(m_attrs["type"], "file");
16042 assert_eq!(m_attrs["id"], "test-id");
16043 }
16044
16045 #[test]
16046 fn issue_430_jfm_to_adf_media_in_ordered_item() {
16047 let md = "1. \n";
16049 let doc = markdown_to_adf(md).unwrap();
16050
16051 let list = &doc.content[0];
16052 assert_eq!(list.node_type, "orderedList");
16053 let item = &list.content.as_ref().unwrap()[0];
16054 let ms = &item.content.as_ref().unwrap()[0];
16055 assert_eq!(
16056 ms.node_type, "mediaSingle",
16057 "expected mediaSingle, got {}",
16058 ms.node_type
16059 );
16060 }
16061
16062 #[test]
16063 fn issue_430_media_then_paragraph_in_bullet_list_roundtrip() {
16064 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
16067 {"type":"listItem","content":[
16068 {"type":"mediaSingle","attrs":{"layout":"center"},
16069 "content":[{"type":"media","attrs":{"type":"file","id":"img-first","collection":"col-1","height":50,"width":100}}]},
16070 {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
16071 ]}
16072 ]}]}"#;
16073 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16074 let md = adf_to_markdown(&doc).unwrap();
16075 let rt = markdown_to_adf(&md).unwrap();
16076
16077 let item = &rt.content[0].content.as_ref().unwrap()[0];
16078 let children = item.content.as_ref().unwrap();
16079 assert_eq!(children.len(), 2, "expected 2 children in listItem");
16080 assert_eq!(children[0].node_type, "mediaSingle");
16081 let media = &children[0].content.as_ref().unwrap()[0];
16082 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-first");
16083 assert_eq!(children[1].node_type, "paragraph");
16084 }
16085
16086 #[test]
16087 fn issue_430_media_then_paragraph_in_ordered_list_roundtrip() {
16088 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
16090 {"type":"listItem","content":[
16091 {"type":"mediaSingle","attrs":{"layout":"center"},
16092 "content":[{"type":"media","attrs":{"type":"file","id":"img-ord","collection":"col-2","height":60,"width":120}}]},
16093 {"type":"paragraph","content":[{"type":"text","text":"Description"}]}
16094 ]}
16095 ]}]}"#;
16096 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16097 let md = adf_to_markdown(&doc).unwrap();
16098 let rt = markdown_to_adf(&md).unwrap();
16099
16100 let item = &rt.content[0].content.as_ref().unwrap()[0];
16101 let children = item.content.as_ref().unwrap();
16102 assert_eq!(children.len(), 2, "expected 2 children in listItem");
16103 assert_eq!(children[0].node_type, "mediaSingle");
16104 assert_eq!(children[1].node_type, "paragraph");
16105 }
16106
16107 #[test]
16108 fn issue_430_external_media_with_width_type_roundtrip() {
16109 let adf_json = r#"{"version":1,"type":"doc","content":[{
16111 "type":"mediaSingle",
16112 "attrs":{"layout":"wide","width":800,"widthType":"pixel"},
16113 "content":[{
16114 "type":"media",
16115 "attrs":{"type":"external","url":"https://example.com/photo.png","alt":"wide photo"}
16116 }]
16117 }]}"#;
16118 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16119 let md = adf_to_markdown(&doc).unwrap();
16120 assert!(
16121 md.contains("widthType=pixel"),
16122 "expected widthType=pixel in markdown, got: {md}"
16123 );
16124 let rt = markdown_to_adf(&md).unwrap();
16125 let ms = &rt.content[0];
16126 assert_eq!(ms.node_type, "mediaSingle");
16127 let ms_attrs = ms.attrs.as_ref().unwrap();
16128 assert_eq!(ms_attrs["widthType"], "pixel");
16129 assert_eq!(ms_attrs["width"], 800);
16130 assert_eq!(ms_attrs["layout"], "wide");
16131 }
16132
16133 #[test]
16136 fn issue_490_paragraph_with_hardbreak_then_media_single_roundtrip() {
16137 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
16140 {"type":"listItem","content":[
16141 {"type":"paragraph","content":[
16142 {"type":"text","text":"Item with image:"},
16143 {"type":"hardBreak"}
16144 ]},
16145 {"type":"mediaSingle","attrs":{"layout":"center","width":400,"widthType":"pixel"},
16146 "content":[{"type":"media","attrs":{
16147 "id":"aabbccdd-1234-5678-abcd-aabbccdd1234",
16148 "type":"file",
16149 "collection":"contentId-123456",
16150 "width":800,
16151 "height":600
16152 }}]}
16153 ]}
16154 ]}]}"#;
16155 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16156 let md = adf_to_markdown(&doc).unwrap();
16157 let rt = markdown_to_adf(&md).unwrap();
16158
16159 let item = &rt.content[0].content.as_ref().unwrap()[0];
16160 let children = item.content.as_ref().unwrap();
16161 assert_eq!(children.len(), 2, "expected 2 children in listItem");
16162 assert_eq!(children[0].node_type, "paragraph");
16163 assert_eq!(
16164 children[1].node_type, "mediaSingle",
16165 "expected mediaSingle, got {:?}",
16166 children[1].node_type
16167 );
16168 let media = &children[1].content.as_ref().unwrap()[0];
16169 let m_attrs = media.attrs.as_ref().unwrap();
16170 assert_eq!(m_attrs["id"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
16171 assert_eq!(m_attrs["collection"], "contentId-123456");
16172 assert_eq!(m_attrs["height"], 600);
16173 assert_eq!(m_attrs["width"], 800);
16174 }
16175
16176 #[test]
16177 fn issue_490_paragraph_with_hardbreak_then_media_single_ordered_list() {
16178 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
16180 {"type":"listItem","content":[
16181 {"type":"paragraph","content":[
16182 {"type":"text","text":"Step with screenshot:"},
16183 {"type":"hardBreak"}
16184 ]},
16185 {"type":"mediaSingle","attrs":{"layout":"center"},
16186 "content":[{"type":"media","attrs":{
16187 "id":"ord-media-id","type":"file","collection":"col-ord","width":640,"height":480
16188 }}]}
16189 ]}
16190 ]}]}"#;
16191 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16192 let md = adf_to_markdown(&doc).unwrap();
16193 let rt = markdown_to_adf(&md).unwrap();
16194
16195 let item = &rt.content[0].content.as_ref().unwrap()[0];
16196 let children = item.content.as_ref().unwrap();
16197 assert_eq!(children.len(), 2, "expected 2 children in listItem");
16198 assert_eq!(children[0].node_type, "paragraph");
16199 assert_eq!(children[1].node_type, "mediaSingle");
16200 let media = &children[1].content.as_ref().unwrap()[0];
16201 assert_eq!(media.attrs.as_ref().unwrap()["id"], "ord-media-id");
16202 }
16203
16204 #[test]
16205 fn issue_490_hardbreak_continuation_does_not_swallow_media_line() {
16206 let md = "- Item with image:\\\n ![](){type=file id=test-490 collection=col height=100 width=200}\n";
16209 let doc = markdown_to_adf(md).unwrap();
16210
16211 let item = &doc.content[0].content.as_ref().unwrap()[0];
16212 let children = item.content.as_ref().unwrap();
16213 assert_eq!(children.len(), 2, "expected 2 children in listItem");
16214 assert_eq!(children[0].node_type, "paragraph");
16215 assert_eq!(
16216 children[1].node_type, "mediaSingle",
16217 "expected mediaSingle as second child, got {:?}",
16218 children[1].node_type
16219 );
16220 let media = &children[1].content.as_ref().unwrap()[0];
16221 assert_eq!(media.attrs.as_ref().unwrap()["id"], "test-490");
16222 }
16223
16224 #[test]
16225 fn issue_490_hardbreak_continuation_still_works_for_text() {
16226 let md = "- first line\\\n second line\n";
16228 let doc = markdown_to_adf(md).unwrap();
16229
16230 let item = &doc.content[0].content.as_ref().unwrap()[0];
16231 let children = item.content.as_ref().unwrap();
16232 assert_eq!(
16233 children.len(),
16234 1,
16235 "expected 1 child (paragraph) in listItem"
16236 );
16237 assert_eq!(children[0].node_type, "paragraph");
16238 let inlines = children[0].content.as_ref().unwrap();
16239 assert_eq!(inlines.len(), 3);
16241 assert_eq!(inlines[0].node_type, "text");
16242 assert_eq!(inlines[1].node_type, "hardBreak");
16243 assert_eq!(inlines[2].node_type, "text");
16244 }
16245
16246 #[test]
16247 fn issue_490_external_media_after_hardbreak_roundtrip() {
16248 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
16250 {"type":"listItem","content":[
16251 {"type":"paragraph","content":[
16252 {"type":"text","text":"See image:"},
16253 {"type":"hardBreak"}
16254 ]},
16255 {"type":"mediaSingle","attrs":{"layout":"center"},
16256 "content":[{"type":"media","attrs":{
16257 "type":"external","url":"https://example.com/photo.png","alt":"photo"
16258 }}]}
16259 ]}
16260 ]}]}"#;
16261 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16262 let md = adf_to_markdown(&doc).unwrap();
16263 let rt = markdown_to_adf(&md).unwrap();
16264
16265 let item = &rt.content[0].content.as_ref().unwrap()[0];
16266 let children = item.content.as_ref().unwrap();
16267 assert_eq!(children.len(), 2);
16268 assert_eq!(children[0].node_type, "paragraph");
16269 assert_eq!(children[1].node_type, "mediaSingle");
16270 let media = &children[1].content.as_ref().unwrap()[0];
16271 let m_attrs = media.attrs.as_ref().unwrap();
16272 assert_eq!(m_attrs["url"], "https://example.com/photo.png");
16273 }
16274
16275 #[test]
16276 fn issue_490_multiple_hardbreaks_then_media_single() {
16277 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
16279 {"type":"listItem","content":[
16280 {"type":"paragraph","content":[
16281 {"type":"text","text":"line one"},
16282 {"type":"hardBreak"},
16283 {"type":"text","text":"line two"},
16284 {"type":"hardBreak"}
16285 ]},
16286 {"type":"mediaSingle","attrs":{"layout":"center"},
16287 "content":[{"type":"media","attrs":{
16288 "type":"file","id":"multi-hb","collection":"col-m","width":320,"height":240
16289 }}]}
16290 ]}
16291 ]}]}"#;
16292 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16293 let md = adf_to_markdown(&doc).unwrap();
16294 let rt = markdown_to_adf(&md).unwrap();
16295
16296 let item = &rt.content[0].content.as_ref().unwrap()[0];
16297 let children = item.content.as_ref().unwrap();
16298 assert_eq!(children.len(), 2, "expected paragraph + mediaSingle");
16299 assert_eq!(children[0].node_type, "paragraph");
16300 assert_eq!(children[1].node_type, "mediaSingle");
16301 let media = &children[1].content.as_ref().unwrap()[0];
16302 assert_eq!(media.attrs.as_ref().unwrap()["id"], "multi-hb");
16303 }
16304
16305 #[test]
16308 fn issue_525_listitem_localid_with_mediasingle_roundtrip() {
16309 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"}]}]}]}]}]}]}"#;
16312 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16313 let md = adf_to_markdown(&doc).unwrap();
16314 let rt = markdown_to_adf(&md).unwrap();
16315
16316 let list = &rt.content[0];
16317 assert_eq!(list.node_type, "bulletList");
16318 let item = &list.content.as_ref().unwrap()[0];
16319 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
16321 assert_eq!(
16322 item_attrs["localId"], "aabbccdd-1234-5678-abcd-000000000001",
16323 "listItem localId must survive round-trip"
16324 );
16325 let children = item.content.as_ref().unwrap();
16326 assert_eq!(
16327 children.len(),
16328 3,
16329 "expected mediaSingle + paragraph + bulletList"
16330 );
16331 assert_eq!(children[0].node_type, "mediaSingle");
16332 assert_eq!(children[1].node_type, "paragraph");
16333 assert_eq!(children[2].node_type, "bulletList");
16334 }
16335
16336 #[test]
16337 fn issue_525_listitem_localid_with_mediasingle_only() {
16338 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
16340 {"type":"listItem","attrs":{"localId":"li-media-only"},"content":[
16341 {"type":"mediaSingle","attrs":{"layout":"center"},
16342 "content":[{"type":"media","attrs":{"type":"file","id":"m-001","collection":"c1","height":50,"width":100}}]}
16343 ]}
16344 ]}]}"#;
16345 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16346 let md = adf_to_markdown(&doc).unwrap();
16347 let rt = markdown_to_adf(&md).unwrap();
16348
16349 let item = &rt.content[0].content.as_ref().unwrap()[0];
16350 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
16351 assert_eq!(
16352 item_attrs["localId"], "li-media-only",
16353 "listItem localId must survive when sole child is mediaSingle"
16354 );
16355 assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
16356 }
16357
16358 #[test]
16359 fn issue_525_listitem_localid_with_external_media() {
16360 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
16362 {"type":"listItem","attrs":{"localId":"li-ext-media"},"content":[
16363 {"type":"mediaSingle","attrs":{"layout":"center"},
16364 "content":[{"type":"media","attrs":{"type":"external","url":"https://example.com/img.png","alt":"photo"}}]}
16365 ]}
16366 ]}]}"#;
16367 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16368 let md = adf_to_markdown(&doc).unwrap();
16369 let rt = markdown_to_adf(&md).unwrap();
16370
16371 let item = &rt.content[0].content.as_ref().unwrap()[0];
16372 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
16373 assert_eq!(
16374 item_attrs["localId"], "li-ext-media",
16375 "listItem localId must survive with external mediaSingle"
16376 );
16377 }
16378
16379 #[test]
16380 fn issue_525_listitem_localid_with_mediasingle_in_ordered_list() {
16381 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
16383 {"type":"listItem","attrs":{"localId":"li-ord-media"},"content":[
16384 {"type":"mediaSingle","attrs":{"layout":"center","width":200,"widthType":"pixel"},
16385 "content":[{"type":"media","attrs":{"type":"file","id":"ord-m-001","collection":"col-ord","height":80,"width":160}}]},
16386 {"type":"paragraph","content":[{"type":"text","text":"ordered item text"}]}
16387 ]}
16388 ]}]}"#;
16389 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16390 let md = adf_to_markdown(&doc).unwrap();
16391 let rt = markdown_to_adf(&md).unwrap();
16392
16393 let item = &rt.content[0].content.as_ref().unwrap()[0];
16394 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
16395 assert_eq!(
16396 item_attrs["localId"], "li-ord-media",
16397 "listItem localId must survive in ordered list with mediaSingle"
16398 );
16399 let children = item.content.as_ref().unwrap();
16400 assert_eq!(children[0].node_type, "mediaSingle");
16401 assert_eq!(children[1].node_type, "paragraph");
16402 }
16403
16404 #[test]
16405 fn issue_525_jfm_localid_on_mediasingle_line_parses_correctly() {
16406 let md = "- ![](){type=file id=test-525 collection=col height=100 width=200 mediaWidth=100 widthType=pixel} {localId=li-jfm-525}\n";
16409 let doc = markdown_to_adf(md).unwrap();
16410
16411 let item = &doc.content[0].content.as_ref().unwrap()[0];
16412 let item_attrs = item
16413 .attrs
16414 .as_ref()
16415 .expect("listItem attrs must be present from JFM");
16416 assert_eq!(item_attrs["localId"], "li-jfm-525");
16417 assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
16418 }
16419
16420 #[test]
16421 fn issue_525_encoding_emits_localid_on_mediasingle_line() {
16422 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
16425 {"type":"listItem","attrs":{"localId":"li-emit-check"},"content":[
16426 {"type":"mediaSingle","attrs":{"layout":"center"},
16427 "content":[{"type":"media","attrs":{"type":"file","id":"m-emit","collection":"c-emit","height":10,"width":20}}]}
16428 ]}
16429 ]}]}"#;
16430 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16431 let md = adf_to_markdown(&doc).unwrap();
16432 assert!(
16433 md.contains("{localId=li-emit-check}"),
16434 "expected localId in JFM output, got: {md}"
16435 );
16436 for line in md.lines() {
16438 if line.contains("![") {
16439 assert!(
16440 line.contains("localId=li-emit-check"),
16441 "localId must be on the same line as the image: {line}"
16442 );
16443 }
16444 }
16445 }
16446
16447 #[test]
16450 fn adf_placeholder_to_markdown() {
16451 let doc = AdfDocument {
16452 version: 1,
16453 doc_type: "doc".to_string(),
16454 content: vec![AdfNode::paragraph(vec![AdfNode::placeholder(
16455 "Type something here",
16456 )])],
16457 };
16458 let md = adf_to_markdown(&doc).unwrap();
16459 assert!(
16460 md.contains(":placeholder[Type something here]"),
16461 "expected :placeholder directive, got: {md}"
16462 );
16463 }
16464
16465 #[test]
16466 fn markdown_placeholder_to_adf() {
16467 let doc = markdown_to_adf("Before :placeholder[Enter name] after").unwrap();
16468 let content = doc.content[0].content.as_ref().unwrap();
16469 assert_eq!(content[1].node_type, "placeholder");
16470 let attrs = content[1].attrs.as_ref().unwrap();
16471 assert_eq!(attrs["text"], "Enter name");
16472 }
16473
16474 #[test]
16475 fn placeholder_round_trip() {
16476 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"placeholder","attrs":{"text":"Type something here"}}]}]}"#;
16477 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16478 let md = adf_to_markdown(&doc).unwrap();
16479 let rt = markdown_to_adf(&md).unwrap();
16480 let content = rt.content[0].content.as_ref().unwrap();
16481 assert_eq!(content.len(), 1);
16482 assert_eq!(content[0].node_type, "placeholder");
16483 let attrs = content[0].attrs.as_ref().unwrap();
16484 assert_eq!(attrs["text"], "Type something here");
16485 }
16486
16487 #[test]
16488 fn placeholder_empty_text() {
16489 let doc = AdfDocument {
16490 version: 1,
16491 doc_type: "doc".to_string(),
16492 content: vec![AdfNode::paragraph(vec![AdfNode::placeholder("")])],
16493 };
16494 let md = adf_to_markdown(&doc).unwrap();
16495 assert!(
16496 md.contains(":placeholder[]"),
16497 "expected empty placeholder directive, got: {md}"
16498 );
16499 let rt = markdown_to_adf(&md).unwrap();
16500 let content = rt.content[0].content.as_ref().unwrap();
16501 assert_eq!(content[0].node_type, "placeholder");
16502 assert_eq!(content[0].attrs.as_ref().unwrap()["text"], "");
16503 }
16504
16505 #[test]
16506 fn placeholder_with_surrounding_text() {
16507 let md = "Click :placeholder[here] to continue\n";
16508 let doc = markdown_to_adf(md).unwrap();
16509 let content = doc.content[0].content.as_ref().unwrap();
16510 assert_eq!(content[0].text.as_deref(), Some("Click "));
16511 assert_eq!(content[1].node_type, "placeholder");
16512 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "here");
16513 assert_eq!(content[2].text.as_deref(), Some(" to continue"));
16514 }
16515
16516 #[test]
16517 fn placeholder_missing_attrs() {
16518 let doc = AdfDocument {
16520 version: 1,
16521 doc_type: "doc".to_string(),
16522 content: vec![AdfNode::paragraph(vec![AdfNode {
16523 node_type: "placeholder".to_string(),
16524 attrs: None,
16525 content: None,
16526 text: None,
16527 marks: None,
16528 local_id: None,
16529 parameters: None,
16530 }])],
16531 };
16532 let md = adf_to_markdown(&doc).unwrap();
16533 assert!(!md.contains("placeholder"));
16535 }
16536
16537 #[test]
16539 fn mention_in_table_bullet_list_preserves_id_and_local_id() {
16540 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":" "}]}]}]}]}]}]}]}"#;
16541 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16542 let md = adf_to_markdown(&doc).unwrap();
16543 let rt = markdown_to_adf(&md).unwrap();
16544
16545 let cell = &rt.content[0].content.as_ref().unwrap()[0]
16547 .content
16548 .as_ref()
16549 .unwrap()[0];
16550 let list = &cell.content.as_ref().unwrap()[0];
16551 let list_item = &list.content.as_ref().unwrap()[0];
16552
16553 assert!(
16555 list_item
16556 .attrs
16557 .as_ref()
16558 .and_then(|a| a.get("localId"))
16559 .is_none(),
16560 "localId should stay on the mention, not the listItem"
16561 );
16562
16563 let para = &list_item.content.as_ref().unwrap()[0];
16564 let inlines = para.content.as_ref().unwrap();
16565
16566 assert_eq!(inlines.len(), 3, "expected 3 inline nodes, got {inlines:?}");
16568
16569 assert_eq!(inlines[0].node_type, "text");
16570 assert_eq!(inlines[0].text.as_deref(), Some("prefix text "));
16571
16572 assert_eq!(inlines[1].node_type, "mention");
16573 let mention_attrs = inlines[1].attrs.as_ref().unwrap();
16574 assert_eq!(
16575 mention_attrs["id"], "aabbccdd11223344aabbccdd",
16576 "mention id must be preserved"
16577 );
16578 assert_eq!(
16579 mention_attrs["localId"], "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
16580 "mention localId must be preserved"
16581 );
16582 assert_eq!(mention_attrs["text"], "@Alice Example");
16583
16584 assert_eq!(inlines[2].node_type, "text");
16585 assert_eq!(inlines[2].text.as_deref(), Some(" "));
16586 }
16587
16588 #[test]
16589 fn mention_in_bullet_list_preserves_id_and_local_id() {
16590 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":" "}]}]}]}]}"#;
16592 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16593 let md = adf_to_markdown(&doc).unwrap();
16594 let rt = markdown_to_adf(&md).unwrap();
16595
16596 let list_item = &rt.content[0].content.as_ref().unwrap()[0];
16597 assert!(
16598 list_item
16599 .attrs
16600 .as_ref()
16601 .and_then(|a| a.get("localId"))
16602 .is_none(),
16603 "localId should stay on the mention, not the listItem"
16604 );
16605
16606 let para = &list_item.content.as_ref().unwrap()[0];
16607 let inlines = para.content.as_ref().unwrap();
16608 assert_eq!(inlines[0].node_type, "mention");
16609 let mention_attrs = inlines[0].attrs.as_ref().unwrap();
16610 assert_eq!(mention_attrs["id"], "user123");
16611 assert_eq!(
16612 mention_attrs["localId"],
16613 "11111111-2222-3333-4444-555555555555"
16614 );
16615 }
16616
16617 #[test]
16618 fn mention_in_ordered_list_preserves_id_and_local_id() {
16619 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"}}]}]}]}]}"#;
16620 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16621 let md = adf_to_markdown(&doc).unwrap();
16622 let rt = markdown_to_adf(&md).unwrap();
16623
16624 let list_item = &rt.content[0].content.as_ref().unwrap()[0];
16625 assert!(
16626 list_item
16627 .attrs
16628 .as_ref()
16629 .and_then(|a| a.get("localId"))
16630 .is_none(),
16631 "localId should stay on the mention, not the listItem"
16632 );
16633
16634 let para = &list_item.content.as_ref().unwrap()[0];
16635 let inlines = para.content.as_ref().unwrap();
16636 assert_eq!(inlines[1].node_type, "mention");
16637 let mention_attrs = inlines[1].attrs.as_ref().unwrap();
16638 assert_eq!(mention_attrs["id"], "xyz");
16639 assert_eq!(mention_attrs["localId"], "aaaa-bbbb");
16640 }
16641
16642 #[test]
16643 fn list_item_own_local_id_with_mention_both_preserved() {
16644 let md = "- hello :mention[@Eve]{id=e1 localId=mention-lid} {localId=item-lid}\n";
16647 let doc = markdown_to_adf(md).unwrap();
16648 let list_item = &doc.content[0].content.as_ref().unwrap()[0];
16649
16650 let item_attrs = list_item.attrs.as_ref().unwrap();
16652 assert_eq!(item_attrs["localId"], "item-lid");
16653
16654 let para = &list_item.content.as_ref().unwrap()[0];
16656 let inlines = para.content.as_ref().unwrap();
16657 let mention = inlines.iter().find(|n| n.node_type == "mention").unwrap();
16658 let mention_attrs = mention.attrs.as_ref().unwrap();
16659 assert_eq!(mention_attrs["id"], "e1");
16660 assert_eq!(mention_attrs["localId"], "mention-lid");
16661 }
16662
16663 #[test]
16664 fn extract_trailing_local_id_ignores_directive_attrs() {
16665 let line = "text :mention[@X]{id=abc localId=uuid}";
16668 let (text, lid, plid) = extract_trailing_local_id(line);
16669 assert_eq!(text, line, "text should be unchanged");
16670 assert!(
16671 lid.is_none(),
16672 "should not extract localId from directive attrs"
16673 );
16674 assert!(plid.is_none());
16675 }
16676
16677 #[test]
16678 fn extract_trailing_local_id_matches_standalone_block() {
16679 let line = "some text {localId=abc-123}";
16681 let (text, lid, plid) = extract_trailing_local_id(line);
16682 assert_eq!(text, "some text");
16683 assert_eq!(lid.as_deref(), Some("abc-123"));
16684 assert!(plid.is_none());
16685 }
16686
16687 #[test]
16690 fn newline_in_text_node_roundtrips_in_bullet_list() {
16691 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"}]}]}]}]}"#;
16695 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16696 let md = adf_to_markdown(&doc).unwrap();
16697 let rt = markdown_to_adf(&md).unwrap();
16698
16699 assert_eq!(rt.content.len(), 1);
16701 let list = &rt.content[0];
16702 assert_eq!(list.node_type, "bulletList");
16703 let items = list.content.as_ref().unwrap();
16704 assert_eq!(items.len(), 1);
16705
16706 let item_content = items[0].content.as_ref().unwrap();
16708 assert_eq!(
16709 item_content.len(),
16710 1,
16711 "listItem should have exactly one paragraph"
16712 );
16713 assert_eq!(item_content[0].node_type, "paragraph");
16714
16715 let inlines = item_content[0].content.as_ref().unwrap();
16717 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16718 assert_eq!(
16719 types,
16720 vec!["text", "hardBreak", "text"],
16721 "embedded newline should stay in a single text node, not produce extra hardBreaks"
16722 );
16723 assert_eq!(
16724 inlines[2].text.as_deref(),
16725 Some("first command\nsecond command")
16726 );
16727 }
16728
16729 #[test]
16730 fn newline_in_text_node_roundtrips_in_ordered_list() {
16731 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"}]}]}]}]}"#;
16733 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16734 let md = adf_to_markdown(&doc).unwrap();
16735 let rt = markdown_to_adf(&md).unwrap();
16736
16737 let list = &rt.content[0];
16738 assert_eq!(list.node_type, "orderedList");
16739 let items = list.content.as_ref().unwrap();
16740 assert_eq!(items.len(), 1);
16741
16742 let item_content = items[0].content.as_ref().unwrap();
16743 assert_eq!(item_content.len(), 1);
16744 assert_eq!(item_content[0].node_type, "paragraph");
16745
16746 let inlines = item_content[0].content.as_ref().unwrap();
16747 assert_eq!(inlines.len(), 1);
16748 assert_eq!(inlines[0].node_type, "text");
16749 assert_eq!(inlines[0].text.as_deref(), Some("first\nsecond"));
16750 }
16751
16752 #[test]
16753 fn newline_in_text_node_roundtrips_in_paragraph() {
16754 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello\nworld"}]}]}"#;
16757 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16758 let md = adf_to_markdown(&doc).unwrap();
16759 assert!(
16760 md.contains("hello\\nworld"),
16761 "newline in text node should render as escaped \\n: {md:?}"
16762 );
16763
16764 let rt = markdown_to_adf(&md).unwrap();
16765 let inlines = rt.content[0].content.as_ref().unwrap();
16766 assert_eq!(inlines.len(), 1);
16767 assert_eq!(inlines[0].text.as_deref(), Some("hello\nworld"));
16768 }
16769
16770 #[test]
16771 fn multiple_newlines_in_text_node_roundtrip() {
16772 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a\nb\nc"}]}]}]}]}"#;
16774 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16775 let md = adf_to_markdown(&doc).unwrap();
16776 let rt = markdown_to_adf(&md).unwrap();
16777
16778 let item_content = rt.content[0].content.as_ref().unwrap()[0]
16779 .content
16780 .as_ref()
16781 .unwrap();
16782 assert_eq!(item_content.len(), 1);
16783
16784 let inlines = item_content[0].content.as_ref().unwrap();
16785 assert_eq!(inlines.len(), 1);
16786 assert_eq!(inlines[0].text.as_deref(), Some("a\nb\nc"));
16787 }
16788
16789 #[test]
16790 fn newline_in_marked_text_node_roundtrips() {
16791 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold\ntext","marks":[{"type":"strong"}]}]}]}"#;
16794 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16795 let md = adf_to_markdown(&doc).unwrap();
16796 assert!(
16797 md.contains("**bold\\ntext**"),
16798 "bold text with embedded newline should stay in one marked run: {md:?}"
16799 );
16800
16801 let rt = markdown_to_adf(&md).unwrap();
16802 let inlines = rt.content[0].content.as_ref().unwrap();
16803 assert_eq!(inlines.len(), 1);
16804 assert_eq!(inlines[0].text.as_deref(), Some("bold\ntext"));
16805 assert!(inlines[0]
16806 .marks
16807 .as_ref()
16808 .unwrap()
16809 .iter()
16810 .any(|m| m.mark_type == "strong"));
16811 }
16812
16813 #[test]
16814 fn trailing_newline_in_text_node_roundtrips() {
16815 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"trailing\n"}]}]}"#;
16818 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16819 let md = adf_to_markdown(&doc).unwrap();
16820 assert!(
16821 md.contains("trailing\\n"),
16822 "trailing newline should be escaped: {md:?}"
16823 );
16824
16825 let rt = markdown_to_adf(&md).unwrap();
16826 let inlines = rt.content[0].content.as_ref().unwrap();
16827 assert_eq!(inlines.len(), 1);
16828 assert_eq!(inlines[0].text.as_deref(), Some("trailing\n"));
16829 }
16830
16831 #[test]
16832 fn hardbreak_and_embedded_newline_are_distinct() {
16833 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"}]}]}"#;
16836 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16837 let md = adf_to_markdown(&doc).unwrap();
16838 let rt = markdown_to_adf(&md).unwrap();
16839
16840 let inlines = rt.content[0].content.as_ref().unwrap();
16841 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16842 assert_eq!(
16843 types,
16844 vec!["text", "hardBreak", "text", "hardBreak", "text"]
16845 );
16846 assert_eq!(inlines[0].text.as_deref(), Some("before"));
16847 assert_eq!(inlines[2].text.as_deref(), Some("mid\ndle"));
16848 assert_eq!(inlines[4].text.as_deref(), Some("after"));
16849 }
16850
16851 #[test]
16854 fn issue_472_bullet_list_trailing_hardbreak_roundtrips() {
16855 let adf_json = r#"{"version":1,"type":"doc","content":[
16858 {"type":"bulletList","content":[
16859 {"type":"listItem","content":[
16860 {"type":"paragraph","content":[
16861 {"type":"text","text":"First item"},
16862 {"type":"hardBreak"}
16863 ]}
16864 ]},
16865 {"type":"listItem","content":[
16866 {"type":"paragraph","content":[
16867 {"type":"text","text":"Second item"}
16868 ]}
16869 ]}
16870 ]}
16871 ]}"#;
16872 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16873 let md = adf_to_markdown(&doc).unwrap();
16874 let rt = markdown_to_adf(&md).unwrap();
16875
16876 assert_eq!(
16878 rt.content.len(),
16879 1,
16880 "Should be 1 block (bulletList), got {}",
16881 rt.content.len()
16882 );
16883 assert_eq!(rt.content[0].node_type, "bulletList");
16884 let items = rt.content[0].content.as_ref().unwrap();
16885 assert_eq!(
16886 items.len(),
16887 2,
16888 "Should have 2 listItems, got {}",
16889 items.len()
16890 );
16891
16892 let p1 = items[0].content.as_ref().unwrap()[0]
16894 .content
16895 .as_ref()
16896 .unwrap();
16897 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
16898 assert_eq!(types1, vec!["text", "hardBreak"]);
16899 assert_eq!(p1[0].text.as_deref(), Some("First item"));
16900
16901 let p2 = items[1].content.as_ref().unwrap()[0]
16903 .content
16904 .as_ref()
16905 .unwrap();
16906 assert_eq!(p2[0].text.as_deref(), Some("Second item"));
16907 }
16908
16909 #[test]
16910 fn issue_472_ordered_list_trailing_hardbreak_roundtrips() {
16911 let adf_json = r#"{"version":1,"type":"doc","content":[
16913 {"type":"orderedList","attrs":{"order":1},"content":[
16914 {"type":"listItem","content":[
16915 {"type":"paragraph","content":[
16916 {"type":"text","text":"Alpha"},
16917 {"type":"hardBreak"}
16918 ]}
16919 ]},
16920 {"type":"listItem","content":[
16921 {"type":"paragraph","content":[
16922 {"type":"text","text":"Beta"}
16923 ]}
16924 ]}
16925 ]}
16926 ]}"#;
16927 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16928 let md = adf_to_markdown(&doc).unwrap();
16929 let rt = markdown_to_adf(&md).unwrap();
16930
16931 assert_eq!(rt.content.len(), 1);
16932 assert_eq!(rt.content[0].node_type, "orderedList");
16933 let items = rt.content[0].content.as_ref().unwrap();
16934 assert_eq!(items.len(), 2);
16935
16936 let p1 = items[0].content.as_ref().unwrap()[0]
16937 .content
16938 .as_ref()
16939 .unwrap();
16940 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
16941 assert_eq!(types1, vec!["text", "hardBreak"]);
16942 assert_eq!(p1[0].text.as_deref(), Some("Alpha"));
16943 }
16944
16945 #[test]
16946 fn issue_472_trailing_hardbreak_jfm_no_blank_line() {
16947 let adf_json = r#"{"version":1,"type":"doc","content":[
16950 {"type":"bulletList","content":[
16951 {"type":"listItem","content":[
16952 {"type":"paragraph","content":[
16953 {"type":"text","text":"Hello"},
16954 {"type":"hardBreak"}
16955 ]}
16956 ]},
16957 {"type":"listItem","content":[
16958 {"type":"paragraph","content":[
16959 {"type":"text","text":"World"}
16960 ]}
16961 ]}
16962 ]}
16963 ]}"#;
16964 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16965 let md = adf_to_markdown(&doc).unwrap();
16966
16967 assert_eq!(md, "- Hello\\\n- World\n");
16969 }
16970
16971 #[test]
16972 fn issue_472_multiple_trailing_hardbreaks_roundtrip() {
16973 let adf_json = r#"{"version":1,"type":"doc","content":[
16975 {"type":"bulletList","content":[
16976 {"type":"listItem","content":[
16977 {"type":"paragraph","content":[
16978 {"type":"text","text":"Item"},
16979 {"type":"hardBreak"},
16980 {"type":"hardBreak"}
16981 ]}
16982 ]},
16983 {"type":"listItem","content":[
16984 {"type":"paragraph","content":[
16985 {"type":"text","text":"Next"}
16986 ]}
16987 ]}
16988 ]}
16989 ]}"#;
16990 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16991 let md = adf_to_markdown(&doc).unwrap();
16992 let rt = markdown_to_adf(&md).unwrap();
16993
16994 assert_eq!(rt.content.len(), 1);
16996 assert_eq!(rt.content[0].node_type, "bulletList");
16997 let items = rt.content[0].content.as_ref().unwrap();
16998 assert_eq!(items.len(), 2);
16999
17000 let p1 = items[0].content.as_ref().unwrap()[0]
17002 .content
17003 .as_ref()
17004 .unwrap();
17005 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
17006 assert_eq!(types1, vec!["text", "hardBreak", "hardBreak"]);
17007 }
17008
17009 #[test]
17010 fn issue_472_hardbreak_mid_and_trailing_roundtrip() {
17011 let adf_json = r#"{"version":1,"type":"doc","content":[
17013 {"type":"bulletList","content":[
17014 {"type":"listItem","content":[
17015 {"type":"paragraph","content":[
17016 {"type":"text","text":"Line one"},
17017 {"type":"hardBreak"},
17018 {"type":"text","text":"Line two"},
17019 {"type":"hardBreak"}
17020 ]}
17021 ]},
17022 {"type":"listItem","content":[
17023 {"type":"paragraph","content":[
17024 {"type":"text","text":"Other item"}
17025 ]}
17026 ]}
17027 ]}
17028 ]}"#;
17029 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17030 let md = adf_to_markdown(&doc).unwrap();
17031 let rt = markdown_to_adf(&md).unwrap();
17032
17033 assert_eq!(rt.content.len(), 1);
17034 assert_eq!(rt.content[0].node_type, "bulletList");
17035 let items = rt.content[0].content.as_ref().unwrap();
17036 assert_eq!(items.len(), 2);
17037
17038 let p1 = items[0].content.as_ref().unwrap()[0]
17039 .content
17040 .as_ref()
17041 .unwrap();
17042 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
17043 assert_eq!(types1, vec!["text", "hardBreak", "text", "hardBreak"]);
17044 assert_eq!(p1[0].text.as_deref(), Some("Line one"));
17045 assert_eq!(p1[2].text.as_deref(), Some("Line two"));
17046 }
17047
17048 #[test]
17049 fn issue_472_only_hardbreak_in_listitem_paragraph() {
17050 let adf_json = r#"{"version":1,"type":"doc","content":[
17052 {"type":"bulletList","content":[
17053 {"type":"listItem","content":[
17054 {"type":"paragraph","content":[
17055 {"type":"hardBreak"}
17056 ]}
17057 ]},
17058 {"type":"listItem","content":[
17059 {"type":"paragraph","content":[
17060 {"type":"text","text":"After"}
17061 ]}
17062 ]}
17063 ]}
17064 ]}"#;
17065 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17066 let md = adf_to_markdown(&doc).unwrap();
17067 let rt = markdown_to_adf(&md).unwrap();
17068
17069 assert_eq!(rt.content.len(), 1);
17071 assert_eq!(rt.content[0].node_type, "bulletList");
17072 let items = rt.content[0].content.as_ref().unwrap();
17073 assert_eq!(items.len(), 2);
17074 }
17075
17076 #[test]
17077 fn issue_472_three_items_middle_has_trailing_hardbreak() {
17078 let adf_json = r#"{"version":1,"type":"doc","content":[
17080 {"type":"bulletList","content":[
17081 {"type":"listItem","content":[
17082 {"type":"paragraph","content":[
17083 {"type":"text","text":"First"}
17084 ]}
17085 ]},
17086 {"type":"listItem","content":[
17087 {"type":"paragraph","content":[
17088 {"type":"text","text":"Second"},
17089 {"type":"hardBreak"}
17090 ]}
17091 ]},
17092 {"type":"listItem","content":[
17093 {"type":"paragraph","content":[
17094 {"type":"text","text":"Third"}
17095 ]}
17096 ]}
17097 ]}
17098 ]}"#;
17099 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17100 let md = adf_to_markdown(&doc).unwrap();
17101 let rt = markdown_to_adf(&md).unwrap();
17102
17103 assert_eq!(rt.content.len(), 1);
17104 assert_eq!(rt.content[0].node_type, "bulletList");
17105 let items = rt.content[0].content.as_ref().unwrap();
17106 assert_eq!(items.len(), 3);
17107 assert_eq!(
17108 items[0].content.as_ref().unwrap()[0]
17109 .content
17110 .as_ref()
17111 .unwrap()[0]
17112 .text
17113 .as_deref(),
17114 Some("First")
17115 );
17116 assert_eq!(
17117 items[2].content.as_ref().unwrap()[0]
17118 .content
17119 .as_ref()
17120 .unwrap()[0]
17121 .text
17122 .as_deref(),
17123 Some("Third")
17124 );
17125 }
17126
17127 #[test]
17130 fn issue_494_space_after_hardbreak_roundtrip() {
17131 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17134 {"type":"text","text":"Some text"},
17135 {"type":"hardBreak"},
17136 {"type":"text","text":" "}
17137 ]}]}"#;
17138 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17139 let md = adf_to_markdown(&doc).unwrap();
17140 let rt = markdown_to_adf(&md).unwrap();
17141 let inlines = rt.content[0].content.as_ref().unwrap();
17142 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17143 assert_eq!(
17144 types,
17145 vec!["text", "hardBreak", "text"],
17146 "space-only text node after hardBreak should survive round-trip"
17147 );
17148 assert_eq!(inlines[2].text.as_deref(), Some(" "));
17149 }
17150
17151 #[test]
17152 fn issue_494_multiple_spaces_after_hardbreak_roundtrip() {
17153 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17155 {"type":"text","text":"Hello"},
17156 {"type":"hardBreak"},
17157 {"type":"text","text":" "}
17158 ]}]}"#;
17159 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17160 let md = adf_to_markdown(&doc).unwrap();
17161 let rt = markdown_to_adf(&md).unwrap();
17162 let inlines = rt.content[0].content.as_ref().unwrap();
17163 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17164 assert_eq!(
17165 types,
17166 vec!["text", "hardBreak", "text"],
17167 "multi-space text node after hardBreak should survive round-trip"
17168 );
17169 assert_eq!(inlines[2].text.as_deref(), Some(" "));
17170 }
17171
17172 #[test]
17173 fn issue_494_space_then_text_after_hardbreak_roundtrip() {
17174 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17177 {"type":"text","text":"Before"},
17178 {"type":"hardBreak"},
17179 {"type":"text","text":" After"}
17180 ]}]}"#;
17181 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17182 let md = adf_to_markdown(&doc).unwrap();
17183 let rt = markdown_to_adf(&md).unwrap();
17184 let inlines = rt.content[0].content.as_ref().unwrap();
17185 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17186 assert_eq!(types, vec!["text", "hardBreak", "text"]);
17187 assert_eq!(inlines[2].text.as_deref(), Some(" After"));
17188 }
17189
17190 #[test]
17191 fn issue_494_hardbreak_then_space_then_hardbreak_roundtrip() {
17192 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17194 {"type":"text","text":"A"},
17195 {"type":"hardBreak"},
17196 {"type":"text","text":" "},
17197 {"type":"hardBreak"},
17198 {"type":"text","text":"B"}
17199 ]}]}"#;
17200 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17201 let md = adf_to_markdown(&doc).unwrap();
17202 let rt = markdown_to_adf(&md).unwrap();
17203 let inlines = rt.content[0].content.as_ref().unwrap();
17204 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17205 assert_eq!(
17206 types,
17207 vec!["text", "hardBreak", "text", "hardBreak", "text"],
17208 "space between two hardBreaks should survive round-trip"
17209 );
17210 assert_eq!(inlines[2].text.as_deref(), Some(" "));
17211 assert_eq!(inlines[4].text.as_deref(), Some("B"));
17212 }
17213
17214 #[test]
17215 fn issue_494_trailing_space_hardbreak_style_not_confused() {
17216 let md = "first paragraph\n\nsecond paragraph\n";
17219 let doc = markdown_to_adf(md).unwrap();
17220 assert_eq!(
17221 doc.content.len(),
17222 2,
17223 "blank line should still separate paragraphs"
17224 );
17225 }
17226
17227 #[test]
17228 fn issue_494_space_after_trailing_space_hardbreak_roundtrip() {
17229 let md = "line one \n \n";
17232 let doc = markdown_to_adf(md).unwrap();
17236 let inlines = doc.content[0].content.as_ref().unwrap();
17237 let has_text_after_break = inlines.iter().any(|n| {
17238 n.node_type == "text"
17239 && n.text
17240 .as_deref()
17241 .is_some_and(|t| t.trim().is_empty() && !t.is_empty())
17242 });
17243 assert!(
17244 has_text_after_break || inlines.len() >= 2,
17245 "space-only line after trailing-space hardBreak should be preserved"
17246 );
17247 }
17248
17249 #[test]
17250 fn issue_494_space_after_hardbreak_in_list_item_roundtrip() {
17251 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
17253 {"type":"listItem","content":[{"type":"paragraph","content":[
17254 {"type":"text","text":"item"},
17255 {"type":"hardBreak"},
17256 {"type":"text","text":" "}
17257 ]}]}
17258 ]}]}"#;
17259 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17260 let md = adf_to_markdown(&doc).unwrap();
17261 let rt = markdown_to_adf(&md).unwrap();
17262 let list = &rt.content[0];
17263 let item = &list.content.as_ref().unwrap()[0];
17264 let para = &item.content.as_ref().unwrap()[0];
17265 let inlines = para.content.as_ref().unwrap();
17266 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17267 assert_eq!(
17268 types,
17269 vec!["text", "hardBreak", "text"],
17270 "space after hardBreak in list item should survive round-trip"
17271 );
17272 assert_eq!(inlines[2].text.as_deref(), Some(" "));
17273 }
17274
17275 #[test]
17278 fn issue_510_trailing_double_space_paragraph_roundtrip() {
17279 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"}]}]}"#;
17282 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17283 let md = adf_to_markdown(&doc).unwrap();
17284 let rt = markdown_to_adf(&md).unwrap();
17285
17286 assert_eq!(
17288 rt.content.len(),
17289 2,
17290 "should produce two paragraphs, got: {}",
17291 rt.content.len()
17292 );
17293 assert_eq!(rt.content[0].node_type, "paragraph");
17294 assert_eq!(rt.content[1].node_type, "paragraph");
17295
17296 let p1 = rt.content[0].content.as_ref().unwrap();
17298 assert_eq!(
17299 p1[0].text.as_deref(),
17300 Some("first paragraph with trailing spaces "),
17301 "trailing spaces should be preserved in first paragraph"
17302 );
17303
17304 let p2 = rt.content[1].content.as_ref().unwrap();
17306 assert_eq!(p2[0].text.as_deref(), Some("second paragraph"));
17307
17308 let all_types: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
17310 assert!(
17311 !all_types.contains(&"hardBreak"),
17312 "trailing spaces should not produce hardBreak, got: {all_types:?}"
17313 );
17314 }
17315
17316 #[test]
17317 fn issue_510_trailing_triple_space_roundtrip() {
17318 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"text "}]},{"type":"paragraph","content":[{"type":"text","text":"next"}]}]}"#;
17320 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17321 let md = adf_to_markdown(&doc).unwrap();
17322 let rt = markdown_to_adf(&md).unwrap();
17323
17324 assert_eq!(rt.content.len(), 2, "should still be two paragraphs");
17325 let p1 = rt.content[0].content.as_ref().unwrap();
17326 assert_eq!(
17327 p1[0].text.as_deref(),
17328 Some("text "),
17329 "three trailing spaces should be preserved"
17330 );
17331 }
17332
17333 #[test]
17334 fn issue_510_trailing_spaces_with_backslash_roundtrip() {
17335 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\ "}]}]}"#;
17337 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17338 let md = adf_to_markdown(&doc).unwrap();
17339 let rt = markdown_to_adf(&md).unwrap();
17340 let p = rt.content[0].content.as_ref().unwrap();
17341 assert_eq!(
17342 p[0].text.as_deref(),
17343 Some("end\\ "),
17344 "backslash + trailing spaces should both survive"
17345 );
17346 }
17347
17348 #[test]
17349 fn issue_510_jfm_contains_escaped_trailing_space() {
17350 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello "}]}]}"#;
17352 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17353 let md = adf_to_markdown(&doc).unwrap();
17354 assert!(
17355 md.contains(r"\ "),
17356 "JFM should contain backslash-space escape for trailing spaces, got: {md:?}"
17357 );
17358 for line in md.lines() {
17360 assert!(
17361 !line.ends_with(" "),
17362 "no JFM line should end with two plain spaces, got: {line:?}"
17363 );
17364 }
17365 }
17366
17367 #[test]
17368 fn issue_510_single_trailing_space_not_escaped() {
17369 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"word "}]}]}"#;
17371 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17372 let md = adf_to_markdown(&doc).unwrap();
17373 assert!(
17374 !md.contains('\\'),
17375 "single trailing space should not be escaped, got: {md:?}"
17376 );
17377 let rt = markdown_to_adf(&md).unwrap();
17378 let p = rt.content[0].content.as_ref().unwrap();
17379 assert_eq!(p[0].text.as_deref(), Some("word "));
17380 }
17381
17382 #[test]
17383 fn issue_510_trailing_spaces_in_heading_roundtrip() {
17384 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"heading "}]}]}"#;
17386 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17387 let md = adf_to_markdown(&doc).unwrap();
17388 let rt = markdown_to_adf(&md).unwrap();
17389 let h = rt.content[0].content.as_ref().unwrap();
17390 assert_eq!(
17391 h[0].text.as_deref(),
17392 Some("heading "),
17393 "trailing spaces in heading should be preserved"
17394 );
17395 }
17396
17397 #[test]
17398 fn issue_510_trailing_spaces_in_list_item_roundtrip() {
17399 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item "}]}]}]}]}"#;
17401 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17402 let md = adf_to_markdown(&doc).unwrap();
17403 let rt = markdown_to_adf(&md).unwrap();
17404 let list = &rt.content[0];
17405 let item = &list.content.as_ref().unwrap()[0];
17406 let para = &item.content.as_ref().unwrap()[0];
17407 let inlines = para.content.as_ref().unwrap();
17408 assert_eq!(
17409 inlines[0].text.as_deref(),
17410 Some("item "),
17411 "trailing spaces in list item should be preserved"
17412 );
17413 }
17414
17415 #[test]
17416 fn issue_510_trailing_spaces_with_bold_mark_roundtrip() {
17417 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold ","marks":[{"type":"strong"}]}]}]}"#;
17421 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17422 let md = adf_to_markdown(&doc).unwrap();
17423 let rt = markdown_to_adf(&md).unwrap();
17424 let p = rt.content[0].content.as_ref().unwrap();
17425 assert_eq!(
17426 p[0].text.as_deref(),
17427 Some("bold "),
17428 "trailing spaces in bold text should be preserved"
17429 );
17430 }
17431
17432 #[test]
17433 fn issue_510_hardbreak_between_paragraphs_still_works() {
17434 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"line one"},{"type":"hardBreak"},{"type":"text","text":"line two"}]}]}"#;
17436 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17437 let md = adf_to_markdown(&doc).unwrap();
17438 let rt = markdown_to_adf(&md).unwrap();
17439 let inlines = rt.content[0].content.as_ref().unwrap();
17440 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17441 assert_eq!(
17442 types,
17443 vec!["text", "hardBreak", "text"],
17444 "explicit hardBreak should still round-trip"
17445 );
17446 }
17447
17448 #[test]
17449 fn issue_510_all_spaces_text_node_roundtrip() {
17450 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":" "}]}]}"#;
17452 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17453 let md = adf_to_markdown(&doc).unwrap();
17454 let rt = markdown_to_adf(&md).unwrap();
17455 let p = rt.content[0].content.as_ref().unwrap();
17456 assert_eq!(
17457 p[0].text.as_deref(),
17458 Some(" "),
17459 "space-only text node should survive round-trip"
17460 );
17461 }
17462
17463 #[test]
17466 fn issue_522_listitem_hardbreak_then_two_paragraphs_roundtrips() {
17467 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"}]}]}]}]}"#;
17470 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17471 let md = adf_to_markdown(&doc).unwrap();
17472 let rt = markdown_to_adf(&md).unwrap();
17473
17474 let items = rt.content[0].content.as_ref().unwrap();
17475 assert_eq!(items.len(), 1);
17476 let children = items[0].content.as_ref().unwrap();
17477 assert_eq!(
17478 children.len(),
17479 3,
17480 "Expected 3 paragraphs in listItem, got {}",
17481 children.len()
17482 );
17483 assert_eq!(children[0].node_type, "paragraph");
17484 assert_eq!(children[1].node_type, "paragraph");
17485 assert_eq!(children[2].node_type, "paragraph");
17486
17487 let text1 = children[1].content.as_ref().unwrap()[0]
17489 .text
17490 .as_deref()
17491 .unwrap();
17492 assert_eq!(text1, "second paragraph");
17493 let text2 = children[2].content.as_ref().unwrap()[0]
17494 .text
17495 .as_deref()
17496 .unwrap();
17497 assert_eq!(text2, "third paragraph");
17498 }
17499
17500 #[test]
17501 fn issue_522_ordered_list_hardbreak_then_paragraphs_roundtrips() {
17502 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"}]}]}]}]}"#;
17504 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17505 let md = adf_to_markdown(&doc).unwrap();
17506 let rt = markdown_to_adf(&md).unwrap();
17507
17508 let items = rt.content[0].content.as_ref().unwrap();
17509 let children = items[0].content.as_ref().unwrap();
17510 assert_eq!(
17511 children.len(),
17512 3,
17513 "Expected 3 paragraphs in ordered listItem, got {}",
17514 children.len()
17515 );
17516 assert_eq!(children[1].node_type, "paragraph");
17517 assert_eq!(children[2].node_type, "paragraph");
17518 assert_eq!(
17519 children[1].content.as_ref().unwrap()[0]
17520 .text
17521 .as_deref()
17522 .unwrap(),
17523 "second para"
17524 );
17525 assert_eq!(
17526 children[2].content.as_ref().unwrap()[0]
17527 .text
17528 .as_deref()
17529 .unwrap(),
17530 "third para"
17531 );
17532 }
17533
17534 #[test]
17535 fn issue_522_two_paragraphs_without_hardbreak_roundtrips() {
17536 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"}]}]}]}]}"#;
17538 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17539 let md = adf_to_markdown(&doc).unwrap();
17540 let rt = markdown_to_adf(&md).unwrap();
17541
17542 let items = rt.content[0].content.as_ref().unwrap();
17543 let children = items[0].content.as_ref().unwrap();
17544 assert_eq!(
17545 children.len(),
17546 2,
17547 "Expected 2 paragraphs in listItem, got {}",
17548 children.len()
17549 );
17550 assert_eq!(children[0].node_type, "paragraph");
17551 assert_eq!(children[1].node_type, "paragraph");
17552 }
17553
17554 #[test]
17555 fn issue_522_paragraph_then_nested_list_no_spurious_blank() {
17556 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"}]}]}]}]}]}]}"#;
17559 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17560 let md = adf_to_markdown(&doc).unwrap();
17561 assert!(
17563 !md.contains(" \n -"),
17564 "No blank separator between paragraph and nested list"
17565 );
17566 let rt = markdown_to_adf(&md).unwrap();
17567
17568 let items = rt.content[0].content.as_ref().unwrap();
17569 let children = items[0].content.as_ref().unwrap();
17570 assert_eq!(children.len(), 2);
17571 assert_eq!(children[0].node_type, "paragraph");
17572 assert_eq!(children[1].node_type, "bulletList");
17573 }
17574
17575 #[test]
17576 fn issue_522_three_paragraphs_no_hardbreak_roundtrips() {
17577 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"}]}]}]}]}"#;
17579 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17580 let md = adf_to_markdown(&doc).unwrap();
17581 let rt = markdown_to_adf(&md).unwrap();
17582
17583 let items = rt.content[0].content.as_ref().unwrap();
17584 let children = items[0].content.as_ref().unwrap();
17585 assert_eq!(
17586 children.len(),
17587 3,
17588 "Expected 3 paragraphs, got {}",
17589 children.len()
17590 );
17591 for (i, child) in children.iter().enumerate() {
17592 assert_eq!(
17593 child.node_type, "paragraph",
17594 "Child {} should be a paragraph",
17595 i
17596 );
17597 }
17598 }
17599
17600 #[test]
17601 fn issue_522_multiple_list_items_each_with_paragraphs() {
17602 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"}]}]}]}]}"#;
17604 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17605 let md = adf_to_markdown(&doc).unwrap();
17606 let rt = markdown_to_adf(&md).unwrap();
17607
17608 let items = rt.content[0].content.as_ref().unwrap();
17609 assert_eq!(items.len(), 2, "Expected 2 list items");
17610
17611 let item1 = items[0].content.as_ref().unwrap();
17612 assert_eq!(item1.len(), 2, "Item 1 should have 2 paragraphs");
17613
17614 let item2 = items[1].content.as_ref().unwrap();
17615 assert_eq!(item2.len(), 2, "Item 2 should have 2 paragraphs");
17616 let item2_p1_inlines = item2[0].content.as_ref().unwrap();
17618 let types: Vec<&str> = item2_p1_inlines
17619 .iter()
17620 .map(|n| n.node_type.as_str())
17621 .collect();
17622 assert_eq!(types, vec!["text", "hardBreak", "text"]);
17623 }
17624
17625 #[test]
17626 fn issue_531_blockquote_hardbreak_then_two_paragraphs_roundtrips() {
17627 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"}]}]}]}"#;
17631 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17632 let md = adf_to_markdown(&doc).unwrap();
17633 let rt = markdown_to_adf(&md).unwrap();
17634
17635 let children = rt.content[0].content.as_ref().unwrap();
17636 assert_eq!(
17637 children.len(),
17638 3,
17639 "Expected 3 paragraphs in blockquote, got {}",
17640 children.len()
17641 );
17642 assert_eq!(children[0].node_type, "paragraph");
17643 assert_eq!(children[1].node_type, "paragraph");
17644 assert_eq!(children[2].node_type, "paragraph");
17645
17646 let text1 = children[1].content.as_ref().unwrap()[0]
17647 .text
17648 .as_deref()
17649 .unwrap();
17650 assert_eq!(text1, "second paragraph");
17651 let text2 = children[2].content.as_ref().unwrap()[0]
17652 .text
17653 .as_deref()
17654 .unwrap();
17655 assert_eq!(text2, "third paragraph");
17656 }
17657
17658 #[test]
17659 fn issue_531_blockquote_two_paragraphs_without_hardbreak_roundtrips() {
17660 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"}]}]}]}"#;
17662 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17663 let md = adf_to_markdown(&doc).unwrap();
17664 let rt = markdown_to_adf(&md).unwrap();
17665
17666 let children = rt.content[0].content.as_ref().unwrap();
17667 assert_eq!(
17668 children.len(),
17669 2,
17670 "Expected 2 paragraphs in blockquote, got {}",
17671 children.len()
17672 );
17673 assert_eq!(children[0].node_type, "paragraph");
17674 assert_eq!(children[1].node_type, "paragraph");
17675 }
17676
17677 #[test]
17678 fn issue_531_blockquote_three_paragraphs_no_hardbreak_roundtrips() {
17679 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"}]}]}]}"#;
17681 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17682 let md = adf_to_markdown(&doc).unwrap();
17683 let rt = markdown_to_adf(&md).unwrap();
17684
17685 let children = rt.content[0].content.as_ref().unwrap();
17686 assert_eq!(
17687 children.len(),
17688 3,
17689 "Expected 3 paragraphs in blockquote, got {}",
17690 children.len()
17691 );
17692 for child in children {
17693 assert_eq!(child.node_type, "paragraph");
17694 }
17695 }
17696
17697 #[test]
17698 fn issue_531_blockquote_paragraph_then_list_no_spurious_blank() {
17699 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"}]}]}]}]}]}"#;
17702 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17703 let md = adf_to_markdown(&doc).unwrap();
17704 let rt = markdown_to_adf(&md).unwrap();
17705
17706 let children = rt.content[0].content.as_ref().unwrap();
17707 assert_eq!(children[0].node_type, "paragraph");
17708 assert_eq!(children[1].node_type, "bulletList");
17709 }
17710
17711 #[test]
17712 fn issue_531_blockquote_single_paragraph_unchanged() {
17713 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"solo"}]}]}]}"#;
17715 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17716 let md = adf_to_markdown(&doc).unwrap();
17717 let rt = markdown_to_adf(&md).unwrap();
17718
17719 let children = rt.content[0].content.as_ref().unwrap();
17720 assert_eq!(children.len(), 1);
17721 assert_eq!(children[0].node_type, "paragraph");
17722 let text = children[0].content.as_ref().unwrap()[0]
17723 .text
17724 .as_deref()
17725 .unwrap();
17726 assert_eq!(text, "solo");
17727 }
17728}