1use anyhow::Result;
8use chrono::NaiveDate;
9use tracing::{debug, warn};
10
11use crate::atlassian::adf::{AdfDocument, AdfMark, AdfNode};
12use crate::atlassian::attrs::{format_kv, parse_attrs, Attrs};
13use crate::atlassian::directive::{
14 is_container_close, try_parse_container_open, try_parse_inline_directive,
15 try_parse_leaf_directive,
16};
17
18pub fn markdown_to_adf(markdown: &str) -> Result<AdfDocument> {
22 debug!(
23 "markdown_to_adf: input {} bytes, {} lines",
24 markdown.len(),
25 markdown.lines().count()
26 );
27 let mut doc = AdfDocument::new();
28 let mut parser = MarkdownParser::new(markdown);
29 doc.content = parser.parse_blocks()?;
30 debug!(
31 "markdown_to_adf: produced {} top-level ADF nodes",
32 doc.content.len()
33 );
34 Ok(doc)
35}
36
37struct MarkdownParser<'a> {
39 lines: Vec<&'a str>,
40 pos: usize,
41}
42
43impl<'a> MarkdownParser<'a> {
44 fn new(input: &'a str) -> Self {
45 Self {
46 lines: input.lines().collect(),
47 pos: 0,
48 }
49 }
50
51 fn at_end(&self) -> bool {
52 self.pos >= self.lines.len()
53 }
54
55 fn current_line(&self) -> &'a str {
56 self.lines[self.pos]
57 }
58
59 fn advance(&mut self) {
60 self.pos += 1;
61 }
62
63 fn collect_hardbreak_continuations(&mut self, full_text: &mut String) {
71 while has_trailing_hard_break(full_text) && !self.at_end() {
72 if !self.try_append_hardbreak_continuation(full_text) {
73 break;
74 }
75 }
76 }
77
78 fn try_append_hardbreak_continuation(&mut self, full_text: &mut String) -> bool {
85 match self
91 .current_line()
92 .strip_prefix(" ")
93 .filter(|s| !is_block_level_continuation_marker(s.trim_start()))
94 {
95 Some(stripped) => {
96 full_text.push('\n');
97 full_text.push_str(stripped);
98 self.advance();
99 true
100 }
101 None => false,
102 }
103 }
104
105 fn parse_blocks(&mut self) -> Result<Vec<AdfNode>> {
106 let mut blocks = Vec::new();
107
108 while !self.at_end() {
109 let line = self.current_line();
110
111 if line.trim().is_empty() {
112 self.advance();
113 continue;
114 }
115
116 let mut node = if let Some(node) = self.try_heading() {
117 node
118 } else if let Some(node) = self.try_horizontal_rule() {
119 node
120 } else if let Some(node) = self.try_container_directive()? {
121 node
122 } else if let Some(node) = self.try_code_block()? {
123 node
124 } else if let Some(node) = self.try_table()? {
125 node
126 } else if let Some(node) = self.try_blockquote()? {
127 node
128 } else if let Some(node) = self.try_list()? {
129 node
130 } else if let Some(node) = self.try_leaf_directive() {
131 node
132 } else if let Some(node) = self.try_image() {
133 node
134 } else {
135 self.parse_paragraph()?
136 };
137
138 self.try_apply_block_attrs(&mut node);
140 blocks.push(node);
141 }
142
143 Ok(blocks)
144 }
145
146 fn try_heading(&mut self) -> Option<AdfNode> {
147 let line = self.current_line();
148 let trimmed = line.trim_start();
149
150 if !trimmed.starts_with('#') {
151 return None;
152 }
153
154 let level = trimmed.chars().take_while(|&c| c == '#').count();
155 if !(1..=6).contains(&level) || !trimmed[level..].starts_with(' ') {
156 return None;
157 }
158
159 let mut full_text = trimmed[level + 1..].to_string();
160 self.advance();
161 self.collect_hardbreak_continuations(&mut full_text);
163 let mut inline_nodes = parse_inline(&full_text);
164 if strip_heading_code_marks(&mut inline_nodes) > 0 {
169 warn!(
170 "Stripped inline `code` from heading (ADF forbids code marks on \
171 headings; kept the text as plain): {}",
172 full_text.trim()
173 );
174 }
175
176 #[allow(clippy::cast_possible_truncation)]
177 Some(AdfNode::heading(level as u8, inline_nodes))
178 }
179
180 fn try_horizontal_rule(&mut self) -> Option<AdfNode> {
181 let line = self.current_line().trim();
182 let is_rule = (line.starts_with("---") && line.chars().all(|c| c == '-'))
183 || (line.starts_with("***") && line.chars().all(|c| c == '*'))
184 || (line.starts_with("___") && line.chars().all(|c| c == '_'));
185
186 if is_rule && line.len() >= 3 {
187 self.advance();
188 Some(AdfNode::rule())
189 } else {
190 None
191 }
192 }
193
194 fn try_code_block(&mut self) -> Result<Option<AdfNode>> {
195 let line = self.current_line();
196 if !is_code_fence_opener(line) {
197 return Ok(None);
198 }
199
200 let language = line[3..].trim();
201 let language = if language == "\"\"" {
202 Some(String::new())
204 } else if language.is_empty() {
205 None
206 } else {
207 Some(language.to_string())
208 };
209
210 self.advance();
211 let mut code_lines = Vec::new();
212
213 while !self.at_end() {
214 let line = self.current_line();
215 if line.starts_with("```") {
216 self.advance();
217 break;
218 }
219 code_lines.push(line);
220 self.advance();
221 }
222
223 let code_text = code_lines.join("\n");
224
225 if language.as_deref() == Some("adf-unsupported") {
227 if let Ok(node) = serde_json::from_str::<AdfNode>(&code_text) {
228 return Ok(Some(node));
229 }
230 }
231
232 Ok(Some(AdfNode::code_block(language.as_deref(), &code_text)))
233 }
234
235 fn try_blockquote(&mut self) -> Result<Option<AdfNode>> {
236 let line = self.current_line();
237 if !line.starts_with('>') {
238 return Ok(None);
239 }
240
241 let mut quote_lines = Vec::new();
242 while !self.at_end() {
243 let line = self.current_line();
244 if let Some(rest) = line.strip_prefix("> ") {
245 quote_lines.push(rest);
246 self.advance();
247 } else if let Some(rest) = line.strip_prefix('>') {
248 quote_lines.push(rest);
249 self.advance();
250 } else {
251 break;
252 }
253 }
254
255 let quote_text = quote_lines.join("\n");
256 let mut inner_parser = MarkdownParser::new("e_text);
257 let inner_blocks = inner_parser.parse_blocks()?;
258
259 Ok(Some(AdfNode::blockquote(inner_blocks)))
260 }
261
262 fn try_list(&mut self) -> Result<Option<AdfNode>> {
263 let line = self.current_line();
264 let trimmed = line.trim_start();
265
266 let is_bullet =
267 trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ");
268 let ordered_match = parse_ordered_list_marker(trimmed);
269
270 if !is_bullet && ordered_match.is_none() {
271 return Ok(None);
272 }
273
274 if is_bullet {
275 self.parse_bullet_list()
276 } else {
277 let start = ordered_match.map_or(1, |(n, _)| n);
278 self.parse_ordered_list(start)
279 }
280 }
281
282 fn parse_bullet_list(&mut self) -> Result<Option<AdfNode>> {
283 let mut items = Vec::new();
284 let mut is_task_list = false;
285
286 while !self.at_end() {
287 let line = self.current_line();
288 let trimmed = line.trim_start();
289
290 if !(trimmed.starts_with("- ")
291 || trimmed.starts_with("* ")
292 || trimmed.starts_with("+ "))
293 {
294 break;
295 }
296
297 let after_marker = trimmed[2..].trim_start();
298
299 if let Some((state, text)) = try_parse_task_marker(after_marker) {
301 is_task_list = true;
302 self.advance();
303 let mut full_text = text.to_string();
307 self.collect_hardbreak_continuations(&mut full_text);
308 let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
309 let inline_nodes = parse_inline(item_text);
310 let content = if let Some(ref plid) = para_local_id {
314 let mut para = AdfNode::paragraph(inline_nodes);
315 if plid != "_" {
316 para.attrs = Some(serde_json::json!({"localId": plid}));
317 }
318 vec![para]
319 } else {
320 inline_nodes
321 };
322 let mut task = AdfNode::task_item(state, content);
323 if let Some(id) = local_id {
325 if let Some(ref mut attrs) = task.attrs {
326 attrs["localId"] = serde_json::Value::String(id);
327 }
328 }
329 let mut sub_lines: Vec<String> = Vec::new();
333 while !self.at_end() && self.current_line().starts_with(" ") {
334 let stripped = &self.current_line()[2..];
335 sub_lines.push(stripped.to_string());
336 self.advance();
337 }
338 if !sub_lines.is_empty() {
339 let sub_text = sub_lines.join("\n");
340 let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
341 let is_empty = task.content.as_ref().map_or(true, Vec::is_empty);
349 if is_empty && nested.len() == 1 && nested[0].node_type == "taskList" {
350 if let Some(task_items) = nested.remove(0).content {
351 task.content = Some(task_items);
352 }
353 if let Some(ref mut attrs) = task.attrs {
354 if let Some(obj) = attrs.as_object_mut() {
355 obj.remove("state");
356 }
357 }
358 items.push(task);
359 } else {
360 let mut sibling_task_lists = Vec::new();
366 let mut child_nodes = Vec::new();
367 for n in nested {
368 if n.node_type == "taskList" {
369 sibling_task_lists.push(n);
370 } else {
371 child_nodes.push(n);
372 }
373 }
374 if !child_nodes.is_empty() {
375 match task.content {
376 Some(ref mut content) => content.append(&mut child_nodes),
377 None => task.content = Some(child_nodes),
378 }
379 }
380 items.push(task);
381 items.append(&mut sibling_task_lists);
382 }
383 } else {
384 items.push(task);
385 }
386 } else {
387 let first_line = &trimmed[2..];
388 self.advance();
389 let mut full_text = first_line.to_string();
390 self.collect_hardbreak_continuations(&mut full_text);
391 let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
392 let mut sub_lines: Vec<String> = Vec::new();
396 while !self.at_end() {
397 let next = self.current_line();
398 if let Some(stripped) = next.strip_prefix(" ") {
399 sub_lines.push(stripped.to_string());
400 self.advance();
401 continue;
402 }
403 break;
404 }
405 let item_content =
406 parse_list_item_first_line(item_text, sub_lines, local_id, para_local_id)?;
407 items.push(item_content);
408 }
409 }
410
411 if items.is_empty() {
412 Ok(None)
413 } else if is_task_list {
414 Ok(Some(AdfNode::task_list(items)))
415 } else {
416 Ok(Some(AdfNode::bullet_list(items)))
417 }
418 }
419
420 fn parse_ordered_list(&mut self, start: u32) -> Result<Option<AdfNode>> {
421 let mut items = Vec::new();
422
423 while !self.at_end() {
424 let line = self.current_line();
425 let trimmed = line.trim_start();
426
427 if let Some((_, rest)) = parse_ordered_list_marker(trimmed) {
428 let first_line = rest.trim_start_matches(|c: char| c.is_ascii_whitespace());
429 self.advance();
430 let mut full_text = first_line.to_string();
431 self.collect_hardbreak_continuations(&mut full_text);
432 let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
433 let mut sub_lines: Vec<String> = Vec::new();
435 while !self.at_end() {
436 let next = self.current_line();
437 if let Some(stripped) = next.strip_prefix(" ") {
438 sub_lines.push(stripped.to_string());
439 self.advance();
440 continue;
441 }
442 break;
443 }
444 let item_content =
445 parse_list_item_first_line(item_text, sub_lines, local_id, para_local_id)?;
446 items.push(item_content);
447 } else {
448 break;
449 }
450 }
451
452 if items.is_empty() {
453 Ok(None)
454 } else {
455 let order = if start == 1 { None } else { Some(start) };
456 Ok(Some(AdfNode::ordered_list(items, order)))
457 }
458 }
459
460 fn try_apply_block_attrs(&mut self, node: &mut AdfNode) {
461 if self.at_end() {
462 return;
463 }
464 let line = self.current_line().trim();
465 if !line.starts_with('{') {
466 return;
467 }
468 let Some((_, attrs)) = parse_attrs(line, 0) else {
469 return;
470 };
471
472 let mut marks = Vec::new();
473 if let Some(align) = attrs.get("align") {
474 marks.push(AdfMark::alignment(align));
475 }
476 if let Some(indent) = attrs.get("indent") {
477 if let Ok(level) = indent.parse::<u32>() {
478 marks.push(AdfMark::indentation(level));
479 }
480 }
481 if let Some(mode) = attrs.get("breakout") {
482 let width = attrs
483 .get("breakoutWidth")
484 .and_then(|w| w.parse::<u32>().ok());
485 marks.push(AdfMark::breakout(mode, width));
486 }
487
488 let local_id = attrs.get("localId").map(str::to_string);
490
491 let order = if node.node_type == "orderedList" {
493 attrs.get("order").and_then(|v| v.parse::<u32>().ok())
494 } else {
495 None
496 };
497
498 let has_attrs = !marks.is_empty() || local_id.is_some() || order.is_some();
499 if has_attrs {
500 if !marks.is_empty() {
501 let existing = node.marks.get_or_insert_with(Vec::new);
502 existing.extend(marks);
503 }
504 if let Some(id) = local_id {
505 let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
506 node_attrs["localId"] = serde_json::Value::String(id);
507 }
508 if let Some(n) = order {
509 let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
510 node_attrs["order"] = serde_json::json!(n);
511 }
512 self.advance(); }
514 }
515
516 fn try_container_directive(&mut self) -> Result<Option<AdfNode>> {
517 let line = self.current_line();
518 let Some((d, colon_count)) = try_parse_container_open(line) else {
519 return Ok(None);
520 };
521 self.advance(); let mut inner_lines = Vec::new();
525 let mut depth: usize = 0;
526 while !self.at_end() {
527 let current = self.current_line();
528 if try_parse_container_open(current).is_some() {
529 depth += 1;
530 } else if depth == 0 && is_container_close(current, colon_count) {
531 self.advance(); break;
533 } else if depth > 0 && is_container_close(current, 3) {
534 depth -= 1;
535 }
536 inner_lines.push(current.to_string());
537 self.advance();
538 }
539
540 let inner_text = inner_lines.join("\n");
541
542 let node = match d.name.as_str() {
543 "panel" => {
544 let panel_type = d
545 .attrs
546 .as_ref()
547 .and_then(|a| a.get("type"))
548 .unwrap_or("info");
549 let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
550 let mut node = AdfNode::panel(panel_type, inner_blocks);
551 if let Some(ref attrs) = d.attrs {
553 if let Some(ref mut node_attrs) = node.attrs {
554 if let Some(icon) = attrs.get("icon") {
555 node_attrs["panelIcon"] = serde_json::Value::String(icon.to_string());
556 }
557 if let Some(color) = attrs.get("color") {
558 node_attrs["panelColor"] = serde_json::Value::String(color.to_string());
559 }
560 }
561 }
562 node
563 }
564 "expand" => {
565 let title = d.attrs.as_ref().and_then(|a| a.get("title"));
566 let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
567 let mut node = AdfNode::expand(title, inner_blocks);
568 pass_through_expand_params(&d.attrs, &mut node);
569 node
570 }
571 "nested-expand" => {
572 let title = d.attrs.as_ref().and_then(|a| a.get("title"));
573 let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
574 let mut node = AdfNode::nested_expand(title, inner_blocks);
575 pass_through_expand_params(&d.attrs, &mut node);
576 node
577 }
578 "layout" => {
579 let columns = self.parse_layout_columns(&inner_text)?;
581 AdfNode::layout_section(columns)
582 }
583 "decisions" => {
584 let items = parse_decision_items(&inner_text);
585 AdfNode::decision_list(items)
586 }
587 "table" => {
588 let rows = self.parse_directive_table_rows(&inner_text)?;
589 let mut table_attrs = serde_json::json!({});
590 if let Some(ref attrs) = d.attrs {
591 if let Some(layout) = attrs.get("layout") {
592 table_attrs["layout"] = serde_json::Value::String(layout.to_string());
593 }
594 if attrs.has_flag("numbered") {
595 table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
596 } else if attrs.get("numbered") == Some("false") {
597 table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
598 }
599 if let Some(tw) = attrs.get("width") {
600 if let Some(w) = parse_numeric_attr(tw) {
601 table_attrs["width"] = w;
602 }
603 }
604 if let Some(local_id) = attrs.get("localId") {
605 table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
606 }
607 }
608 if table_attrs == serde_json::json!({}) {
609 AdfNode::table(rows)
610 } else {
611 AdfNode::table_with_attrs(rows, table_attrs)
612 }
613 }
614 "extension" => {
615 let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
616 let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
617 let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
618 let mut node = AdfNode::bodied_extension(ext_type, ext_key, inner_blocks);
619 if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
620 {
621 if let Some(layout) = dir_attrs.get("layout") {
622 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
623 }
624 if let Some(local_id) = dir_attrs.get("localId") {
625 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
626 }
627 if let Some(params_str) = dir_attrs.get("params") {
628 if let Ok(params_val) =
629 serde_json::from_str::<serde_json::Value>(params_str)
630 {
631 node_attrs["parameters"] = params_val;
632 }
633 }
634 }
635 node
636 }
637 _ => return Ok(None),
638 };
639
640 Ok(Some(node))
641 }
642
643 fn parse_layout_columns(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
644 let mut columns = Vec::new();
645 let mut current_column_lines: Vec<String> = Vec::new();
646 let mut current_width: serde_json::Value = serde_json::json!(50);
647 let mut current_dir_attrs: Option<crate::atlassian::attrs::Attrs> = None;
648 let mut in_column = false;
649 let mut depth: usize = 0;
650
651 let lines: Vec<&str> = inner_text.lines().collect();
652 let mut i = 0;
653
654 while i < lines.len() {
655 let line = lines[i];
656 if let Some((col_d, _)) = try_parse_container_open(line) {
657 if col_d.name == "column" && depth == 0 {
658 if in_column && !current_column_lines.is_empty() {
660 let col_text = current_column_lines.join("\n");
661 let blocks = MarkdownParser::new(&col_text).parse_blocks()?;
662 let mut col = AdfNode::layout_column(current_width.clone(), blocks);
663 pass_through_local_id(¤t_dir_attrs, &mut col);
664 columns.push(col);
665 current_column_lines.clear();
666 }
667 current_width = col_d
668 .attrs
669 .as_ref()
670 .and_then(|a| a.get("width"))
671 .and_then(parse_numeric_attr)
672 .unwrap_or_else(|| serde_json::json!(50));
673 current_dir_attrs = col_d.attrs;
674 in_column = true;
675 i += 1;
676 continue;
677 }
678 if in_column {
679 depth += 1;
680 }
681 }
682 if in_column && is_container_close(line, 3) {
683 if depth > 0 {
684 depth -= 1;
685 current_column_lines.push(line.to_string());
686 i += 1;
687 continue;
688 }
689 let col_text = current_column_lines.join("\n");
691 let blocks = MarkdownParser::new(&col_text).parse_blocks()?;
692 let mut col = AdfNode::layout_column(current_width.clone(), blocks);
693 pass_through_local_id(¤t_dir_attrs, &mut col);
694 columns.push(col);
695 current_column_lines.clear();
696 current_dir_attrs = None;
697 in_column = false;
698 i += 1;
699 continue;
700 }
701 if in_column {
702 current_column_lines.push(line.to_string());
703 }
704 i += 1;
705 }
706
707 if in_column && !current_column_lines.is_empty() {
709 let col_text = current_column_lines.join("\n");
710 let blocks = MarkdownParser::new(&col_text).parse_blocks()?;
711 let mut col = AdfNode::layout_column(current_width, blocks);
712 pass_through_local_id(¤t_dir_attrs, &mut col);
713 columns.push(col);
714 }
715
716 Ok(columns)
717 }
718
719 fn parse_directive_table_rows(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
721 debug!(
722 "parse_directive_table_rows: {} lines of inner text",
723 inner_text.lines().count()
724 );
725 let mut rows = Vec::new();
726 let lines: Vec<&str> = inner_text.lines().collect();
727 let mut i = 0;
728
729 while i < lines.len() {
730 let line = lines[i];
731 if let Some((d, _)) = try_parse_container_open(line) {
732 if d.name == "tr" {
733 let tr_attrs = d.attrs.clone();
734 i += 1;
735 let (mut row, next_i) = self.parse_directive_table_row(&lines, i)?;
736 if let Some(ref attrs) = tr_attrs {
738 if let Some(local_id) = attrs.get("localId") {
739 let row_attrs = row.attrs.get_or_insert_with(|| serde_json::json!({}));
740 row_attrs["localId"] = serde_json::Value::String(local_id.to_string());
741 }
742 }
743 rows.push(row);
744 i = next_i;
745 continue;
746 }
747 if d.name == "caption" {
748 let dir_attrs = d.attrs.clone();
749 i += 1;
750 let mut caption_lines = Vec::new();
751 while i < lines.len() {
752 if is_container_close(lines[i], 3) {
753 i += 1;
754 break;
755 }
756 caption_lines.push(lines[i]);
757 i += 1;
758 }
759 let caption_text = caption_lines.join("\n");
760 let inline_nodes = parse_inline(&caption_text);
761 let mut caption = AdfNode::caption(inline_nodes);
762 pass_through_local_id(&dir_attrs, &mut caption);
763 rows.push(caption);
764 continue;
765 }
766 }
767 i += 1;
768 }
769
770 Ok(rows)
771 }
772
773 fn parse_directive_table_row(&self, lines: &[&str], start: usize) -> Result<(AdfNode, usize)> {
775 let mut cells = Vec::new();
776 let mut i = start;
777 let mut depth: usize = 0;
778
779 while i < lines.len() {
780 let line = lines[i];
781 if is_container_close(line, 3) {
782 if depth == 0 {
783 i += 1;
785 break;
786 }
787 depth -= 1;
788 i += 1;
789 continue;
790 }
791 if let Some((d, _)) = try_parse_container_open(line) {
792 if depth == 0 && (d.name == "th" || d.name == "td") {
793 let is_header = d.name == "th";
794 let cell_attrs = d.attrs.clone();
795 i += 1;
796 let (cell, next_i) =
797 self.parse_directive_table_cell(lines, i, is_header, cell_attrs)?;
798 cells.push(cell);
799 i = next_i;
800 continue;
801 }
802 depth += 1;
803 }
804 i += 1;
805 }
806
807 if cells.is_empty() {
808 let context = lines[start.saturating_sub(1)..lines.len().min(start + 3)].to_vec();
809 warn!(
810 "Directive table row at line {start} has no cells — \
811 Confluence requires at least one. Nearby lines: {context:?}"
812 );
813 }
814 debug!("Parsed directive table row: {} cells", cells.len());
815
816 Ok((AdfNode::table_row(cells), i))
817 }
818
819 fn parse_directive_table_cell(
821 &self,
822 lines: &[&str],
823 start: usize,
824 is_header: bool,
825 cell_attrs: Option<crate::atlassian::attrs::Attrs>,
826 ) -> Result<(AdfNode, usize)> {
827 let mut cell_lines = Vec::new();
828 let mut i = start;
829 let mut depth: usize = 0;
830
831 while i < lines.len() {
832 let line = lines[i];
833 if try_parse_container_open(line).is_some() {
834 depth += 1;
835 } else if is_container_close(line, 3) {
836 if depth == 0 {
837 i += 1;
838 break;
839 }
840 depth -= 1;
841 }
842 cell_lines.push(line.to_string());
843 i += 1;
844 }
845
846 let cell_text = cell_lines.join("\n");
847 let blocks = MarkdownParser::new(&cell_text).parse_blocks()?;
848
849 let adf_attrs = cell_attrs.as_ref().map(build_cell_attrs);
850 let cell_marks = cell_attrs
851 .as_ref()
852 .map(build_border_marks)
853 .unwrap_or_default();
854
855 let cell = if cell_marks.is_empty() {
856 if is_header {
857 if let Some(attrs) = adf_attrs {
858 AdfNode::table_header_with_attrs(blocks, attrs)
859 } else {
860 AdfNode::table_header(blocks)
861 }
862 } else if let Some(attrs) = adf_attrs {
863 AdfNode::table_cell_with_attrs(blocks, attrs)
864 } else {
865 AdfNode::table_cell(blocks)
866 }
867 } else if is_header {
868 AdfNode::table_header_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
869 } else {
870 AdfNode::table_cell_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
871 };
872
873 Ok((cell, i))
874 }
875
876 fn try_leaf_directive(&mut self) -> Option<AdfNode> {
877 let line = self.current_line();
878 let d = try_parse_leaf_directive(line)?;
879
880 let node = match d.name.as_str() {
881 "card" => {
882 let content = d.content.as_deref().unwrap_or("");
883 let url = match d.attrs.as_ref().and_then(|a| a.get("url")) {
888 Some(u) => u,
889 None => content,
890 };
891 let mut node = AdfNode::block_card(url);
892 if let Some(ref attrs) = d.attrs {
894 if let Some(ref mut node_attrs) = node.attrs {
895 if let Some(layout) = attrs.get("layout") {
896 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
897 }
898 if let Some(width) = attrs.get("width") {
899 if let Ok(w) = width.parse::<u64>() {
900 node_attrs["width"] = serde_json::json!(w);
901 }
902 }
903 }
904 }
905 node
906 }
907 "embed" => {
908 let url = d.content.as_deref().unwrap_or("");
909 let layout = d.attrs.as_ref().and_then(|a| a.get("layout"));
910 let original_height = d
911 .attrs
912 .as_ref()
913 .and_then(|a| a.get("originalHeight"))
914 .and_then(|v| v.parse::<f64>().ok());
915 let width = d
916 .attrs
917 .as_ref()
918 .and_then(|a| a.get("width"))
919 .and_then(|w| w.parse::<f64>().ok());
920 AdfNode::embed_card(url, layout, original_height, width)
921 }
922 "extension" => {
923 let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
924 let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
925 let params = d
926 .attrs
927 .as_ref()
928 .and_then(|a| a.get("params"))
929 .and_then(|p| serde_json::from_str(p).ok());
930 let mut node = AdfNode::extension(ext_type, ext_key, params);
931 if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
932 {
933 if let Some(layout) = dir_attrs.get("layout") {
934 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
935 }
936 if let Some(local_id) = dir_attrs.get("localId") {
937 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
938 }
939 }
940 node
941 }
942 "paragraph" => {
943 let mut node = if let Some(ref text) = d.content {
944 AdfNode::paragraph(parse_inline(text))
945 } else {
946 AdfNode::paragraph(vec![])
947 };
948 pass_through_local_id(&d.attrs, &mut node);
949 node
950 }
951 _ => return None,
952 };
953
954 self.advance();
955 Some(node)
956 }
957
958 fn try_image(&mut self) -> Option<AdfNode> {
959 let line = self.current_line().trim();
960 let mut node = try_parse_media_single_from_line(line)?;
961 self.advance();
962
963 if !self.at_end() {
965 if let Some((d, _)) = try_parse_container_open(self.current_line()) {
966 if d.name == "caption" {
967 let dir_attrs = d.attrs;
968 self.advance(); let mut caption_lines = Vec::new();
970 while !self.at_end() {
971 if is_container_close(self.current_line(), 3) {
972 self.advance(); break;
974 }
975 caption_lines.push(self.current_line());
976 self.advance();
977 }
978 let caption_text = caption_lines.join("\n");
979 let inline_nodes = parse_inline(&caption_text);
980 let mut caption = AdfNode::caption(inline_nodes);
981 pass_through_local_id(&dir_attrs, &mut caption);
982 if let Some(ref mut content) = node.content {
983 content.push(caption);
984 }
985 }
986 }
987 }
988
989 Some(node)
990 }
991
992 fn try_table(&mut self) -> Result<Option<AdfNode>> {
993 let line = self.current_line();
994 if !line.contains('|') || !line.trim_start().starts_with('|') {
995 return Ok(None);
996 }
997
998 if self.pos + 1 >= self.lines.len() {
1000 return Ok(None);
1001 }
1002 let next_line = self.lines[self.pos + 1];
1003 if !is_table_separator(next_line) {
1004 return Ok(None);
1005 }
1006
1007 let header_cells = parse_table_row(line);
1009 self.advance(); let sep_line = self.current_line();
1013 let alignments = parse_table_alignments(sep_line);
1014 self.advance(); let mut rows = Vec::new();
1017
1018 let header_adf_cells: Vec<AdfNode> = header_cells
1020 .iter()
1021 .enumerate()
1022 .map(|(col_idx, cell)| {
1023 let (cell_text, cell_attrs) = extract_cell_attrs(cell);
1024 let mut para = AdfNode::paragraph(parse_inline(&cell_text));
1025 apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
1026 if let Some(attrs) = cell_attrs {
1027 AdfNode::table_header_with_attrs(vec![para], attrs)
1028 } else {
1029 AdfNode::table_header(vec![para])
1030 }
1031 })
1032 .collect();
1033 if header_adf_cells.is_empty() {
1034 warn!(
1035 "Pipe table header row at line {} has no cells",
1036 self.pos - 1
1037 );
1038 }
1039 rows.push(AdfNode::table_row(header_adf_cells));
1040
1041 while !self.at_end() {
1043 let line = self.current_line();
1044 if !line.contains('|') || line.trim().is_empty() {
1045 break;
1046 }
1047
1048 let cells = parse_table_row(line);
1049 let adf_cells: Vec<AdfNode> = cells
1050 .iter()
1051 .enumerate()
1052 .map(|(col_idx, cell)| {
1053 let (cell_text, cell_attrs) = extract_cell_attrs(cell);
1054 let mut para = AdfNode::paragraph(parse_inline(&cell_text));
1055 apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
1056 if let Some(attrs) = cell_attrs {
1057 AdfNode::table_cell_with_attrs(vec![para], attrs)
1058 } else {
1059 AdfNode::table_cell(vec![para])
1060 }
1061 })
1062 .collect();
1063 if adf_cells.is_empty() {
1064 warn!("Pipe table body row at line {} has no cells", self.pos);
1065 }
1066 rows.push(AdfNode::table_row(adf_cells));
1067 self.advance();
1068 }
1069
1070 debug!("Parsed pipe table with {} rows", rows.len());
1071 let mut table = AdfNode::table(rows);
1072
1073 if !self.at_end() {
1075 let next = self.current_line().trim();
1076 if next.starts_with('{') {
1077 if let Some((_, attrs)) = parse_attrs(next, 0) {
1078 let mut table_attrs = serde_json::json!({});
1079 if let Some(layout) = attrs.get("layout") {
1080 table_attrs["layout"] = serde_json::Value::String(layout.to_string());
1081 }
1082 if attrs.has_flag("numbered") {
1083 table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
1084 } else if attrs.get("numbered") == Some("false") {
1085 table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
1086 }
1087 if let Some(tw) = attrs.get("width") {
1088 if let Some(w) = parse_numeric_attr(tw) {
1089 table_attrs["width"] = w;
1090 }
1091 }
1092 if let Some(local_id) = attrs.get("localId") {
1093 table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1094 }
1095 if table_attrs != serde_json::json!({}) {
1096 table.attrs = Some(table_attrs);
1097 self.advance(); }
1099 }
1100 }
1101 }
1102
1103 Ok(Some(table))
1104 }
1105
1106 fn parse_paragraph(&mut self) -> Result<AdfNode> {
1107 let mut lines: Vec<&str> = Vec::new();
1108
1109 while !self.at_end() {
1110 let line = self.current_line();
1111 if (line.trim().is_empty()
1120 && !lines
1121 .last()
1122 .is_some_and(|prev| has_trailing_hard_break(prev)))
1123 || is_code_fence_opener(line)
1124 || (is_horizontal_rule(line) && !lines.is_empty())
1125 {
1126 break;
1127 }
1128 let is_hardbreak_cont = !lines.is_empty()
1131 && line.starts_with(" ")
1132 && lines
1133 .last()
1134 .is_some_and(|prev| has_trailing_hard_break(prev));
1135 if is_hardbreak_cont {
1136 lines.push(&line[2..]);
1137 self.advance();
1138 continue;
1139 }
1140 if !lines.is_empty()
1141 && (line.starts_with('#') || line.starts_with('>') || is_list_start(line))
1142 {
1143 break;
1144 }
1145 if !lines.is_empty() && is_block_attrs_line(line) {
1147 break;
1148 }
1149 lines.push(line);
1150 self.advance();
1151 }
1152
1153 let text = lines.join("\n");
1154 let inline_nodes = parse_inline(&text);
1155 Ok(AdfNode::paragraph(inline_nodes))
1156 }
1157}
1158
1159fn build_cell_attrs(attrs: &crate::atlassian::attrs::Attrs) -> serde_json::Value {
1162 let mut adf = serde_json::json!({});
1163 if let Some(bg) = attrs.get("bg") {
1164 adf["background"] = serde_json::Value::String(bg.to_string());
1165 }
1166 if let Some(colspan) = attrs.get("colspan") {
1167 if let Ok(n) = colspan.parse::<u32>() {
1168 adf["colspan"] = serde_json::json!(n);
1169 }
1170 }
1171 if let Some(rowspan) = attrs.get("rowspan") {
1172 if let Ok(n) = rowspan.parse::<u32>() {
1173 adf["rowspan"] = serde_json::json!(n);
1174 }
1175 }
1176 if let Some(colwidth) = attrs.get("colwidth") {
1177 let widths: Vec<serde_json::Value> = colwidth
1178 .split(',')
1179 .filter_map(|s| parse_numeric_attr(s.trim()))
1180 .collect();
1181 if !widths.is_empty() {
1182 adf["colwidth"] = serde_json::Value::Array(widths);
1183 }
1184 }
1185 if let Some(local_id) = attrs.get("localId") {
1186 adf["localId"] = serde_json::Value::String(local_id.to_string());
1187 }
1188 adf
1189}
1190
1191fn build_border_marks(attrs: &crate::atlassian::attrs::Attrs) -> Vec<AdfMark> {
1193 let mut marks = Vec::new();
1194 let border_color = attrs.get("border-color");
1195 let border_size = attrs.get("border-size");
1196 if border_color.is_some() || border_size.is_some() {
1197 let color = border_color.unwrap_or("#000000");
1198 let size = border_size.and_then(|s| s.parse::<u32>().ok()).unwrap_or(1);
1199 marks.push(AdfMark::border(color, size));
1200 }
1201 marks
1202}
1203
1204fn iso_date_to_epoch_ms(date_str: &str) -> String {
1207 if date_str.chars().all(|c| c.is_ascii_digit()) {
1209 return date_str.to_string();
1210 }
1211 if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
1212 let epoch_ms = date
1213 .and_hms_opt(0, 0, 0)
1214 .map_or(0, |dt| dt.and_utc().timestamp_millis());
1215 epoch_ms.to_string()
1216 } else {
1217 date_str.to_string()
1219 }
1220}
1221
1222fn epoch_ms_to_iso_date(timestamp: &str) -> String {
1225 if timestamp.contains('-') {
1227 return timestamp.to_string();
1228 }
1229 if let Ok(ms) = timestamp.parse::<i64>() {
1230 let secs = ms / 1000;
1231 if let Some(dt) = chrono::DateTime::from_timestamp(secs, 0) {
1232 return dt.format("%Y-%m-%d").to_string();
1233 }
1234 }
1235 timestamp.to_string()
1237}
1238
1239fn is_block_attrs_line(line: &str) -> bool {
1241 let trimmed = line.trim();
1242 if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
1243 return false;
1244 }
1245 if let Some((_, attrs)) = parse_attrs(trimmed, 0) {
1246 attrs.get("align").is_some()
1248 || attrs.get("indent").is_some()
1249 || attrs.get("breakout").is_some()
1250 || attrs.get("breakoutWidth").is_some()
1251 || attrs.get("localId").is_some()
1252 } else {
1253 false
1254 }
1255}
1256
1257fn parse_decision_items(text: &str) -> Vec<AdfNode> {
1260 let mut items = Vec::new();
1261 for line in text.lines() {
1262 let trimmed = line.trim();
1263 if let Some(rest) = trimmed.strip_prefix("- <> ") {
1264 let inline_nodes = parse_inline(rest);
1265 items.push(AdfNode::decision_item("DECIDED", inline_nodes));
1266 }
1267 }
1268 items
1269}
1270
1271fn try_parse_task_marker(text: &str) -> Option<(&str, &str)> {
1279 if let Some(rest) = strip_task_checkbox(text, "[ ]") {
1280 Some(("TODO", rest))
1281 } else if let Some(rest) =
1282 strip_task_checkbox(text, "[x]").or_else(|| strip_task_checkbox(text, "[X]"))
1283 {
1284 Some(("DONE", rest))
1285 } else {
1286 None
1287 }
1288}
1289
1290fn strip_task_checkbox<'a>(text: &'a str, checkbox: &str) -> Option<&'a str> {
1294 let rest = text.strip_prefix(checkbox)?;
1295 if rest.is_empty() {
1296 Some(rest)
1297 } else {
1298 rest.strip_prefix(' ')
1299 }
1300}
1301
1302fn starts_with_task_marker(s: &str) -> bool {
1310 let after = if let Some(rest) = s.strip_prefix("[ ]") {
1311 rest
1312 } else if let Some(rest) = s.strip_prefix("[x]").or_else(|| s.strip_prefix("[X]")) {
1313 rest
1314 } else {
1315 return false;
1316 };
1317 after.is_empty() || after.starts_with(' ') || after.starts_with('\n')
1318}
1319
1320fn parse_ordered_list_marker(line: &str) -> Option<(u32, &str)> {
1322 let digit_end = line.find(|c: char| !c.is_ascii_digit())?;
1323 if digit_end == 0 {
1324 return None;
1325 }
1326 let rest = &line[digit_end..];
1327 let after_marker = rest.strip_prefix(". ")?;
1328 let num: u32 = line[..digit_end].parse().ok()?;
1329 Some((num, after_marker))
1330}
1331
1332fn has_trailing_hard_break(line: &str) -> bool {
1335 line.ends_with('\\') || line.ends_with(" ")
1336}
1337
1338fn is_block_level_continuation_marker(trimmed: &str) -> bool {
1346 trimmed.starts_with("![") || trimmed.starts_with("```") || trimmed.starts_with(":::")
1347}
1348
1349fn is_list_start(line: &str) -> bool {
1351 let trimmed = line.trim_start();
1352 trimmed.starts_with("- ")
1353 || trimmed.starts_with("* ")
1354 || trimmed.starts_with("+ ")
1355 || parse_ordered_list_marker(trimmed).is_some()
1356}
1357
1358fn escape_emphasis_markers(text: &str) -> String {
1371 escape_emphasis_with(text, false)
1372}
1373
1374fn escape_emphasis_markers_with_underscore(text: &str) -> String {
1382 escape_emphasis_with(text, true)
1383}
1384
1385fn escape_emphasis_with(text: &str, escape_underscore_always: bool) -> String {
1391 let chars: Vec<char> = text.chars().collect();
1392 let mut out = String::with_capacity(text.len());
1393 let mut idx = 0;
1394 while idx < chars.len() {
1395 let ch = chars[idx];
1396 if ch == '*' {
1397 out.push('\\');
1398 out.push(ch);
1399 idx += 1;
1400 } else if ch == '_' {
1401 let run_start = idx;
1405 let mut run_end = idx;
1406 while run_end < chars.len() && chars[run_end] == '_' {
1407 run_end += 1;
1408 }
1409 let escape_run = if escape_underscore_always {
1410 true
1411 } else {
1412 let before_alnum = run_start > 0 && chars[run_start - 1].is_alphanumeric();
1413 let after_alnum = chars.get(run_end).is_some_and(|c| c.is_alphanumeric());
1414 !(before_alnum && after_alnum)
1415 };
1416 for _ in run_start..run_end {
1417 if escape_run {
1418 out.push('\\');
1419 }
1420 out.push('_');
1421 }
1422 idx = run_end;
1423 } else {
1424 out.push(ch);
1425 idx += 1;
1426 }
1427 }
1428 out
1429}
1430
1431fn escape_backticks(text: &str) -> String {
1437 let mut out = String::with_capacity(text.len());
1438 for ch in text.chars() {
1439 if ch == '`' {
1440 out.push('\\');
1441 }
1442 out.push(ch);
1443 }
1444 out
1445}
1446
1447fn inline_code_delimiter(text: &str) -> (usize, bool) {
1455 let mut max_run = 0usize;
1456 let mut current = 0usize;
1457 for ch in text.chars() {
1458 if ch == '`' {
1459 current += 1;
1460 if current > max_run {
1461 max_run = current;
1462 }
1463 } else {
1464 current = 0;
1465 }
1466 }
1467 let n = max_run + 1;
1468 let starts_bt = text.starts_with('`');
1469 let ends_bt = text.ends_with('`');
1470 let starts_sp = text.starts_with(' ');
1471 let ends_sp = text.ends_with(' ');
1472 let all_sp = !text.is_empty() && text.chars().all(|c| c == ' ');
1473 let needs_pad = starts_bt || ends_bt || (starts_sp && ends_sp && !all_sp);
1474 (n, needs_pad)
1475}
1476
1477fn render_inline_code(text: &str, output: &mut String) {
1480 let (n, pad) = inline_code_delimiter(text);
1481 for _ in 0..n {
1482 output.push('`');
1483 }
1484 if pad {
1485 output.push(' ');
1486 }
1487 output.push_str(text);
1488 if pad {
1489 output.push(' ');
1490 }
1491 for _ in 0..n {
1492 output.push('`');
1493 }
1494}
1495
1496fn escape_pipes_in_cell(text: &str) -> String {
1503 let mut out = String::with_capacity(text.len());
1504 for ch in text.chars() {
1505 if ch == '|' {
1506 out.push('\\');
1507 }
1508 out.push(ch);
1509 }
1510 out
1511}
1512
1513fn escape_link_brackets(text: &str) -> String {
1518 let mut out = String::with_capacity(text.len());
1519 for ch in text.chars() {
1520 if ch == '[' || ch == ']' {
1521 out.push('\\');
1522 }
1523 out.push(ch);
1524 }
1525 out
1526}
1527
1528fn escape_bare_urls(text: &str) -> String {
1534 let mut result = String::with_capacity(text.len());
1535 for (i, ch) in text.char_indices() {
1536 if ch == 'h' {
1537 let rest = &text[i..];
1538 if rest.starts_with("http://") || rest.starts_with("https://") {
1539 result.push('\\');
1540 }
1541 }
1542 result.push(ch);
1543 }
1544 result
1545}
1546
1547fn url_safe_in_bracket_content(s: &str) -> bool {
1556 if s.contains('\n') {
1557 return false;
1558 }
1559 let mut depth: i32 = 1;
1560 for ch in s.chars() {
1561 match ch {
1562 '[' => depth += 1,
1563 ']' => {
1564 depth -= 1;
1565 if depth == 0 {
1566 return false;
1567 }
1568 }
1569 _ => {}
1570 }
1571 }
1572 true
1573}
1574
1575fn escape_emoji_shortcodes(text: &str) -> String {
1586 let mut result = String::with_capacity(text.len());
1587
1588 for (i, ch) in text.char_indices() {
1589 if ch == ':' {
1590 let after = i + 1;
1593 if after < text.len() {
1594 let name_end = text[after..]
1595 .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')
1596 .map_or(text[after..].len(), |pos| pos);
1597 if name_end > 0
1598 && after + name_end < text.len()
1599 && text.as_bytes()[after + name_end] == b':'
1600 {
1601 result.push('\\');
1603 }
1604 }
1605 }
1606 result.push(ch);
1607 }
1608
1609 result
1610}
1611
1612fn escape_list_marker(line: &str) -> String {
1616 if let Some(dot_pos) = line.find(". ") {
1617 if parse_ordered_list_marker(line).is_some() {
1618 let mut s = String::with_capacity(line.len() + 1);
1619 s.push_str(&line[..dot_pos]);
1620 s.push('\\');
1621 s.push_str(&line[dot_pos..]);
1622 return s;
1623 }
1624 }
1625 for prefix in &["- ", "* ", "+ "] {
1626 if line.starts_with(prefix) {
1627 let mut s = String::with_capacity(line.len() + 1);
1628 s.push('\\');
1629 s.push_str(line);
1630 return s;
1631 }
1632 }
1633 line.to_string()
1634}
1635
1636fn is_code_fence_opener(line: &str) -> bool {
1643 if !line.starts_with("```") {
1644 return false;
1645 }
1646 !line[3..].contains('`')
1647}
1648
1649fn is_horizontal_rule(line: &str) -> bool {
1651 let trimmed = line.trim();
1652 trimmed.len() >= 3
1653 && ((trimmed.starts_with("---") && trimmed.chars().all(|c| c == '-'))
1654 || (trimmed.starts_with("***") && trimmed.chars().all(|c| c == '*'))
1655 || (trimmed.starts_with("___") && trimmed.chars().all(|c| c == '_')))
1656}
1657
1658fn is_table_separator(line: &str) -> bool {
1660 let trimmed = line.trim();
1661 trimmed.contains('|')
1662 && trimmed
1663 .chars()
1664 .all(|c| c == '|' || c == '-' || c == ':' || c == ' ')
1665}
1666
1667fn parse_table_row(line: &str) -> Vec<String> {
1674 let trimmed = line.trim();
1675 let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1676 let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1677
1678 let mut cells: Vec<String> = Vec::new();
1679 let mut current = String::new();
1680 let mut chars = trimmed.chars().peekable();
1681 while let Some(ch) = chars.next() {
1682 if ch == '\\' && chars.peek() == Some(&'|') {
1683 current.push('|');
1684 chars.next();
1685 } else if ch == '|' {
1686 cells.push(std::mem::take(&mut current));
1687 } else {
1688 current.push(ch);
1689 }
1690 }
1691 cells.push(current);
1692
1693 cells
1694 .iter()
1695 .map(|s| {
1696 let stripped = s.strip_prefix(' ').unwrap_or(s.as_str());
1699 let stripped = stripped.strip_suffix(' ').unwrap_or(stripped);
1700 stripped.to_string()
1701 })
1702 .collect()
1703}
1704
1705fn parse_table_alignments(separator_line: &str) -> Vec<Option<&'static str>> {
1708 let trimmed = separator_line.trim();
1709 let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1710 let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1711
1712 trimmed
1713 .split('|')
1714 .map(|cell| {
1715 let cell = cell.trim();
1716 let starts_colon = cell.starts_with(':');
1717 let ends_colon = cell.ends_with(':');
1718 match (starts_colon, ends_colon) {
1719 (true, true) => Some("center"),
1720 (false, true) => Some("end"),
1721 _ => None, }
1723 })
1724 .collect()
1725}
1726
1727fn apply_column_alignment(para: &mut AdfNode, alignment: Option<&str>) {
1729 if let Some(align) = alignment {
1730 para.marks = Some(vec![AdfMark::alignment(align)]);
1731 }
1732}
1733
1734fn extract_cell_attrs(cell_text: &str) -> (String, Option<serde_json::Value>) {
1737 let trimmed = cell_text.trim_start();
1738 if !trimmed.starts_with('{') {
1739 return (cell_text.to_string(), None);
1740 }
1741 if let Some((end_pos, attrs)) = parse_attrs(trimmed, 0) {
1742 let remaining = trimmed[end_pos..].trim_start().to_string();
1743 let adf_attrs = build_cell_attrs(&attrs);
1744 (remaining, Some(adf_attrs))
1745 } else {
1746 (cell_text.to_string(), None)
1747 }
1748}
1749
1750fn try_parse_media_single_from_line(line: &str) -> Option<AdfNode> {
1753 let line = line.trim();
1754 if !line.starts_with("? + 1; let img_end = find_closing_paren(line, paren_open)? + 1;
1763 let after_img = line[img_end..].trim_start();
1764
1765 if after_img.starts_with('{') {
1766 if let Some((_, attrs)) = parse_attrs(after_img, 0) {
1767 if attrs.get("type") == Some("file") || attrs.get("id").is_some() {
1769 let mut media_attrs = serde_json::json!({"type": "file"});
1770 if let Some(id) = attrs.get("id") {
1771 media_attrs["id"] = serde_json::Value::String(id.to_string());
1772 }
1773 if let Some(collection) = attrs.get("collection") {
1774 media_attrs["collection"] = serde_json::Value::String(collection.to_string());
1775 }
1776 if let Some(occurrence_key) = attrs.get("occurrenceKey") {
1777 media_attrs["occurrenceKey"] =
1778 serde_json::Value::String(occurrence_key.to_string());
1779 }
1780 if let Some(height) = attrs.get("height") {
1781 if let Some(h) = parse_numeric_attr(height) {
1782 media_attrs["height"] = h;
1783 }
1784 }
1785 if let Some(width) = attrs.get("width") {
1786 if let Some(w) = parse_numeric_attr(width) {
1787 media_attrs["width"] = w;
1788 }
1789 }
1790 if let Some(alt_text) = alt_opt {
1791 media_attrs["alt"] = serde_json::Value::String(alt_text.to_string());
1792 }
1793 if let Some(local_id) = attrs.get("localId") {
1794 media_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1795 }
1796 let mut ms_attrs = serde_json::json!({"layout": "center"});
1797 if let Some(layout) = attrs.get("layout") {
1798 ms_attrs["layout"] = serde_json::Value::String(layout.to_string());
1799 }
1800 if let Some(ms_width) = attrs.get("mediaWidth") {
1801 if let Some(w) = parse_numeric_attr(ms_width) {
1802 ms_attrs["width"] = w;
1803 }
1804 }
1805 if let Some(wt) = attrs.get("widthType") {
1806 ms_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1807 }
1808 if let Some(mode) = attrs.get("mode") {
1809 ms_attrs["mode"] = serde_json::Value::String(mode.to_string());
1810 }
1811 let border_marks = build_border_marks(&attrs);
1812 let media_marks = if border_marks.is_empty() {
1813 None
1814 } else {
1815 Some(border_marks)
1816 };
1817 return Some(AdfNode {
1818 node_type: "mediaSingle".to_string(),
1819 attrs: Some(ms_attrs),
1820 content: Some(vec![AdfNode {
1821 node_type: "media".to_string(),
1822 attrs: Some(media_attrs),
1823 content: None,
1824 text: None,
1825 marks: media_marks,
1826 local_id: None,
1827 parameters: None,
1828 }]),
1829 text: None,
1830 marks: None,
1831 local_id: None,
1832 parameters: None,
1833 });
1834 }
1835
1836 let mut node = AdfNode::media_single(url, alt_opt);
1838 if let Some(ref mut node_attrs) = node.attrs {
1839 if let Some(layout) = attrs.get("layout") {
1840 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
1841 }
1842 if let Some(width) = attrs.get("width") {
1843 if let Some(w) = parse_numeric_attr(width) {
1844 node_attrs["width"] = w;
1845 }
1846 }
1847 if let Some(wt) = attrs.get("widthType") {
1848 node_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1849 }
1850 if let Some(mode) = attrs.get("mode") {
1851 node_attrs["mode"] = serde_json::Value::String(mode.to_string());
1852 }
1853 }
1854 if let Some(ref mut content) = node.content {
1855 if let Some(media) = content.first_mut() {
1856 if let Some(local_id) = attrs.get("localId") {
1857 if let Some(ref mut media_attrs) = media.attrs {
1858 media_attrs["localId"] =
1859 serde_json::Value::String(local_id.to_string());
1860 }
1861 }
1862 let border_marks = build_border_marks(&attrs);
1863 if !border_marks.is_empty() {
1864 media.marks = Some(border_marks);
1865 }
1866 }
1867 }
1868 return Some(node);
1869 }
1870 }
1871
1872 Some(AdfNode::media_single(url, alt_opt))
1873}
1874
1875fn parse_image_syntax(line: &str) -> Option<(&str, &str)> {
1877 let line = line.trim();
1878 if !line.starts_with("?;
1883 let alt = &line[2..alt_end];
1884 let paren_start = alt_end + 1; let url_end = find_closing_paren(line, paren_start)?;
1886 let url = &line[paren_start + 1..url_end];
1887
1888 Some((alt, url))
1889}
1890
1891fn parse_inline(text: &str) -> Vec<AdfNode> {
1899 parse_inline_impl(text, true)
1900}
1901
1902fn parse_inline_no_auto_cards(text: &str) -> Vec<AdfNode> {
1909 parse_inline_impl(text, false)
1910}
1911
1912fn parse_inline_impl(text: &str, auto_inline_card: bool) -> Vec<AdfNode> {
1917 let mut nodes = Vec::new();
1918 let mut chars = text.char_indices().peekable();
1919 let mut plain_start = 0;
1920
1921 while let Some(&(i, ch)) = chars.peek() {
1922 match ch {
1923 '*' | '_' => {
1924 if let Some((end, content, is_bold)) = try_parse_emphasis(text, i) {
1925 flush_plain(text, plain_start, i, &mut nodes);
1926 let mark = if is_bold {
1927 AdfMark::strong()
1928 } else {
1929 AdfMark::em()
1930 };
1931 let inner = parse_inline_no_auto_cards(content);
1932 for mut node in inner {
1933 prepend_mark(&mut node, mark.clone());
1934 nodes.push(node);
1935 }
1936 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1938 chars.next();
1939 }
1940 plain_start = end;
1941 continue;
1942 }
1943 if ch == '_' {
1948 while chars.peek().is_some_and(|&(_, c)| c == '_') {
1949 chars.next();
1950 }
1951 } else {
1952 chars.next();
1953 }
1954 }
1955 '~' => {
1956 if let Some((end, content)) = try_parse_strikethrough(text, i) {
1957 flush_plain(text, plain_start, i, &mut nodes);
1958 let inner = parse_inline_no_auto_cards(content);
1959 for mut node in inner {
1960 prepend_mark(&mut node, AdfMark::strike());
1961 nodes.push(node);
1962 }
1963 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1964 chars.next();
1965 }
1966 plain_start = end;
1967 continue;
1968 }
1969 chars.next();
1970 }
1971 '`' => {
1972 if let Some((end, content)) = try_parse_inline_code(text, i) {
1973 flush_plain(text, plain_start, i, &mut nodes);
1974 nodes.push(AdfNode::text_with_marks(content, vec![AdfMark::code()]));
1975 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1976 chars.next();
1977 }
1978 plain_start = end;
1979 continue;
1980 }
1981 while chars.peek().is_some_and(|&(_, c)| c == '`') {
1984 chars.next();
1985 }
1986 }
1987 '[' => {
1988 if let Some((end, link_text, href)) = try_parse_link(text, i) {
1989 flush_plain(text, plain_start, i, &mut nodes);
1990 if link_text.starts_with("http://") || link_text.starts_with("https://") {
1991 nodes.push(AdfNode::text_with_marks(
1996 link_text,
1997 vec![AdfMark::link(href)],
1998 ));
1999 } else {
2000 let inner = parse_inline_no_auto_cards(link_text);
2001 for mut node in inner {
2002 prepend_mark(&mut node, AdfMark::link(href));
2003 nodes.push(node);
2004 }
2005 }
2006 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2007 chars.next();
2008 }
2009 plain_start = end;
2010 continue;
2011 }
2012 if let Some((end, span_nodes)) = try_parse_bracketed_span(text, i) {
2014 flush_plain(text, plain_start, i, &mut nodes);
2015 nodes.extend(span_nodes);
2016 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2017 chars.next();
2018 }
2019 plain_start = end;
2020 continue;
2021 }
2022 chars.next();
2023 }
2024 ':' => {
2025 if let Some(node) = try_dispatch_inline_directive(text, i) {
2027 flush_plain(text, plain_start, i, &mut nodes);
2028 let end = node.1;
2029 nodes.push(node.0);
2030 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2031 chars.next();
2032 }
2033 plain_start = end;
2034 continue;
2035 }
2036 if let Some((end, short_name)) = try_parse_emoji_shortcode(text, i) {
2038 flush_plain(text, plain_start, i, &mut nodes);
2039 let (final_end, emoji_node) = parse_emoji_with_attrs(text, end, short_name);
2040 nodes.push(emoji_node);
2041 while chars.peek().is_some_and(|&(idx, _)| idx < final_end) {
2042 chars.next();
2043 }
2044 plain_start = final_end;
2045 continue;
2046 }
2047 chars.next();
2048 }
2049 ' ' if text[i..].starts_with(" \n") => {
2050 flush_plain(text, plain_start, i, &mut nodes);
2053 nodes.push(AdfNode::hard_break());
2054 while chars.peek().is_some_and(|&(_, c)| c == ' ') {
2056 chars.next();
2057 }
2058 if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2060 chars.next();
2061 }
2062 plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2063 }
2064 '!' if text[i..].starts_with("![") => {
2065 chars.next();
2069 }
2070 'h' if auto_inline_card
2071 && (text[i..].starts_with("http://") || text[i..].starts_with("https://")) =>
2072 {
2073 if let Some((end, url)) = try_parse_bare_url(text, i) {
2074 flush_plain(text, plain_start, i, &mut nodes);
2075 nodes.push(AdfNode::inline_card(url));
2076 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2077 chars.next();
2078 }
2079 plain_start = end;
2080 continue;
2081 }
2082 chars.next();
2083 }
2084 '\\' if text.as_bytes().get(i + 1) == Some(&b'n')
2085 && text.as_bytes().get(i + 2) != Some(&b'\n') =>
2086 {
2087 flush_plain(text, plain_start, i, &mut nodes);
2091 nodes.push(AdfNode::text("\n"));
2092 chars.next(); chars.next(); plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2095 }
2096 '\\' if i + 1 < text.len() && !text[i..].starts_with("\\\n") => {
2097 flush_plain(text, plain_start, i, &mut nodes);
2102 chars.next(); plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2107 chars.next(); }
2109 '\\' if text[i..].starts_with("\\\n") => {
2110 flush_plain(text, plain_start, i, &mut nodes);
2112 nodes.push(AdfNode::hard_break());
2113 chars.next(); if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2116 chars.next();
2117 }
2118 plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2119 }
2120 '\\' if i + 1 == text.len() => {
2121 flush_plain(text, plain_start, i, &mut nodes);
2123 nodes.push(AdfNode::hard_break());
2124 chars.next(); plain_start = text.len();
2126 }
2127 _ => {
2128 chars.next();
2129 }
2130 }
2131 }
2132
2133 if plain_start < text.len() {
2135 let remaining = &text[plain_start..];
2136 if !remaining.is_empty() {
2137 nodes.push(AdfNode::text(remaining));
2138 }
2139 }
2140
2141 merge_adjacent_text(&mut nodes);
2144
2145 nodes
2146}
2147
2148fn merge_adjacent_text(nodes: &mut Vec<AdfNode>) {
2150 let mut i = 0;
2151 while i + 1 < nodes.len() {
2152 if nodes[i].node_type == "text"
2153 && nodes[i + 1].node_type == "text"
2154 && nodes[i].marks.is_none()
2155 && nodes[i + 1].marks.is_none()
2156 {
2157 let next_text = nodes[i + 1].text.clone().unwrap_or_default();
2158 if let Some(ref mut t) = nodes[i].text {
2159 t.push_str(&next_text);
2160 }
2161 nodes.remove(i + 1);
2162 } else {
2163 i += 1;
2164 }
2165 }
2166}
2167
2168fn flush_plain(text: &str, start: usize, end: usize, nodes: &mut Vec<AdfNode>) {
2170 if start < end {
2171 let plain = &text[start..end];
2172 if !plain.is_empty() {
2173 nodes.push(AdfNode::text(plain));
2174 }
2175 }
2176}
2177
2178#[cfg(test)]
2180fn add_mark(node: &mut AdfNode, mark: AdfMark) {
2181 if let Some(ref mut marks) = node.marks {
2182 marks.push(mark);
2183 } else {
2184 node.marks = Some(vec![mark]);
2185 }
2186}
2187
2188fn strip_heading_code_marks(nodes: &mut [AdfNode]) -> usize {
2199 let mut removed = 0;
2200 for node in nodes.iter_mut() {
2201 if let Some(marks) = node.marks.as_mut() {
2202 let before = marks.len();
2203 marks.retain(|m| m.mark_type != "code");
2204 removed += before - marks.len();
2205 if marks.is_empty() {
2206 node.marks = None;
2207 }
2208 }
2209 if let Some(children) = node.content.as_mut() {
2210 removed += strip_heading_code_marks(children);
2211 }
2212 }
2213 removed
2214}
2215
2216fn prepend_mark(node: &mut AdfNode, mark: AdfMark) {
2218 if let Some(ref mut marks) = node.marks {
2219 marks.insert(0, mark);
2220 } else {
2221 node.marks = Some(vec![mark]);
2222 }
2223}
2224
2225fn is_intraword_underscore(text: &str, delim_pos: usize, len: usize) -> bool {
2230 let before = text[..delim_pos]
2231 .chars()
2232 .next_back()
2233 .is_some_and(char::is_alphanumeric);
2234 let after = text[delim_pos + len..]
2235 .chars()
2236 .next()
2237 .is_some_and(char::is_alphanumeric);
2238 before && after
2239}
2240
2241fn find_unescaped(haystack: &str, needle: &str) -> Option<usize> {
2245 let needle_bytes = needle.as_bytes();
2246 let hay_bytes = haystack.as_bytes();
2247 let mut i = 0;
2248 while i < hay_bytes.len() {
2249 if hay_bytes[i] == b'\\' {
2250 i += 2; continue;
2252 }
2253 if hay_bytes[i..].starts_with(needle_bytes) {
2254 return Some(i);
2255 }
2256 i += 1;
2257 }
2258 None
2259}
2260
2261fn find_unescaped_char(haystack: &str, ch: u8) -> Option<usize> {
2264 let hay_bytes = haystack.as_bytes();
2265 let mut i = 0;
2266 while i < hay_bytes.len() {
2267 if hay_bytes[i] == b'\\' {
2268 i += 2;
2269 continue;
2270 }
2271 if hay_bytes[i] == ch {
2272 return Some(i);
2273 }
2274 i += 1;
2275 }
2276 None
2277}
2278
2279fn try_parse_emphasis(text: &str, i: usize) -> Option<(usize, &str, bool)> {
2289 let rest = &text[i..];
2290
2291 if rest.starts_with("***") || rest.starts_with("___") {
2295 let is_underscore = rest.starts_with("___");
2296 if is_underscore && is_intraword_underscore(text, i, 3) {
2297 return None;
2298 }
2299 let triple = &rest[..3];
2300 let after = &rest[3..];
2301 if let Some(close) = find_unescaped(after, triple) {
2302 if close > 0 {
2303 let close_pos = i + 3 + close;
2304 if is_underscore && is_intraword_underscore(text, close_pos, 3) {
2305 return None;
2306 }
2307 let content = &rest[2..=3 + close];
2311 let end = i + 3 + close + 3;
2312 return Some((end, content, true));
2313 }
2314 }
2315 }
2316
2317 if rest.starts_with("**") || rest.starts_with("__") {
2319 let is_underscore = rest.starts_with("__");
2320 if is_underscore && is_intraword_underscore(text, i, 2) {
2321 return None;
2322 }
2323 let delimiter = &rest[..2];
2324 let after = &rest[2..];
2325 let close = find_unescaped(after, delimiter)?;
2326 if close == 0 {
2327 return None;
2328 }
2329 let close_pos = i + 2 + close;
2330 if is_underscore && is_intraword_underscore(text, close_pos, 2) {
2331 return None;
2332 }
2333 let content = &after[..close];
2334 let end = i + 2 + close + 2;
2335 return Some((end, content, true));
2336 }
2337
2338 if rest.starts_with('*') || rest.starts_with('_') {
2340 let delim_char = rest.as_bytes()[0];
2341 let is_underscore = delim_char == b'_';
2342 if is_underscore && is_intraword_underscore(text, i, 1) {
2343 return None;
2344 }
2345 let after = &rest[1..];
2346 let close = find_unescaped_char(after, delim_char)?;
2347 if close == 0 {
2348 return None;
2349 }
2350 let close_pos = i + 1 + close;
2351 if is_underscore && is_intraword_underscore(text, close_pos, 1) {
2352 return None;
2353 }
2354 let content = &after[..close];
2355 let end = i + 1 + close + 1;
2356 return Some((end, content, false));
2357 }
2358
2359 None
2360}
2361
2362fn try_parse_strikethrough(text: &str, i: usize) -> Option<(usize, &str)> {
2364 let rest = &text[i..];
2365 if !rest.starts_with("~~") {
2366 return None;
2367 }
2368 let after = &rest[2..];
2369 let close = after.find("~~")?;
2370 if close == 0 {
2371 return None;
2372 }
2373 let content = &after[..close];
2374 Some((i + 2 + close + 2, content))
2375}
2376
2377fn try_parse_inline_code(text: &str, i: usize) -> Option<(usize, &str)> {
2384 let rest = &text[i..];
2385 let bytes = rest.as_bytes();
2386 if bytes.is_empty() || bytes[0] != b'`' {
2387 return None;
2388 }
2389 let mut opening = 0usize;
2390 while opening < bytes.len() && bytes[opening] == b'`' {
2391 opening += 1;
2392 }
2393
2394 let mut j = opening;
2395 while j < bytes.len() {
2396 if bytes[j] == b'`' {
2397 let run_start = j;
2398 while j < bytes.len() && bytes[j] == b'`' {
2399 j += 1;
2400 }
2401 if j - run_start == opening {
2402 let content = &rest[opening..run_start];
2403 let content = strip_code_span_padding(content);
2404 return Some((i + j, content));
2405 }
2406 } else {
2407 j += 1;
2408 }
2409 }
2410 None
2411}
2412
2413fn strip_code_span_padding(content: &str) -> &str {
2417 let bytes = content.as_bytes();
2418 if bytes.len() >= 2
2419 && bytes[0] == b' '
2420 && bytes[bytes.len() - 1] == b' '
2421 && content.bytes().any(|b| b != b' ')
2422 {
2423 &content[1..content.len() - 1]
2424 } else {
2425 content
2426 }
2427}
2428
2429fn try_parse_bracketed_span(text: &str, i: usize) -> Option<(usize, Vec<AdfNode>)> {
2432 let rest = &text[i..];
2433 if !rest.starts_with('[') {
2434 return None;
2435 }
2436
2437 let mut depth: usize = 0;
2441 let mut bracket_close = None;
2442 let bs_bytes = rest.as_bytes();
2443 for (j, ch) in rest.char_indices() {
2444 match ch {
2445 '\\' if j + 1 < bs_bytes.len()
2446 && (bs_bytes[j + 1] == b'[' || bs_bytes[j + 1] == b']') => {}
2447 '[' if j == 0 || bs_bytes[j - 1] != b'\\' => depth += 1,
2448 ']' if j == 0 || bs_bytes[j - 1] != b'\\' => {
2449 depth -= 1;
2450 if depth == 0 {
2451 bracket_close = Some(j);
2452 break;
2453 }
2454 }
2455 _ => {}
2456 }
2457 }
2458 let bracket_close = bracket_close?;
2459 let after_bracket = &rest[bracket_close + 1..];
2461 if !after_bracket.starts_with('{') {
2462 return None;
2463 }
2464
2465 let span_text = &rest[1..bracket_close];
2466 let attrs_start = i + bracket_close + 1;
2467 let (attrs_end, attrs) = parse_attrs(text, attrs_start)?;
2468
2469 let mut marks = Vec::new();
2470 if attrs.has_flag("underline") {
2471 marks.push(AdfMark::underline());
2472 }
2473 let ann_ids = attrs.get_all("annotation-id");
2474 let ann_types = attrs.get_all("annotation-type");
2475 for (idx, ann_id) in ann_ids.iter().enumerate() {
2476 let ann_type = ann_types.get(idx).copied().unwrap_or("inlineComment");
2477 marks.push(AdfMark::annotation(ann_id, ann_type));
2478 }
2479
2480 if marks.is_empty() {
2481 return None; }
2483
2484 let inner = parse_inline_no_auto_cards(span_text);
2485 let result: Vec<AdfNode> = inner
2486 .into_iter()
2487 .map(|mut node| {
2488 let mut combined = marks.clone();
2491 if let Some(ref existing) = node.marks {
2492 combined.extend(existing.iter().cloned());
2493 }
2494 node.marks = Some(combined);
2495 node
2496 })
2497 .collect();
2498
2499 Some((attrs_end, result))
2500}
2501
2502fn try_dispatch_inline_directive(text: &str, pos: usize) -> Option<(AdfNode, usize)> {
2505 let d = try_parse_inline_directive(text, pos)?;
2506 let content = d.content.as_deref().unwrap_or("");
2507
2508 let node = match d.name.as_str() {
2509 "card" => {
2510 let url = d
2515 .attrs
2516 .as_ref()
2517 .and_then(|a| a.get("url"))
2518 .unwrap_or(content);
2519 let mut node = AdfNode::inline_card(url);
2520 pass_through_local_id(&d.attrs, &mut node);
2521 node
2522 }
2523 "status" => {
2524 let color = d
2525 .attrs
2526 .as_ref()
2527 .and_then(|a| a.get("color"))
2528 .unwrap_or("neutral");
2529 let mut node = AdfNode::status(content, color);
2530 if let Some(ref attrs) = d.attrs {
2532 if let Some(ref mut node_attrs) = node.attrs {
2533 if let Some(style) = attrs.get("style") {
2534 node_attrs["style"] = serde_json::Value::String(style.to_string());
2535 }
2536 if let Some(local_id) = attrs.get("localId") {
2537 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2538 }
2539 }
2540 }
2541 node
2542 }
2543 "date" => {
2544 let timestamp = d
2545 .attrs
2546 .as_ref()
2547 .and_then(|a| a.get("timestamp"))
2548 .map_or_else(|| iso_date_to_epoch_ms(content), ToString::to_string);
2549 let mut node = AdfNode::date(×tamp);
2550 pass_through_local_id(&d.attrs, &mut node);
2551 node
2552 }
2553 "mention" => {
2554 let id = d.attrs.as_ref().and_then(|a| a.get("id")).unwrap_or("");
2555 let mut node = AdfNode::mention(id, content);
2556 if let Some(ref attrs) = d.attrs {
2558 if let (Some(ref mut node_attrs), true) = (
2559 &mut node.attrs,
2560 attrs.get("userType").is_some() || attrs.get("accessLevel").is_some(),
2561 ) {
2562 if let Some(ut) = attrs.get("userType") {
2563 node_attrs["userType"] = serde_json::Value::String(ut.to_string());
2564 }
2565 if let Some(al) = attrs.get("accessLevel") {
2566 node_attrs["accessLevel"] = serde_json::Value::String(al.to_string());
2567 }
2568 }
2569 }
2570 pass_through_local_id(&d.attrs, &mut node);
2571 node
2572 }
2573 "span" => {
2574 let mut marks = Vec::new();
2575 if let Some(ref attrs) = d.attrs {
2576 if let Some(color) = attrs.get("color") {
2577 marks.push(AdfMark::text_color(color));
2578 }
2579 if let Some(bg) = attrs.get("bg") {
2580 marks.push(AdfMark::background_color(bg));
2581 }
2582 if attrs.has_flag("sub") {
2583 marks.push(AdfMark::subsup("sub"));
2584 }
2585 if attrs.has_flag("sup") {
2586 marks.push(AdfMark::subsup("sup"));
2587 }
2588 }
2589 if marks.is_empty() {
2590 AdfNode::text(content)
2591 } else {
2592 let inner = parse_inline_no_auto_cards(content);
2595 let mut nodes: Vec<AdfNode> = inner
2596 .into_iter()
2597 .map(|mut node| {
2598 let mut combined = marks.clone();
2599 if let Some(ref existing) = node.marks {
2600 combined.extend(existing.iter().cloned());
2601 }
2602 node.marks = Some(combined);
2603 node
2604 })
2605 .collect();
2606 nodes.remove(0)
2608 }
2609 }
2610 "placeholder" => AdfNode::placeholder(content),
2611 "media-inline" => {
2612 let mut json_attrs = serde_json::Map::new();
2613 if let Some(ref attrs) = d.attrs {
2614 for key in &["type", "id", "collection", "url", "alt", "width", "height"] {
2615 if let Some(val) = attrs.get(key) {
2616 if *key == "width" || *key == "height" {
2617 if let Ok(n) = val.parse::<u64>() {
2618 json_attrs.insert(
2619 (*key).to_string(),
2620 serde_json::Value::Number(n.into()),
2621 );
2622 continue;
2623 }
2624 }
2625 json_attrs.insert(
2626 (*key).to_string(),
2627 serde_json::Value::String(val.to_string()),
2628 );
2629 }
2630 }
2631 if let Some(local_id) = attrs.get("localId") {
2632 json_attrs.insert(
2633 "localId".to_string(),
2634 serde_json::Value::String(local_id.to_string()),
2635 );
2636 }
2637 }
2638 AdfNode::media_inline(serde_json::Value::Object(json_attrs))
2639 }
2640 "extension" => {
2641 let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
2642 let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
2643 AdfNode::inline_extension(ext_type, ext_key, Some(content))
2644 }
2645 _ => return None, };
2647
2648 Some((node, d.end_pos))
2649}
2650
2651fn try_parse_bare_url(text: &str, i: usize) -> Option<(usize, &str)> {
2654 let rest = &text[i..];
2655 if !rest.starts_with("http://") && !rest.starts_with("https://") {
2656 return None;
2657 }
2658 let end = rest
2660 .find(|c: char| c.is_whitespace() || c == ')' || c == ']' || c == '>')
2661 .unwrap_or(rest.len());
2662 let url = rest[..end].trim_end_matches(['.', ',', ';', '!', '?']);
2664 if url.len() <= "https://".len() {
2665 return None; }
2667 Some((i + url.len(), url))
2668}
2669
2670fn try_parse_emoji_shortcode(text: &str, i: usize) -> Option<(usize, &str)> {
2673 let rest = &text[i..];
2674 if !rest.starts_with(':') {
2675 return None;
2676 }
2677 let after = &rest[1..];
2678 let name_end =
2679 after.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')?;
2680 if name_end == 0 {
2681 return None;
2682 }
2683 if after.as_bytes().get(name_end) != Some(&b':') {
2684 return None;
2685 }
2686 let name = &after[..name_end];
2687 Some((i + 1 + name_end + 1, name))
2688}
2689
2690fn parse_emoji_with_attrs(text: &str, shortcode_end: usize, short_name: &str) -> (usize, AdfNode) {
2693 let mut chain_end = shortcode_end;
2698 while let Some((next_end, _)) = try_parse_emoji_shortcode(text, chain_end) {
2699 chain_end = next_end;
2700 }
2701 if chain_end > shortcode_end {
2702 if let Some((attr_end, attrs)) = parse_attrs(text, chain_end) {
2703 return (attr_end, build_emoji_node(&attrs, short_name));
2704 }
2705 }
2706
2707 if let Some((attr_end, attrs)) = parse_attrs(text, shortcode_end) {
2708 (attr_end, build_emoji_node(&attrs, short_name))
2709 } else {
2710 (shortcode_end, AdfNode::emoji(&format!(":{short_name}:")))
2711 }
2712}
2713
2714fn build_emoji_node(attrs: &Attrs, short_name: &str) -> AdfNode {
2717 let resolved_name = attrs
2718 .get("shortName")
2719 .map_or_else(|| format!(":{short_name}:"), str::to_string);
2720 let mut emoji_attrs = serde_json::json!({ "shortName": resolved_name });
2721 if let Some(id) = attrs.get("id") {
2722 emoji_attrs["id"] = serde_json::Value::String(id.to_string());
2723 }
2724 if let Some(t) = attrs.get("text") {
2725 emoji_attrs["text"] = serde_json::Value::String(t.to_string());
2726 }
2727 if let Some(lid) = attrs.get("localId") {
2728 emoji_attrs["localId"] = serde_json::Value::String(lid.to_string());
2729 }
2730 AdfNode {
2731 node_type: "emoji".to_string(),
2732 attrs: Some(emoji_attrs),
2733 content: None,
2734 text: None,
2735 marks: None,
2736 local_id: None,
2737 parameters: None,
2738 }
2739}
2740
2741fn find_closing_paren(s: &str, open: usize) -> Option<usize> {
2746 let mut depth: usize = 0;
2747 for (j, ch) in s[open..].char_indices() {
2748 match ch {
2749 '(' => depth += 1,
2750 ')' => {
2751 depth -= 1;
2752 if depth == 0 {
2753 return Some(open + j);
2754 }
2755 }
2756 _ => {}
2757 }
2758 }
2759 None
2760}
2761
2762fn try_parse_link(text: &str, i: usize) -> Option<(usize, &str, &str)> {
2768 let rest = &text[i..];
2769 if !rest.starts_with('[') {
2770 return None;
2771 }
2772
2773 let mut depth: usize = 0;
2775 let mut text_end = None;
2776 let bytes = rest.as_bytes();
2777 for (j, ch) in rest.char_indices() {
2778 match ch {
2779 '\\' if j + 1 < bytes.len() && (bytes[j + 1] == b'[' || bytes[j + 1] == b']') => {
2780 }
2782 '[' if j == 0 || bytes[j - 1] != b'\\' => depth += 1,
2783 ']' if j == 0 || bytes[j - 1] != b'\\' => {
2784 depth -= 1;
2785 if depth == 0 {
2786 text_end = Some(j);
2787 break;
2788 }
2789 }
2790 _ => {}
2791 }
2792 }
2793
2794 let text_end = text_end?;
2795 let link_text = &rest[1..text_end];
2796 let after_bracket = &rest[text_end + 1..];
2798 if !after_bracket.starts_with('(') {
2799 return None;
2800 }
2801 let url_start = text_end + 1; let url_end = find_closing_paren(rest, url_start)?;
2803 let href = &rest[url_start + 1..url_end];
2804
2805 Some((i + url_end + 1, link_text, href))
2806}
2807
2808#[derive(Debug, Clone, Default)]
2812pub struct RenderOptions {
2813 pub strip_local_ids: bool,
2815}
2816
2817pub fn adf_to_markdown(doc: &AdfDocument) -> Result<String> {
2819 adf_to_markdown_with_options(doc, &RenderOptions::default())
2820}
2821
2822pub fn adf_to_markdown_with_options(doc: &AdfDocument, opts: &RenderOptions) -> Result<String> {
2824 let mut output = String::new();
2825
2826 for (i, node) in doc.content.iter().enumerate() {
2827 if i > 0 {
2828 output.push('\n');
2829 }
2830 render_block_node(node, &mut output, opts);
2831 }
2832
2833 Ok(output)
2834}
2835
2836#[must_use]
2845pub fn adf_to_plain_text(doc: &AdfDocument) -> String {
2846 let mut out = String::new();
2847 for node in &doc.content {
2848 collect_plain_text(node, &mut out);
2849 if !out.is_empty() && !out.ends_with(' ') {
2850 out.push(' ');
2851 }
2852 }
2853 out.truncate(out.trim_end().len());
2854 out
2855}
2856
2857fn collect_plain_text(node: &AdfNode, out: &mut String) {
2858 if let Some(text) = &node.text {
2859 out.push_str(text);
2860 }
2861 if let Some(children) = &node.content {
2862 for child in children {
2863 collect_plain_text(child, out);
2864 }
2865 }
2866}
2867
2868fn pass_through_local_id(dir_attrs: &Option<crate::atlassian::attrs::Attrs>, node: &mut AdfNode) {
2872 if let Some(ref attrs) = dir_attrs {
2873 if let Some(local_id) = attrs.get("localId") {
2874 if let Some(ref mut node_attrs) = node.attrs {
2875 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2876 } else {
2877 node.attrs = Some(serde_json::json!({"localId": local_id}));
2878 }
2879 }
2880 }
2881}
2882
2883fn pass_through_expand_params(
2886 dir_attrs: &Option<crate::atlassian::attrs::Attrs>,
2887 node: &mut AdfNode,
2888) {
2889 if let Some(ref attrs) = dir_attrs {
2890 if let Some(local_id) = attrs.get("localId") {
2891 node.local_id = Some(local_id.to_string());
2892 }
2893 if let Some(params_str) = attrs.get("params") {
2894 if let Ok(params) = serde_json::from_str(params_str) {
2895 node.parameters = Some(params);
2896 }
2897 }
2898 }
2899}
2900
2901fn extract_trailing_local_id(text: &str) -> (&str, Option<String>, Option<String>) {
2911 let trimmed = text.trim_end();
2912 if !trimmed.ends_with('}') {
2913 return (text, None, None);
2914 }
2915 if let Some(brace_pos) = trimmed.rfind('{') {
2920 if brace_pos > 0 && !trimmed.as_bytes()[brace_pos - 1].is_ascii_whitespace() {
2921 return (text, None, None);
2922 }
2923 let attr_str = &trimmed[brace_pos..];
2924 if let Some((_, attrs)) = parse_attrs(attr_str, 0) {
2925 let local_id = attrs.get("localId").map(str::to_string);
2926 let para_local_id = attrs.get("paraLocalId").map(str::to_string);
2927 if local_id.is_some() || para_local_id.is_some() {
2928 let before = trimmed[..brace_pos]
2929 .strip_suffix(' ')
2930 .unwrap_or(&trimmed[..brace_pos]);
2931 return (before, local_id, para_local_id);
2932 }
2933 }
2934 }
2935 (text, None, None)
2936}
2937
2938fn parse_list_item_first_line(
2945 item_text: &str,
2946 sub_lines: Vec<String>,
2947 local_id: Option<String>,
2948 para_local_id: Option<String>,
2949) -> Result<AdfNode> {
2950 if item_text.starts_with("```") {
2951 let mut all_lines = vec![item_text.to_string()];
2953 all_lines.extend(sub_lines);
2954 let combined = all_lines.join("\n");
2955 let nested = MarkdownParser::new(&combined).parse_blocks()?;
2956 Ok(list_item_with_local_id(nested, local_id, para_local_id))
2957 } else if let Some(media) = try_parse_media_single_from_line(item_text) {
2958 if sub_lines.is_empty() {
2960 Ok(list_item_with_local_id(
2961 vec![media],
2962 local_id,
2963 para_local_id,
2964 ))
2965 } else {
2966 let sub_text = sub_lines.join("\n");
2967 let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
2968 let mut content = vec![media];
2969 content.append(&mut nested);
2970 Ok(list_item_with_local_id(content, local_id, para_local_id))
2971 }
2972 } else {
2973 let first_node = AdfNode::paragraph(parse_inline(item_text));
2974 if sub_lines.is_empty() {
2975 Ok(list_item_with_local_id(
2976 vec![first_node],
2977 local_id,
2978 para_local_id,
2979 ))
2980 } else {
2981 let sub_text = sub_lines.join("\n");
2982 let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
2983 let mut content = vec![first_node];
2984 content.append(&mut nested);
2985 Ok(list_item_with_local_id(content, local_id, para_local_id))
2986 }
2987 }
2988}
2989
2990fn list_item_with_local_id(
2991 mut content: Vec<AdfNode>,
2992 local_id: Option<String>,
2993 para_local_id: Option<String>,
2994) -> AdfNode {
2995 if let Some(id) = ¶_local_id {
2996 if let Some(first) = content.first_mut() {
2997 if first.node_type == "paragraph" {
2998 let node_attrs = first.attrs.get_or_insert_with(|| serde_json::json!({}));
2999 node_attrs["localId"] = serde_json::Value::String(id.clone());
3000 }
3001 }
3002 }
3003 let mut item = AdfNode::list_item(content);
3004 if let Some(id) = local_id {
3005 item.attrs = Some(serde_json::json!({"localId": id}));
3006 }
3007 item
3008}
3009
3010fn maybe_push_local_id(attrs: &serde_json::Value, parts: &mut Vec<String>, opts: &RenderOptions) {
3011 if opts.strip_local_ids {
3012 return;
3013 }
3014 if let Some(local_id) = attrs.get("localId").and_then(serde_json::Value::as_str) {
3015 if !local_id.is_empty() && local_id != "00000000-0000-0000-0000-000000000000" {
3016 parts.push(format_kv("localId", local_id));
3017 }
3018 }
3019}
3020
3021fn render_block_children(children: &[AdfNode], output: &mut String, opts: &RenderOptions) {
3023 for (i, child) in children.iter().enumerate() {
3024 if i > 0 {
3025 output.push('\n');
3026 }
3027 render_block_node(child, output, opts);
3028 }
3029}
3030
3031fn fmt_f64_attr(v: f64) -> String {
3034 if v.fract() == 0.0 {
3035 format!("{}", v as i64)
3036 } else {
3037 v.to_string()
3038 }
3039}
3040
3041fn parse_numeric_attr(s: &str) -> Option<serde_json::Value> {
3048 if s.contains('.') || s.contains('e') || s.contains('E') {
3049 s.parse::<f64>().ok().map(serde_json::Value::from)
3050 } else {
3051 s.parse::<i64>().ok().map(serde_json::Value::from)
3052 }
3053}
3054
3055fn fmt_numeric_attr(v: &serde_json::Value) -> Option<String> {
3063 if let Some(n) = v.as_i64() {
3064 return Some(n.to_string());
3065 }
3066 if let Some(n) = v.as_u64() {
3067 return Some(n.to_string());
3068 }
3069 if let Some(n) = v.as_f64() {
3070 if n.fract() == 0.0 && n.is_finite() {
3071 return Some(format!("{n:.1}"));
3072 }
3073 return Some(n.to_string());
3074 }
3075 None
3076}
3077
3078fn render_block_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3080 match node.node_type.as_str() {
3081 "paragraph" => {
3082 let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3083 let dir_attrs = {
3085 let mut parts = Vec::new();
3086 if let Some(ref attrs) = node.attrs {
3087 maybe_push_local_id(attrs, &mut parts, opts);
3088 }
3089 if parts.is_empty() {
3090 String::new()
3091 } else {
3092 format!("{{{}}}", parts.join(" "))
3093 }
3094 };
3095 if is_empty {
3096 output.push_str(&format!("::paragraph{dir_attrs}\n"));
3097 } else {
3098 let mut buf = String::new();
3100 render_inline_content(node, &mut buf, opts);
3101 if buf.trim().is_empty() && !buf.is_empty() {
3102 output.push_str(&format!("::paragraph[{buf}]{dir_attrs}\n"));
3105 } else {
3106 let mut is_first_line = true;
3111 for line in buf.split('\n') {
3112 if is_first_line {
3113 if is_list_start(line) {
3114 output.push_str(&escape_list_marker(line));
3115 } else {
3116 output.push_str(line);
3117 }
3118 is_first_line = false;
3119 } else {
3120 output.push('\n');
3121 if !line.is_empty() {
3122 output.push_str(" ");
3123 }
3124 output.push_str(line);
3125 }
3126 }
3127 output.push('\n');
3128 }
3129 }
3130 }
3131 "heading" => {
3132 let level = node
3133 .attrs
3134 .as_ref()
3135 .and_then(|a| a.get("level"))
3136 .and_then(serde_json::Value::as_u64)
3137 .unwrap_or(1);
3138 for _ in 0..level {
3139 output.push('#');
3140 }
3141 output.push(' ');
3142 let mut buf = String::new();
3143 render_inline_content(node, &mut buf, opts);
3144 let mut is_first_line = true;
3147 for line in buf.split('\n') {
3148 if is_first_line {
3149 output.push_str(line);
3150 is_first_line = false;
3151 } else {
3152 output.push('\n');
3153 if !line.is_empty() {
3154 output.push_str(" ");
3155 }
3156 output.push_str(line);
3157 }
3158 }
3159 output.push('\n');
3160 }
3161 "codeBlock" => {
3162 let language_value = node.attrs.as_ref().and_then(|a| a.get("language"));
3163 let language = language_value
3164 .and_then(serde_json::Value::as_str)
3165 .unwrap_or("");
3166 output.push_str("```");
3167 if language.is_empty() && language_value.is_some() {
3168 output.push_str("\"\"");
3171 } else {
3172 output.push_str(language);
3173 }
3174 output.push('\n');
3175 if let Some(ref content) = node.content {
3176 for child in content {
3177 if let Some(ref text) = child.text {
3178 output.push_str(text);
3179 }
3180 }
3181 }
3182 output.push_str("\n```\n");
3183 }
3184 "blockquote" => {
3185 if let Some(ref content) = node.content {
3186 for (i, child) in content.iter().enumerate() {
3187 if i > 0
3191 && child.node_type == "paragraph"
3192 && content[i - 1].node_type == "paragraph"
3193 {
3194 output.push_str(">\n");
3195 }
3196 let mut inner = String::new();
3197 render_block_node(child, &mut inner, opts);
3198 for line in inner.lines() {
3199 output.push_str("> ");
3200 output.push_str(line);
3201 output.push('\n');
3202 }
3203 }
3204 }
3205 }
3206 "bulletList" => {
3207 if let Some(ref items) = node.content {
3208 for item in items {
3209 output.push_str("- ");
3210 let content_start = output.len();
3211 render_list_item_content(item, output, opts);
3212 if starts_with_task_marker(&output[content_start..]) {
3218 output.insert(content_start, '\\');
3219 }
3220 }
3221 }
3222 }
3223 "orderedList" => {
3224 let start = node
3225 .attrs
3226 .as_ref()
3227 .and_then(|a| a.get("order"))
3228 .and_then(serde_json::Value::as_u64)
3229 .unwrap_or(1);
3230 if let Some(ref items) = node.content {
3231 for (i, item) in items.iter().enumerate() {
3232 let num = start + i as u64;
3233 output.push_str(&format!("{num}. "));
3234 render_list_item_content(item, output, opts);
3235 }
3236 }
3237 }
3238 "taskList" => {
3239 if let Some(ref items) = node.content {
3240 for item in items {
3241 if item.node_type == "taskList" {
3242 let mut nested = String::new();
3246 render_block_node(item, &mut nested, opts);
3247 for line in nested.lines() {
3248 output.push_str(" ");
3249 output.push_str(line);
3250 output.push('\n');
3251 }
3252 } else {
3253 let state = item
3254 .attrs
3255 .as_ref()
3256 .and_then(|a| a.get("state"))
3257 .and_then(serde_json::Value::as_str)
3258 .unwrap_or("TODO");
3259 if state == "DONE" {
3260 output.push_str("- [x] ");
3261 } else {
3262 output.push_str("- [ ] ");
3263 }
3264 render_list_item_content(item, output, opts);
3265 }
3266 }
3267 }
3268 }
3269 "rule" => {
3270 output.push_str("---\n");
3271 }
3272 "table" => {
3273 render_table(node, output, opts);
3274 }
3275 "mediaSingle" => {
3276 if let Some(ref content) = node.content {
3277 for child in content {
3278 if child.node_type == "media" {
3279 render_media(child, node.attrs.as_ref(), output, opts);
3280 }
3281 }
3282 for child in content {
3283 if child.node_type == "caption" {
3284 let mut cap_parts = Vec::new();
3285 if let Some(ref attrs) = child.attrs {
3286 maybe_push_local_id(attrs, &mut cap_parts, opts);
3287 }
3288 if cap_parts.is_empty() {
3289 output.push_str(":::caption\n");
3290 } else {
3291 output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3292 }
3293 if let Some(ref caption_content) = child.content {
3294 for inline in caption_content {
3295 render_inline_node(inline, output, opts);
3296 }
3297 output.push('\n');
3298 }
3299 output.push_str(":::\n");
3300 }
3301 }
3302 }
3303 }
3304 "blockCard" => {
3305 if let Some(ref attrs) = node.attrs {
3306 let url = attrs
3307 .get("url")
3308 .and_then(serde_json::Value::as_str)
3309 .unwrap_or("");
3310 let mut attr_parts = Vec::new();
3311 if url_safe_in_bracket_content(url) {
3312 output.push_str(&format!("::card[{url}]"));
3313 } else {
3314 output.push_str("::card[]");
3316 let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
3317 attr_parts.push(format!("url=\"{escaped}\""));
3318 }
3319 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3320 attr_parts.push(format!("layout={layout}"));
3321 }
3322 if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
3323 attr_parts.push(format!("width={width}"));
3324 }
3325 if !attr_parts.is_empty() {
3326 output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3327 }
3328 output.push('\n');
3329 }
3330 }
3331 "embedCard" => {
3332 if let Some(ref attrs) = node.attrs {
3333 let url = attrs
3334 .get("url")
3335 .and_then(serde_json::Value::as_str)
3336 .unwrap_or("");
3337 output.push_str(&format!("::embed[{url}]"));
3338 let mut attr_parts = Vec::new();
3339 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3340 attr_parts.push(format!("layout={layout}"));
3341 }
3342 if let Some(h) = attrs
3343 .get("originalHeight")
3344 .and_then(serde_json::Value::as_f64)
3345 {
3346 attr_parts.push(format!("originalHeight={}", fmt_f64_attr(h)));
3347 }
3348 if let Some(w) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3349 attr_parts.push(format!("width={}", fmt_f64_attr(w)));
3350 }
3351 if !attr_parts.is_empty() {
3352 output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3353 }
3354 output.push('\n');
3355 }
3356 }
3357 "extension" => {
3358 if let Some(ref attrs) = node.attrs {
3359 let ext_type = attrs
3360 .get("extensionType")
3361 .and_then(serde_json::Value::as_str)
3362 .unwrap_or("");
3363 let ext_key = attrs
3364 .get("extensionKey")
3365 .and_then(serde_json::Value::as_str)
3366 .unwrap_or("");
3367 let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3368 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3369 attr_parts.push(format!("layout={layout}"));
3370 }
3371 if let Some(params) = attrs.get("parameters") {
3372 if let Ok(json_str) = serde_json::to_string(params) {
3373 attr_parts.push(format!("params='{json_str}'"));
3374 }
3375 }
3376 maybe_push_local_id(attrs, &mut attr_parts, opts);
3377 output.push_str(&format!("::extension{{{}}}\n", attr_parts.join(" ")));
3378 }
3379 }
3380 "panel" => {
3381 let panel_type = node
3382 .attrs
3383 .as_ref()
3384 .and_then(|a| a.get("panelType"))
3385 .and_then(serde_json::Value::as_str)
3386 .unwrap_or("info");
3387 let mut attr_parts = vec![format!("type={panel_type}")];
3388 if let Some(ref attrs) = node.attrs {
3389 if let Some(icon) = attrs.get("panelIcon").and_then(serde_json::Value::as_str) {
3390 attr_parts.push(format!("icon=\"{icon}\""));
3391 }
3392 if let Some(color) = attrs.get("panelColor").and_then(serde_json::Value::as_str) {
3393 attr_parts.push(format!("color=\"{color}\""));
3394 }
3395 }
3396 output.push_str(&format!(":::panel{{{}}}\n", attr_parts.join(" ")));
3397 if let Some(ref content) = node.content {
3398 render_block_children(content, output, opts);
3399 }
3400 output.push_str(":::\n");
3401 }
3402 "expand" | "nestedExpand" => {
3403 let directive_name = if node.node_type == "nestedExpand" {
3404 "nested-expand"
3405 } else {
3406 "expand"
3407 };
3408 let mut attr_parts = Vec::new();
3409 if let Some(t) = node
3410 .attrs
3411 .as_ref()
3412 .and_then(|a| a.get("title"))
3413 .and_then(serde_json::Value::as_str)
3414 {
3415 attr_parts.push(format!("title=\"{t}\""));
3416 }
3417 if let Some(ref lid) = node.local_id {
3419 if !opts.strip_local_ids && lid != "00000000-0000-0000-0000-000000000000" {
3420 attr_parts.push(format!("localId={lid}"));
3421 }
3422 } else if let Some(ref attrs) = node.attrs {
3423 maybe_push_local_id(attrs, &mut attr_parts, opts);
3424 }
3425 if let Some(ref params) = node.parameters {
3427 if let Ok(json_str) = serde_json::to_string(params) {
3428 attr_parts.push(format!("params='{json_str}'"));
3429 }
3430 }
3431 if attr_parts.is_empty() {
3432 output.push_str(&format!(":::{directive_name}\n"));
3433 } else {
3434 output.push_str(&format!(
3435 ":::{directive_name}{{{}}}\n",
3436 attr_parts.join(" ")
3437 ));
3438 }
3439 if let Some(ref content) = node.content {
3440 render_block_children(content, output, opts);
3441 }
3442 output.push_str(":::\n");
3443 }
3444 "layoutSection" => {
3445 output.push_str("::::layout\n");
3446 if let Some(ref content) = node.content {
3447 for child in content {
3448 if child.node_type == "layoutColumn" {
3449 let width_str = child
3450 .attrs
3451 .as_ref()
3452 .and_then(|a| a.get("width"))
3453 .and_then(fmt_numeric_attr)
3454 .unwrap_or_else(|| "50".to_string());
3455 let mut parts = vec![format!("width={width_str}")];
3456 if let Some(ref attrs) = child.attrs {
3457 maybe_push_local_id(attrs, &mut parts, opts);
3458 }
3459 output.push_str(&format!(":::column{{{}}}\n", parts.join(" ")));
3460 if let Some(ref col_content) = child.content {
3461 render_block_children(col_content, output, opts);
3462 }
3463 output.push_str(":::\n");
3464 }
3465 }
3466 }
3467 output.push_str("::::\n");
3468 }
3469 "decisionList" => {
3470 output.push_str(":::decisions\n");
3471 if let Some(ref content) = node.content {
3472 for item in content {
3473 output.push_str("- <> ");
3474 render_list_item_content(item, output, opts);
3475 }
3476 }
3477 output.push_str(":::\n");
3478 }
3479 "bodiedExtension" => {
3480 if let Some(ref attrs) = node.attrs {
3481 let ext_type = attrs
3482 .get("extensionType")
3483 .and_then(serde_json::Value::as_str)
3484 .unwrap_or("");
3485 let ext_key = attrs
3486 .get("extensionKey")
3487 .and_then(serde_json::Value::as_str)
3488 .unwrap_or("");
3489 let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3490 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3491 attr_parts.push(format!("layout={layout}"));
3492 }
3493 if let Some(params) = attrs.get("parameters") {
3494 if let Ok(json_str) = serde_json::to_string(params) {
3495 attr_parts.push(format!("params='{json_str}'"));
3496 }
3497 }
3498 maybe_push_local_id(attrs, &mut attr_parts, opts);
3499 output.push_str(&format!(":::extension{{{}}}\n", attr_parts.join(" ")));
3500 if let Some(ref content) = node.content {
3501 render_block_children(content, output, opts);
3502 }
3503 output.push_str(":::\n");
3504 }
3505 }
3506 _ => {
3507 if let Ok(json) = serde_json::to_string_pretty(node) {
3509 output.push_str("```adf-unsupported\n");
3510 output.push_str(&json);
3511 output.push_str("\n```\n");
3512 }
3513 }
3514 }
3515
3516 let mut parts = Vec::new();
3518 if let Some(ref marks) = node.marks {
3519 for mark in marks {
3520 match mark.mark_type.as_str() {
3521 "alignment" => {
3522 if let Some(align) = mark
3523 .attrs
3524 .as_ref()
3525 .and_then(|a| a.get("align"))
3526 .and_then(serde_json::Value::as_str)
3527 {
3528 parts.push(format!("align={align}"));
3529 }
3530 }
3531 "indentation" => {
3532 if let Some(level) = mark
3533 .attrs
3534 .as_ref()
3535 .and_then(|a| a.get("level"))
3536 .and_then(serde_json::Value::as_u64)
3537 {
3538 parts.push(format!("indent={level}"));
3539 }
3540 }
3541 "breakout" => {
3542 if let Some(mode) = mark
3543 .attrs
3544 .as_ref()
3545 .and_then(|a| a.get("mode"))
3546 .and_then(serde_json::Value::as_str)
3547 {
3548 parts.push(format!("breakout={mode}"));
3549 }
3550 if let Some(width) = mark
3551 .attrs
3552 .as_ref()
3553 .and_then(|a| a.get("width"))
3554 .and_then(serde_json::Value::as_u64)
3555 {
3556 parts.push(format!("breakoutWidth={width}"));
3557 }
3558 }
3559 _ => {}
3560 }
3561 }
3562 }
3563 let para_used_directive = node.node_type == "paragraph" && {
3567 let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3568 if is_empty {
3569 true
3570 } else {
3571 let mut buf = String::new();
3572 render_inline_content(node, &mut buf, opts);
3573 buf.trim().is_empty() && !buf.is_empty()
3574 }
3575 };
3576 if !matches!(node.node_type.as_str(), "expand" | "nestedExpand") && !para_used_directive {
3577 if let Some(ref attrs) = node.attrs {
3578 maybe_push_local_id(attrs, &mut parts, opts);
3579 }
3580 }
3581 if node.node_type == "orderedList" {
3586 if let Some(ref attrs) = node.attrs {
3587 if attrs.get("order").and_then(serde_json::Value::as_u64) == Some(1) {
3588 parts.push("order=1".to_string());
3589 }
3590 }
3591 }
3592 if !parts.is_empty() {
3593 output.push_str(&format!("{{{}}}\n", parts.join(" ")));
3594 }
3595}
3596
3597fn render_list_item_content(item: &AdfNode, output: &mut String, opts: &RenderOptions) {
3605 let Some(ref content) = item.content else {
3606 let bare = AdfNode::text("");
3608 emit_list_item_local_ids(item, &bare, output, opts);
3609 output.push('\n');
3610 return;
3611 };
3612 if content.is_empty() {
3613 let bare = AdfNode::text("");
3614 emit_list_item_local_ids(item, &bare, output, opts);
3615 output.push('\n');
3616 return;
3617 }
3618 let first = &content[0];
3619 let rest_start;
3620 if first.node_type == "paragraph" {
3621 let mut buf = String::new();
3622 render_inline_content(first, &mut buf, opts);
3623 let buf = buf.trim_end_matches('\n');
3629 let mut is_first_line = true;
3632 for line in buf.split('\n') {
3633 if is_first_line {
3634 output.push_str(line);
3635 is_first_line = false;
3636 } else {
3637 output.push('\n');
3638 if !line.is_empty() {
3639 output.push_str(" ");
3640 }
3641 output.push_str(line);
3642 }
3643 }
3644 emit_list_item_local_ids(item, first, output, opts);
3646 output.push('\n');
3647 rest_start = 1;
3648 } else if is_inline_node_type(&first.node_type) {
3649 rest_start = content
3651 .iter()
3652 .position(|c| !is_inline_node_type(&c.node_type))
3653 .unwrap_or(content.len());
3654 let mut buf = String::new();
3655 for child in &content[..rest_start] {
3656 render_inline_node(child, &mut buf, opts);
3657 }
3658 let buf = buf.trim_end_matches('\n');
3661 let mut is_first_line = true;
3662 for line in buf.split('\n') {
3663 if is_first_line {
3664 output.push_str(line);
3665 is_first_line = false;
3666 } else {
3667 output.push('\n');
3668 if !line.is_empty() {
3669 output.push_str(" ");
3670 }
3671 output.push_str(line);
3672 }
3673 }
3674 let bare = AdfNode::text("");
3676 emit_list_item_local_ids(item, &bare, output, opts);
3677 output.push('\n');
3678 } else if first.node_type == "taskItem" {
3681 let bare = AdfNode::text("");
3686 emit_list_item_local_ids(item, &bare, output, opts);
3687 output.push('\n');
3688 for child in content {
3689 if child.node_type == "taskItem" {
3690 let state = child
3691 .attrs
3692 .as_ref()
3693 .and_then(|a| a.get("state"))
3694 .and_then(serde_json::Value::as_str)
3695 .unwrap_or("TODO");
3696 let marker = if state == "DONE" { "- [x] " } else { "- [ ] " };
3697 output.push_str(" ");
3698 output.push_str(marker);
3699 render_list_item_content(child, output, opts);
3700 } else {
3701 let mut nested = String::new();
3702 render_block_node(child, &mut nested, opts);
3703 for line in nested.lines() {
3704 output.push_str(" ");
3705 output.push_str(line);
3706 output.push('\n');
3707 }
3708 }
3709 }
3710 return;
3711 } else {
3712 let mut buf = String::new();
3718 render_block_node(first, &mut buf, opts);
3719 let bare = AdfNode::text("");
3720 let mut is_first = true;
3721 for line in buf.lines() {
3722 if is_first {
3723 output.push_str(line);
3724 emit_list_item_local_ids(item, &bare, output, opts);
3725 output.push('\n');
3726 is_first = false;
3727 } else {
3728 output.push_str(" ");
3729 output.push_str(line);
3730 output.push('\n');
3731 }
3732 }
3733 rest_start = 1;
3734 }
3735 let rest = &content[rest_start..];
3736 for (i, child) in rest.iter().enumerate() {
3737 if child.node_type == "paragraph" {
3741 let prev_is_para = if i == 0 {
3742 first.node_type == "paragraph"
3745 } else {
3746 rest[i - 1].node_type == "paragraph"
3747 };
3748 if prev_is_para {
3749 output.push_str(" \n");
3750 }
3751 }
3752 let mut nested = String::new();
3753 render_block_node(child, &mut nested, opts);
3754 for line in nested.lines() {
3755 output.push_str(" ");
3756 output.push_str(line);
3757 output.push('\n');
3758 }
3759 }
3760}
3761
3762fn is_inline_node_type(node_type: &str) -> bool {
3764 matches!(
3765 node_type,
3766 "text"
3767 | "hardBreak"
3768 | "inlineCard"
3769 | "emoji"
3770 | "mention"
3771 | "status"
3772 | "date"
3773 | "placeholder"
3774 | "mediaInline"
3775 )
3776}
3777
3778fn emit_list_item_local_ids(
3781 item: &AdfNode,
3782 paragraph: &AdfNode,
3783 output: &mut String,
3784 opts: &RenderOptions,
3785) {
3786 if opts.strip_local_ids {
3787 return;
3788 }
3789 let mut parts = Vec::new();
3790 if let Some(ref attrs) = item.attrs {
3791 maybe_push_local_id(attrs, &mut parts, opts);
3792 }
3793 if paragraph.node_type == "paragraph" {
3794 let has_real_id = paragraph
3795 .attrs
3796 .as_ref()
3797 .and_then(|a| a.get("localId"))
3798 .and_then(serde_json::Value::as_str)
3799 .filter(|id| !id.is_empty() && *id != "00000000-0000-0000-0000-000000000000");
3800 if let Some(local_id) = has_real_id {
3801 parts.push(format!("paraLocalId={local_id}"));
3802 } else if item.node_type == "taskItem" {
3803 parts.push("paraLocalId=_".to_string());
3807 }
3808 }
3809 if !parts.is_empty() {
3810 output.push_str(&format!(" {{{}}}", parts.join(" ")));
3811 }
3812}
3813
3814fn render_table(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3816 let Some(ref rows) = node.content else {
3817 return;
3818 };
3819
3820 if table_qualifies_for_pipe_syntax(rows) {
3821 render_pipe_table(node, rows, output, opts);
3822 } else {
3823 render_directive_table(node, rows, output, opts);
3824 }
3825}
3826
3827fn table_qualifies_for_pipe_syntax(rows: &[AdfNode]) -> bool {
3834 if rows.iter().any(|n| n.node_type == "caption") {
3836 return false;
3837 }
3838 let mut first_row_has_header = false;
3839 for (row_idx, row) in rows.iter().enumerate() {
3840 let Some(ref cells) = row.content else {
3841 continue;
3842 };
3843 for cell in cells {
3844 if row_idx > 0 && cell.node_type == "tableHeader" {
3846 return false;
3847 }
3848 if row_idx == 0 && cell.node_type == "tableHeader" {
3849 first_row_has_header = true;
3850 }
3851 let Some(ref content) = cell.content else {
3853 continue;
3854 };
3855 if content.len() != 1 || content[0].node_type != "paragraph" {
3856 return false;
3857 }
3858 if cell_contains_hard_break(&content[0]) {
3861 return false;
3862 }
3863 if cell.marks.as_ref().is_some_and(|m| !m.is_empty()) {
3866 return false;
3867 }
3868 if content[0]
3871 .attrs
3872 .as_ref()
3873 .and_then(|a| a.get("localId"))
3874 .is_some()
3875 {
3876 return false;
3877 }
3878 }
3879 }
3880 first_row_has_header
3883}
3884
3885fn cell_contains_hard_break(paragraph: &AdfNode) -> bool {
3887 paragraph
3888 .content
3889 .as_ref()
3890 .is_some_and(|nodes| nodes.iter().any(|n| n.node_type == "hardBreak"))
3891}
3892
3893fn render_pipe_table(node: &AdfNode, rows: &[AdfNode], output: &mut String, opts: &RenderOptions) {
3895 for (row_idx, row) in rows.iter().enumerate() {
3896 let Some(ref cells) = row.content else {
3897 continue;
3898 };
3899
3900 output.push('|');
3901 for cell in cells {
3902 output.push(' ');
3903 let mut cell_buf = String::new();
3904 render_cell_attrs_prefix(cell, &mut cell_buf);
3905 render_inline_content_from_first_paragraph(cell, &mut cell_buf, opts);
3906 output.push_str(&escape_pipes_in_cell(&cell_buf));
3907 output.push_str(" |");
3908 }
3909 output.push('\n');
3910
3911 if row_idx == 0 {
3913 output.push('|');
3914 for cell in cells {
3915 let align = get_cell_paragraph_alignment(cell);
3916 match align {
3917 Some("center") => output.push_str(" :---: |"),
3918 Some("end") => output.push_str(" ---: |"),
3919 _ => output.push_str(" --- |"),
3920 }
3921 }
3922 output.push('\n');
3923 }
3924 }
3925
3926 render_table_level_attrs(node, output, opts);
3928}
3929
3930fn render_directive_table(
3932 node: &AdfNode,
3933 rows: &[AdfNode],
3934 output: &mut String,
3935 opts: &RenderOptions,
3936) {
3937 let mut attr_parts = Vec::new();
3939 if let Some(ref attrs) = node.attrs {
3940 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3941 attr_parts.push(format!("layout={layout}"));
3942 }
3943 if let Some(numbered) = attrs
3944 .get("isNumberColumnEnabled")
3945 .and_then(serde_json::Value::as_bool)
3946 {
3947 if numbered {
3948 attr_parts.push("numbered".to_string());
3949 } else {
3950 attr_parts.push("numbered=false".to_string());
3951 }
3952 }
3953 if let Some(tw) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3954 let tw_str = if tw.fract() == 0.0 {
3955 (tw as u64).to_string()
3956 } else {
3957 tw.to_string()
3958 };
3959 attr_parts.push(format!("width={tw_str}"));
3960 }
3961 maybe_push_local_id(attrs, &mut attr_parts, opts);
3962 }
3963 if attr_parts.is_empty() {
3964 output.push_str("::::table\n");
3965 } else {
3966 output.push_str(&format!("::::table{{{}}}\n", attr_parts.join(" ")));
3967 }
3968
3969 for row in rows {
3970 if row.node_type == "caption" {
3971 let mut cap_parts = Vec::new();
3972 if let Some(ref attrs) = row.attrs {
3973 maybe_push_local_id(attrs, &mut cap_parts, opts);
3974 }
3975 if cap_parts.is_empty() {
3976 output.push_str(":::caption\n");
3977 } else {
3978 output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3979 }
3980 if let Some(ref content) = row.content {
3981 for child in content {
3982 render_inline_node(child, output, opts);
3983 }
3984 output.push('\n');
3985 }
3986 output.push_str(":::\n");
3987 continue;
3988 }
3989 let Some(ref cells) = row.content else {
3990 continue;
3991 };
3992 let mut tr_attrs = Vec::new();
3994 if let Some(ref attrs) = row.attrs {
3995 maybe_push_local_id(attrs, &mut tr_attrs, opts);
3996 }
3997 if tr_attrs.is_empty() {
3998 output.push_str(":::tr\n");
3999 } else {
4000 output.push_str(&format!(":::tr{{{}}}\n", tr_attrs.join(" ")));
4001 }
4002 for cell in cells {
4003 let directive_name = if cell.node_type == "tableHeader" {
4004 "th"
4005 } else {
4006 "td"
4007 };
4008 let mut cell_attr_str = build_cell_attrs_string(cell);
4009 if let Some(ref attrs) = cell.attrs {
4011 let mut lid_parts = Vec::new();
4012 maybe_push_local_id(attrs, &mut lid_parts, opts);
4013 if !lid_parts.is_empty() {
4014 if !cell_attr_str.is_empty() {
4015 cell_attr_str.push(' ');
4016 }
4017 cell_attr_str.push_str(&lid_parts.join(" "));
4018 }
4019 }
4020 if let Some(ref marks) = cell.marks {
4022 for mark in marks {
4023 if mark.mark_type == "border" {
4024 if let Some(ref attrs) = mark.attrs {
4025 if let Some(color) =
4026 attrs.get("color").and_then(serde_json::Value::as_str)
4027 {
4028 if !cell_attr_str.is_empty() {
4029 cell_attr_str.push(' ');
4030 }
4031 cell_attr_str.push_str(&format!("border-color={color}"));
4032 }
4033 if let Some(size) =
4034 attrs.get("size").and_then(serde_json::Value::as_u64)
4035 {
4036 if !cell_attr_str.is_empty() {
4037 cell_attr_str.push(' ');
4038 }
4039 cell_attr_str.push_str(&format!("border-size={size}"));
4040 }
4041 }
4042 }
4043 }
4044 }
4045 let has_marks = cell.marks.as_ref().is_some_and(|m| !m.is_empty());
4046 if cell_attr_str.is_empty() && cell.attrs.is_none() && !has_marks {
4047 output.push_str(&format!(":::{directive_name}\n"));
4048 } else {
4049 output.push_str(&format!(":::{directive_name}{{{cell_attr_str}}}\n"));
4050 }
4051 if let Some(ref content) = cell.content {
4052 render_block_children(content, output, opts);
4053 }
4054 output.push_str(":::\n");
4055 }
4056 output.push_str(":::\n");
4057 }
4058
4059 output.push_str("::::\n");
4060}
4061
4062fn needs_attr_quoting(value: &str) -> bool {
4066 value.contains(|c: char| c.is_whitespace() || c == '}' || c == '(' || c == ')' || c == ',')
4067}
4068
4069fn build_cell_attrs_string(cell: &AdfNode) -> String {
4071 let Some(ref attrs) = cell.attrs else {
4072 return String::new();
4073 };
4074 let mut parts = Vec::new();
4075 if let Some(colspan) = attrs.get("colspan").and_then(serde_json::Value::as_u64) {
4076 parts.push(format!("colspan={colspan}"));
4077 }
4078 if let Some(rowspan) = attrs.get("rowspan").and_then(serde_json::Value::as_u64) {
4079 parts.push(format!("rowspan={rowspan}"));
4080 }
4081 if let Some(bg) = attrs.get("background").and_then(serde_json::Value::as_str) {
4082 if needs_attr_quoting(bg) {
4083 let escaped = bg.replace('\\', "\\\\").replace('"', "\\\"");
4084 parts.push(format!("bg=\"{escaped}\""));
4085 } else {
4086 parts.push(format!("bg={bg}"));
4087 }
4088 }
4089 if let Some(colwidth) = attrs.get("colwidth").and_then(serde_json::Value::as_array) {
4090 let widths: Vec<String> = colwidth
4091 .iter()
4092 .filter_map(|v| {
4093 if let Some(n) = v.as_u64() {
4096 Some(n.to_string())
4097 } else if let Some(n) = v.as_f64() {
4098 if n.fract() == 0.0 {
4099 format!("{n:.1}")
4100 } else {
4101 n.to_string()
4102 }
4103 .into()
4104 } else {
4105 None
4106 }
4107 })
4108 .collect();
4109 if !widths.is_empty() {
4110 parts.push(format!("colwidth={}", widths.join(",")));
4111 }
4112 }
4113 parts.join(" ")
4114}
4115
4116fn render_cell_attrs_prefix(cell: &AdfNode, output: &mut String) {
4118 let Some(ref _attrs) = cell.attrs else {
4119 return;
4120 };
4121 let attr_str = build_cell_attrs_string(cell);
4122 if attr_str.is_empty() {
4123 output.push_str("{} ");
4124 } else {
4125 output.push_str(&format!("{{{attr_str}}} "));
4126 }
4127}
4128
4129fn get_cell_paragraph_alignment(cell: &AdfNode) -> Option<&str> {
4131 let content = cell.content.as_ref()?;
4132 let para = content.first()?;
4133 let marks = para.marks.as_ref()?;
4134 marks.iter().find_map(|m| {
4135 if m.mark_type == "alignment" {
4136 m.attrs
4137 .as_ref()
4138 .and_then(|a| a.get("align"))
4139 .and_then(serde_json::Value::as_str)
4140 } else {
4141 None
4142 }
4143 })
4144}
4145
4146fn render_table_level_attrs(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4148 if let Some(ref attrs) = node.attrs {
4149 let mut parts = Vec::new();
4150 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
4151 parts.push(format!("layout={layout}"));
4152 }
4153 if let Some(numbered) = attrs
4154 .get("isNumberColumnEnabled")
4155 .and_then(serde_json::Value::as_bool)
4156 {
4157 if numbered {
4158 parts.push("numbered".to_string());
4159 } else {
4160 parts.push("numbered=false".to_string());
4161 }
4162 }
4163 if let Some(tw_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4164 parts.push(format!("width={tw_str}"));
4165 }
4166 maybe_push_local_id(attrs, &mut parts, opts);
4167 if !parts.is_empty() {
4168 output.push_str(&format!("{{{}}}\n", parts.join(" ")));
4169 }
4170 }
4171}
4172
4173fn render_inline_content_from_first_paragraph(
4175 cell: &AdfNode,
4176 output: &mut String,
4177 opts: &RenderOptions,
4178) {
4179 if let Some(ref content) = cell.content {
4180 if let Some(first) = content.first() {
4181 if first.node_type == "paragraph" {
4182 render_inline_content(first, output, opts);
4183 }
4184 }
4185 }
4186}
4187
4188fn push_border_mark_attrs(marks: &Option<Vec<AdfMark>>, parts: &mut Vec<String>) {
4190 if let Some(ref marks) = marks {
4191 for mark in marks {
4192 if mark.mark_type == "border" {
4193 if let Some(ref attrs) = mark.attrs {
4194 if let Some(color) = attrs.get("color").and_then(serde_json::Value::as_str) {
4195 parts.push(format!("border-color={color}"));
4196 }
4197 if let Some(size) = attrs.get("size").and_then(serde_json::Value::as_u64) {
4198 parts.push(format!("border-size={size}"));
4199 }
4200 }
4201 }
4202 }
4203 }
4204}
4205
4206fn render_media(
4208 node: &AdfNode,
4209 parent_attrs: Option<&serde_json::Value>,
4210 output: &mut String,
4211 opts: &RenderOptions,
4212) {
4213 if let Some(ref attrs) = node.attrs {
4214 let media_type = attrs
4215 .get("type")
4216 .and_then(serde_json::Value::as_str)
4217 .unwrap_or("external");
4218 let alt = attrs
4219 .get("alt")
4220 .and_then(serde_json::Value::as_str)
4221 .unwrap_or("");
4222
4223 if media_type == "file" {
4224 output.push_str(&format!("![{alt}]()"));
4226 let mut parts = vec!["type=file".to_string()];
4227 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4228 parts.push(format_kv("id", id));
4229 }
4230 if let Some(collection) = attrs.get("collection").and_then(serde_json::Value::as_str) {
4231 parts.push(format_kv("collection", collection));
4232 }
4233 if let Some(occurrence_key) = attrs
4234 .get("occurrenceKey")
4235 .and_then(serde_json::Value::as_str)
4236 {
4237 parts.push(format_kv("occurrenceKey", occurrence_key));
4238 }
4239 if let Some(height_str) = attrs.get("height").and_then(fmt_numeric_attr) {
4240 parts.push(format!("height={height_str}"));
4241 }
4242 if let Some(width_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4243 parts.push(format!("width={width_str}"));
4244 }
4245 maybe_push_local_id(attrs, &mut parts, opts);
4246 if let Some(p_attrs) = parent_attrs {
4248 if let Some(layout) = p_attrs.get("layout").and_then(serde_json::Value::as_str) {
4249 if layout != "center" {
4250 parts.push(format!("layout={layout}"));
4251 }
4252 }
4253 if let Some(ms_width_str) = p_attrs.get("width").and_then(fmt_numeric_attr) {
4254 parts.push(format!("mediaWidth={ms_width_str}"));
4255 }
4256 if let Some(wt) = p_attrs.get("widthType").and_then(serde_json::Value::as_str) {
4257 parts.push(format!("widthType={wt}"));
4258 }
4259 if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4260 parts.push(format!("mode={mode}"));
4261 }
4262 }
4263 push_border_mark_attrs(&node.marks, &mut parts);
4264 output.push_str(&format!("{{{}}}", parts.join(" ")));
4265 } else {
4266 let url = attrs
4268 .get("url")
4269 .and_then(serde_json::Value::as_str)
4270 .unwrap_or("");
4271 output.push_str(&format!(""));
4272
4273 {
4275 let mut parts = Vec::new();
4276 if let Some(p_attrs) = parent_attrs {
4277 let layout = p_attrs.get("layout").and_then(serde_json::Value::as_str);
4278 let width_str = p_attrs.get("width").and_then(fmt_numeric_attr);
4279 let width_type = p_attrs.get("widthType").and_then(serde_json::Value::as_str);
4280 if let Some(l) = layout {
4281 if l != "center" {
4282 parts.push(format!("layout={l}"));
4283 }
4284 }
4285 if let Some(w) = width_str {
4286 parts.push(format!("width={w}"));
4287 }
4288 if let Some(wt) = width_type {
4289 parts.push(format!("widthType={wt}"));
4290 }
4291 if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4292 parts.push(format!("mode={mode}"));
4293 }
4294 }
4295 maybe_push_local_id(attrs, &mut parts, opts);
4296 push_border_mark_attrs(&node.marks, &mut parts);
4297 if !parts.is_empty() {
4298 output.push_str(&format!("{{{}}}", parts.join(" ")));
4299 }
4300 }
4301 }
4302
4303 output.push('\n');
4304 }
4305}
4306
4307fn render_inline_content(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4309 if let Some(ref content) = node.content {
4310 for child in content {
4311 render_inline_node(child, output, opts);
4312 }
4313 }
4314}
4315
4316fn render_inline_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4318 match node.node_type.as_str() {
4319 "text" => {
4320 let text = node.text.as_deref().unwrap_or("");
4321 let marks = node.marks.as_deref().unwrap_or(&[]);
4322 let has_code = marks.iter().any(|m| m.mark_type == "code");
4323 let owned;
4328 let text = if !has_code {
4329 owned = text.replace('\\', "\\\\");
4330 owned.as_str()
4331 } else {
4332 text
4333 };
4334 let owned_nl;
4339 let text = if text.contains('\n') {
4340 owned_nl = text.replace('\n', "\\n");
4341 owned_nl.as_str()
4342 } else {
4343 text
4344 };
4345 let owned_ts;
4351 let text = if !has_code && text.ends_with(" ") {
4352 let mut s = text.to_string();
4353 s.insert(s.len() - 1, '\\');
4355 owned_ts = s;
4356 owned_ts.as_str()
4357 } else {
4358 text
4359 };
4360 render_marked_text(text, marks, output);
4361 }
4362 "hardBreak" => {
4363 output.push_str("\\\n");
4364 }
4365 other => {
4366 let mut body = String::new();
4370 render_non_text_inline_body(other, node, &mut body, opts);
4371
4372 let annotations: Vec<&AdfMark> = node
4373 .marks
4374 .as_deref()
4375 .unwrap_or(&[])
4376 .iter()
4377 .filter(|m| m.mark_type == "annotation")
4378 .collect();
4379
4380 if annotations.is_empty() {
4381 output.push_str(&body);
4382 } else {
4383 let mut attr_parts = Vec::new();
4384 for ann in &annotations {
4385 if let Some(ref attrs) = ann.attrs {
4386 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4387 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4388 attr_parts.push(format!("annotation-id=\"{escaped}\""));
4389 }
4390 if let Some(at) = attrs
4391 .get("annotationType")
4392 .and_then(serde_json::Value::as_str)
4393 {
4394 attr_parts.push(format!("annotation-type={at}"));
4395 }
4396 }
4397 }
4398 output.push('[');
4399 output.push_str(&body);
4400 output.push_str("]{");
4401 output.push_str(&attr_parts.join(" "));
4402 output.push('}');
4403 }
4404 }
4405 }
4406}
4407
4408fn render_non_text_inline_body(
4410 node_type: &str,
4411 node: &AdfNode,
4412 output: &mut String,
4413 opts: &RenderOptions,
4414) {
4415 match node_type {
4416 "inlineCard" => {
4417 if let Some(ref attrs) = node.attrs {
4418 if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4419 let mut attr_parts = Vec::new();
4420 if url_safe_in_bracket_content(url) {
4421 output.push_str(":card[");
4422 output.push_str(url);
4423 output.push(']');
4424 } else {
4425 output.push_str(":card[]");
4429 let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
4430 attr_parts.push(format!("url=\"{escaped}\""));
4431 }
4432 maybe_push_local_id(attrs, &mut attr_parts, opts);
4433 if !attr_parts.is_empty() {
4434 output.push('{');
4435 output.push_str(&attr_parts.join(" "));
4436 output.push('}');
4437 }
4438 }
4439 }
4440 }
4441 "emoji" => {
4442 if let Some(ref attrs) = node.attrs {
4443 if let Some(short_name) = attrs.get("shortName").and_then(serde_json::Value::as_str)
4444 {
4445 output.push(':');
4446 let name = short_name.strip_prefix(':').unwrap_or(short_name);
4447 let name = name.strip_suffix(':').unwrap_or(name);
4448 output.push_str(name);
4449 output.push(':');
4450
4451 let mut parts = Vec::new();
4452 let escaped_sn = short_name.replace('\\', "\\\\").replace('"', "\\\"");
4453 parts.push(format!("shortName=\"{escaped_sn}\""));
4454 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4455 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4456 parts.push(format!("id=\"{escaped}\""));
4457 }
4458 if let Some(text) = attrs.get("text").and_then(serde_json::Value::as_str) {
4459 let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
4460 parts.push(format!("text=\"{escaped}\""));
4461 }
4462 maybe_push_local_id(attrs, &mut parts, opts);
4463 output.push('{');
4464 output.push_str(&parts.join(" "));
4465 output.push('}');
4466 }
4467 }
4468 }
4469 "status" => {
4470 if let Some(ref attrs) = node.attrs {
4471 let text = attrs
4472 .get("text")
4473 .and_then(serde_json::Value::as_str)
4474 .unwrap_or("");
4475 let color = attrs
4476 .get("color")
4477 .and_then(serde_json::Value::as_str)
4478 .unwrap_or("neutral");
4479 let mut attr_parts = vec![format!("color={color}")];
4480 if let Some(style) = attrs.get("style").and_then(serde_json::Value::as_str) {
4481 attr_parts.push(format!("style={style}"));
4482 }
4483 maybe_push_local_id(attrs, &mut attr_parts, opts);
4484 output.push_str(&format!(":status[{text}]{{{}}}", attr_parts.join(" ")));
4485 }
4486 }
4487 "date" => {
4488 if let Some(ref attrs) = node.attrs {
4489 if let Some(timestamp) = attrs.get("timestamp").and_then(serde_json::Value::as_str)
4490 {
4491 let display = epoch_ms_to_iso_date(timestamp);
4492 let mut attr_parts = vec![format!("timestamp={timestamp}")];
4493 maybe_push_local_id(attrs, &mut attr_parts, opts);
4494 output.push_str(&format!(":date[{display}]{{{}}}", attr_parts.join(" ")));
4495 }
4496 }
4497 }
4498 "mention" => {
4499 if let Some(ref attrs) = node.attrs {
4500 let id = attrs
4501 .get("id")
4502 .and_then(serde_json::Value::as_str)
4503 .unwrap_or("");
4504 let text = attrs
4505 .get("text")
4506 .and_then(serde_json::Value::as_str)
4507 .unwrap_or("");
4508 let mut attr_parts = vec![format!("id={id}")];
4509 if let Some(ut) = attrs.get("userType").and_then(serde_json::Value::as_str) {
4510 attr_parts.push(format!("userType={ut}"));
4511 }
4512 if let Some(al) = attrs.get("accessLevel").and_then(serde_json::Value::as_str) {
4513 attr_parts.push(format!("accessLevel={al}"));
4514 }
4515 maybe_push_local_id(attrs, &mut attr_parts, opts);
4516 output.push_str(&format!(":mention[{text}]{{{}}}", attr_parts.join(" ")));
4517 }
4518 }
4519 "placeholder" => {
4520 if let Some(ref attrs) = node.attrs {
4521 let text = attrs
4522 .get("text")
4523 .and_then(serde_json::Value::as_str)
4524 .unwrap_or("");
4525 output.push_str(&format!(":placeholder[{text}]"));
4526 }
4527 }
4528 "inlineExtension" => {
4529 if let Some(ref attrs) = node.attrs {
4530 let ext_type = attrs
4531 .get("extensionType")
4532 .and_then(serde_json::Value::as_str)
4533 .unwrap_or("");
4534 let ext_key = attrs
4535 .get("extensionKey")
4536 .and_then(serde_json::Value::as_str)
4537 .unwrap_or("");
4538 let fallback = node.text.as_deref().unwrap_or("");
4539 output.push_str(&format!(
4540 ":extension[{fallback}]{{type={ext_type} key={ext_key}}}"
4541 ));
4542 }
4543 }
4544 "mediaInline" => {
4545 if let Some(ref attrs) = node.attrs {
4546 let mut attr_parts = Vec::new();
4547 if let Some(media_type) = attrs.get("type").and_then(serde_json::Value::as_str) {
4548 attr_parts.push(format_kv("type", media_type));
4549 }
4550 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4551 attr_parts.push(format_kv("id", id));
4552 }
4553 if let Some(collection) =
4554 attrs.get("collection").and_then(serde_json::Value::as_str)
4555 {
4556 attr_parts.push(format_kv("collection", collection));
4557 }
4558 if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4559 attr_parts.push(format_kv("url", url));
4560 }
4561 if let Some(alt) = attrs.get("alt").and_then(serde_json::Value::as_str) {
4562 attr_parts.push(format_kv("alt", alt));
4563 }
4564 if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
4565 attr_parts.push(format!("width={width}"));
4566 }
4567 if let Some(height) = attrs.get("height").and_then(serde_json::Value::as_u64) {
4568 attr_parts.push(format!("height={height}"));
4569 }
4570 maybe_push_local_id(attrs, &mut attr_parts, opts);
4571 output.push_str(&format!(":media-inline[]{{{}}}", attr_parts.join(" ")));
4572 }
4573 }
4574 _ => {
4575 output.push_str(&format!("<!-- unsupported inline: {} -->", node.node_type));
4576 }
4577 }
4578}
4579
4580fn render_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4593 if marks.iter().any(|m| m.mark_type == "code") {
4594 render_code_marked_text(text, marks, output);
4595 return;
4596 }
4597
4598 let has_link = marks.iter().any(|m| m.mark_type == "link");
4599 let has_strong = marks.iter().any(|m| m.mark_type == "strong");
4600 let has_em = marks.iter().any(|m| m.mark_type == "em");
4601
4602 if marks.len() == 2 && marks[0].mark_type == "strong" && marks[1].mark_type == "em" {
4606 let escaped = escape_emphasis_markers(text);
4607 let escaped = escape_emoji_shortcodes(&escaped);
4608 let escaped = escape_backticks(&escaped);
4609 let escaped = escape_bare_urls(&escaped);
4610 output.push_str("***");
4611 output.push_str(&escaped);
4612 output.push_str("***");
4613 return;
4614 }
4615
4616 let em_delim = if has_strong && has_em { "_" } else { "*" };
4620
4621 let escaped = if em_delim == "_" {
4624 escape_emphasis_markers_with_underscore(text)
4625 } else {
4626 escape_emphasis_markers(text)
4627 };
4628 let escaped = escape_emoji_shortcodes(&escaped);
4629 let escaped = escape_backticks(&escaped);
4630 let escaped = escape_bare_urls(&escaped);
4638 let escaped = if has_link {
4639 escape_link_brackets(&escaped)
4640 } else {
4641 escaped
4642 };
4643
4644 let mut wrappers: Vec<(String, String)> = Vec::new();
4650 let mut i = 0;
4651 while i < marks.len() {
4652 match marks[i].mark_type.as_str() {
4653 "em" => {
4654 wrappers.push((em_delim.to_string(), em_delim.to_string()));
4655 i += 1;
4656 }
4657 "strong" => {
4658 wrappers.push(("**".to_string(), "**".to_string()));
4659 i += 1;
4660 }
4661 "strike" => {
4662 wrappers.push(("~~".to_string(), "~~".to_string()));
4663 i += 1;
4664 }
4665 "link" => {
4666 let href = link_href(&marks[i]);
4667 wrappers.push(("[".to_string(), format!("]({href})")));
4668 i += 1;
4669 }
4670 "textColor" | "backgroundColor" | "subsup" => {
4671 let start = i;
4672 while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4673 i += 1;
4674 }
4675 emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4676 }
4677 "underline" | "annotation" => {
4678 let start = i;
4679 while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4680 i += 1;
4681 }
4682 emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4683 }
4684 _ => {
4685 i += 1;
4686 }
4687 }
4688 }
4689
4690 let mut result = escaped;
4692 for (open, close) in wrappers.iter().rev() {
4693 result.insert_str(0, open);
4694 result.push_str(close);
4695 }
4696 output.push_str(&result);
4697}
4698
4699fn render_code_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4707 let link_mark = marks.iter().find(|m| m.mark_type == "link");
4708
4709 let mut code_str = String::new();
4710 if let Some(link_mark) = link_mark {
4711 let href = link_href(link_mark);
4712 code_str.push('[');
4713 render_inline_code(text, &mut code_str);
4714 code_str.push_str("](");
4715 code_str.push_str(href);
4716 code_str.push(')');
4717 } else {
4718 render_inline_code(text, &mut code_str);
4719 }
4720
4721 let mut wrappers: Vec<(String, String)> = Vec::new();
4724 let mut i = 0;
4725 while i < marks.len() {
4726 match marks[i].mark_type.as_str() {
4727 "textColor" | "backgroundColor" | "subsup" => {
4728 let start = i;
4729 while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4730 i += 1;
4731 }
4732 emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4733 }
4734 "underline" | "annotation" => {
4735 let start = i;
4736 while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4737 i += 1;
4738 }
4739 emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4740 }
4741 _ => {
4742 i += 1;
4743 }
4744 }
4745 }
4746
4747 let mut result = code_str;
4749 for (open, close) in wrappers.iter().rev() {
4750 result.insert_str(0, open);
4751 result.push_str(close);
4752 }
4753 output.push_str(&result);
4754}
4755
4756fn collect_span_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4758 match mark.mark_type.as_str() {
4759 "textColor" => {
4760 if let Some(c) = mark
4761 .attrs
4762 .as_ref()
4763 .and_then(|a| a.get("color"))
4764 .and_then(serde_json::Value::as_str)
4765 {
4766 attrs.push(format!("color={c}"));
4767 }
4768 }
4769 "backgroundColor" => {
4770 if let Some(c) = mark
4771 .attrs
4772 .as_ref()
4773 .and_then(|a| a.get("color"))
4774 .and_then(serde_json::Value::as_str)
4775 {
4776 attrs.push(format!("bg={c}"));
4777 }
4778 }
4779 "subsup" => {
4780 if let Some(kind) = mark
4781 .attrs
4782 .as_ref()
4783 .and_then(|a| a.get("type"))
4784 .and_then(serde_json::Value::as_str)
4785 {
4786 attrs.push(kind.to_string());
4787 }
4788 }
4789 _ => {}
4790 }
4791}
4792
4793fn collect_bracketed_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4795 match mark.mark_type.as_str() {
4796 "underline" => attrs.push("underline".to_string()),
4797 "annotation" => {
4798 if let Some(ref a) = mark.attrs {
4799 if let Some(id) = a.get("id").and_then(serde_json::Value::as_str) {
4800 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4801 attrs.push(format!("annotation-id=\"{escaped}\""));
4802 }
4803 if let Some(at) = a.get("annotationType").and_then(serde_json::Value::as_str) {
4804 attrs.push(format!("annotation-type={at}"));
4805 }
4806 }
4807 }
4808 _ => {}
4809 }
4810}
4811
4812fn is_span_attr_mark(mark_type: &str) -> bool {
4813 matches!(mark_type, "textColor" | "backgroundColor" | "subsup")
4814}
4815
4816fn is_bracketed_span_mark(mark_type: &str) -> bool {
4817 matches!(mark_type, "underline" | "annotation")
4818}
4819
4820fn span_attr_order(mark_type: &str) -> u8 {
4824 match mark_type {
4825 "textColor" => 0,
4826 "backgroundColor" => 1,
4827 "subsup" => 2,
4828 _ => u8::MAX,
4829 }
4830}
4831
4832fn span_run_is_canonical(run: &[AdfMark]) -> bool {
4837 let mut prev = 0;
4838 for m in run {
4839 let order = span_attr_order(&m.mark_type);
4840 if order == u8::MAX || order < prev {
4841 return false;
4842 }
4843 prev = order;
4844 }
4845 true
4846}
4847
4848fn bracketed_run_is_canonical(run: &[AdfMark]) -> bool {
4853 let mut seen_annotation = false;
4854 for m in run {
4855 match m.mark_type.as_str() {
4856 "underline" => {
4857 if seen_annotation {
4858 return false;
4859 }
4860 }
4861 "annotation" => seen_annotation = true,
4862 _ => return false,
4863 }
4864 }
4865 true
4866}
4867
4868fn emit_span_attr_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4872 if span_run_is_canonical(run) {
4873 let mut attrs = Vec::new();
4874 for m in run {
4875 collect_span_attr(m, &mut attrs);
4876 }
4877 wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4878 return;
4879 }
4880 for m in run {
4881 let mut attrs = Vec::new();
4882 collect_span_attr(m, &mut attrs);
4883 wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4884 }
4885}
4886
4887fn emit_bracketed_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4891 if bracketed_run_is_canonical(run) {
4892 let mut attrs = Vec::new();
4893 for m in run {
4894 collect_bracketed_attr(m, &mut attrs);
4895 }
4896 wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4897 return;
4898 }
4899 for m in run {
4900 let mut attrs = Vec::new();
4901 collect_bracketed_attr(m, &mut attrs);
4902 wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4903 }
4904}
4905
4906fn link_href(mark: &AdfMark) -> &str {
4908 mark.attrs
4909 .as_ref()
4910 .and_then(|a| a.get("href"))
4911 .and_then(serde_json::Value::as_str)
4912 .unwrap_or("")
4913}
4914
4915#[cfg(test)]
4916#[allow(
4917 clippy::unwrap_used,
4918 clippy::expect_used,
4919 clippy::needless_update,
4920 clippy::needless_collect,
4921 duplicate_macro_attributes
4922)]
4923mod tests {
4924 use super::*;
4925
4926 #[test]
4929 fn adf_to_plain_text_single_paragraph() {
4930 let doc = markdown_to_adf("Hello world").unwrap();
4931 assert_eq!(adf_to_plain_text(&doc), "Hello world");
4932 }
4933
4934 #[test]
4935 fn adf_to_plain_text_multiple_paragraphs_space_separated() {
4936 let doc = markdown_to_adf("Alpha\n\nBeta").unwrap();
4937 let plain = adf_to_plain_text(&doc);
4938 assert!(plain.contains("Alpha"));
4940 assert!(plain.contains("Beta"));
4941 assert_eq!(plain, "Alpha Beta");
4942 }
4943
4944 #[test]
4945 fn adf_to_plain_text_drops_marks_but_keeps_text() {
4946 let doc = markdown_to_adf("Hello **bold** world").unwrap();
4947 assert_eq!(adf_to_plain_text(&doc), "Hello bold world");
4948 }
4949
4950 #[test]
4951 fn adf_to_plain_text_empty_doc() {
4952 let doc = AdfDocument::new();
4953 assert_eq!(adf_to_plain_text(&doc), "");
4954 }
4955
4956 #[test]
4957 fn adf_to_plain_text_leading_empty_block_emits_no_extra_space() {
4958 let doc = AdfDocument {
4961 version: 1,
4962 doc_type: "doc".to_string(),
4963 content: vec![
4964 AdfNode {
4965 node_type: "paragraph".to_string(),
4966 attrs: None,
4967 content: Some(vec![]),
4968 text: None,
4969 marks: None,
4970 local_id: None,
4971 parameters: None,
4972 },
4973 AdfNode {
4974 node_type: "paragraph".to_string(),
4975 attrs: None,
4976 content: Some(vec![AdfNode::text("Hello")]),
4977 text: None,
4978 marks: None,
4979 local_id: None,
4980 parameters: None,
4981 },
4982 ],
4983 };
4984 assert_eq!(adf_to_plain_text(&doc), "Hello");
4985 }
4986
4987 #[test]
4990 fn paragraph() {
4991 let doc = markdown_to_adf("Hello world").unwrap();
4992 assert_eq!(doc.content.len(), 1);
4993 assert_eq!(doc.content[0].node_type, "paragraph");
4994 }
4995
4996 #[test]
4997 fn heading_levels() {
4998 for level in 1..=6 {
4999 let hashes = "#".repeat(level);
5000 let md = format!("{hashes} Title");
5001 let doc = markdown_to_adf(&md).unwrap();
5002 assert_eq!(doc.content[0].node_type, "heading");
5003 let attrs = doc.content[0].attrs.as_ref().unwrap();
5004 assert_eq!(attrs["level"], level as u64);
5005 }
5006 }
5007
5008 #[test]
5011 fn heading_inline_code_mark_stripped() {
5012 let doc = markdown_to_adf("### `GET /api`").unwrap();
5015 let heading = &doc.content[0];
5016 assert_eq!(heading.node_type, "heading");
5017 let content = heading.content.as_ref().unwrap();
5018 assert_eq!(content.len(), 1);
5019 assert_eq!(content[0].text.as_deref(), Some("GET /api"));
5020 assert!(
5021 content[0].marks.is_none(),
5022 "expected no marks, got: {:?}",
5023 content[0].marks
5024 );
5025 }
5026
5027 #[test]
5028 fn heading_code_strip_preserves_sibling_strong() {
5029 let doc = markdown_to_adf("### **`x`**").unwrap();
5031 let content = doc.content[0].content.as_ref().unwrap();
5032 let marks = content[0].marks.as_ref().unwrap();
5033 assert!(marks.iter().any(|m| m.mark_type == "strong"));
5034 assert!(!marks.iter().any(|m| m.mark_type == "code"));
5035 }
5036
5037 #[test]
5038 fn heading_code_strip_preserves_link() {
5039 let doc = markdown_to_adf("### [`x`](https://e.com)").unwrap();
5041 let content = doc.content[0].content.as_ref().unwrap();
5042 let marks = content[0].marks.as_ref().unwrap();
5043 assert!(marks.iter().any(|m| m.mark_type == "link"));
5044 assert!(!marks.iter().any(|m| m.mark_type == "code"));
5045 }
5046
5047 #[test]
5048 fn heading_with_code_now_passes_validation() {
5049 let doc = markdown_to_adf("### `GET /api`").unwrap();
5052 let violations = crate::atlassian::adf_schema::validate_document(&doc);
5053 assert!(violations.is_empty(), "got: {violations:?}");
5054 }
5055
5056 #[test]
5057 fn paragraph_inline_code_mark_preserved() {
5058 let doc = markdown_to_adf("`x`").unwrap();
5061 let content = doc.content[0].content.as_ref().unwrap();
5062 let marks = content[0].marks.as_ref().unwrap();
5063 assert!(marks.iter().any(|m| m.mark_type == "code"));
5064 }
5065
5066 #[test]
5067 fn code_block() {
5068 let md = "```rust\nfn main() {}\n```";
5069 let doc = markdown_to_adf(md).unwrap();
5070 assert_eq!(doc.content[0].node_type, "codeBlock");
5071 let attrs = doc.content[0].attrs.as_ref().unwrap();
5072 assert_eq!(attrs["language"], "rust");
5073 }
5074
5075 #[test]
5076 fn code_block_no_language() {
5077 let md = "```\nsome code\n```";
5078 let doc = markdown_to_adf(md).unwrap();
5079 assert_eq!(doc.content[0].node_type, "codeBlock");
5080 assert!(doc.content[0].attrs.is_none());
5081 }
5082
5083 #[test]
5084 fn code_block_empty_language() {
5085 let md = "```\"\"\nsome code\n```";
5086 let doc = markdown_to_adf(md).unwrap();
5087 assert_eq!(doc.content[0].node_type, "codeBlock");
5088 let attrs = doc.content[0].attrs.as_ref().unwrap();
5089 assert_eq!(attrs["language"], "");
5090 }
5091
5092 #[test]
5093 fn horizontal_rule() {
5094 let doc = markdown_to_adf("---").unwrap();
5095 assert_eq!(doc.content[0].node_type, "rule");
5096 }
5097
5098 #[test]
5099 fn horizontal_rule_stars() {
5100 let doc = markdown_to_adf("***").unwrap();
5101 assert_eq!(doc.content[0].node_type, "rule");
5102 }
5103
5104 #[test]
5105 fn blockquote() {
5106 let md = "> This is a quote\n> Second line";
5107 let doc = markdown_to_adf(md).unwrap();
5108 assert_eq!(doc.content[0].node_type, "blockquote");
5109 }
5110
5111 #[test]
5112 fn bullet_list() {
5113 let md = "- Item 1\n- Item 2\n- Item 3";
5114 let doc = markdown_to_adf(md).unwrap();
5115 assert_eq!(doc.content[0].node_type, "bulletList");
5116 let items = doc.content[0].content.as_ref().unwrap();
5117 assert_eq!(items.len(), 3);
5118 }
5119
5120 #[test]
5121 fn ordered_list() {
5122 let md = "1. First\n2. Second\n3. Third";
5123 let doc = markdown_to_adf(md).unwrap();
5124 assert_eq!(doc.content[0].node_type, "orderedList");
5125 let items = doc.content[0].content.as_ref().unwrap();
5126 assert_eq!(items.len(), 3);
5127 }
5128
5129 #[test]
5130 fn task_list() {
5131 let md = "- [ ] Todo item\n- [x] Done item";
5132 let doc = markdown_to_adf(md).unwrap();
5133 assert_eq!(doc.content[0].node_type, "taskList");
5134 let items = doc.content[0].content.as_ref().unwrap();
5135 assert_eq!(items.len(), 2);
5136 assert_eq!(items[0].node_type, "taskItem");
5137 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5138 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5139 }
5140
5141 #[test]
5142 fn task_list_uppercase_x() {
5143 let md = "- [X] Done item";
5144 let doc = markdown_to_adf(md).unwrap();
5145 assert_eq!(doc.content[0].node_type, "taskList");
5146 let item = &doc.content[0].content.as_ref().unwrap()[0];
5147 assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
5148 }
5149
5150 #[test]
5153 fn task_list_empty_todo_no_trailing_space() {
5154 let md = "- [ ]";
5155 let doc = markdown_to_adf(md).unwrap();
5156 assert_eq!(doc.content[0].node_type, "taskList");
5157 let items = doc.content[0].content.as_ref().unwrap();
5158 assert_eq!(items.len(), 1);
5159 assert_eq!(items[0].node_type, "taskItem");
5160 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5161 assert!(items[0].content.is_none());
5162 }
5163
5164 #[test]
5166 fn task_list_empty_done_no_trailing_space() {
5167 let md = "- [x]\n- [X]";
5168 let doc = markdown_to_adf(md).unwrap();
5169 assert_eq!(doc.content[0].node_type, "taskList");
5170 let items = doc.content[0].content.as_ref().unwrap();
5171 assert_eq!(items.len(), 2);
5172 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
5173 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5174 }
5175
5176 #[test]
5179 fn task_list_body_has_no_leading_space() {
5180 let md = "- [ ] Buy groceries";
5181 let doc = markdown_to_adf(md).unwrap();
5182 let item = &doc.content[0].content.as_ref().unwrap()[0];
5183 let text = item.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5184 assert_eq!(text, "Buy groceries");
5185 }
5186
5187 #[test]
5191 fn round_trip_empty_task_items_stripped_trailing_spaces() {
5192 let json = r#"{
5193 "version": 1,
5194 "type": "doc",
5195 "content": [{
5196 "type": "taskList",
5197 "attrs": {"localId": "abc"},
5198 "content": [
5199 {"type": "taskItem", "attrs": {"localId": "def", "state": "TODO"}},
5200 {"type": "taskItem", "attrs": {"localId": "ghi", "state": "DONE"}}
5201 ]
5202 }]
5203 }"#;
5204 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5205 let md = adf_to_markdown(&doc).unwrap();
5206 let stripped: String = md.lines().map(str::trim_end).collect::<Vec<_>>().join("\n");
5207 let parsed = markdown_to_adf(&stripped).unwrap();
5208 assert_eq!(parsed.content[0].node_type, "taskList");
5209 let items = parsed.content[0].content.as_ref().unwrap();
5210 assert_eq!(items.len(), 2);
5211 assert_eq!(items[0].node_type, "taskItem");
5212 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5213 assert_eq!(items[1].node_type, "taskItem");
5214 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5215 }
5216
5217 #[test]
5218 fn try_parse_task_marker_accepts_bare_checkbox() {
5219 assert_eq!(try_parse_task_marker("[ ]"), Some(("TODO", "")));
5220 assert_eq!(try_parse_task_marker("[x]"), Some(("DONE", "")));
5221 assert_eq!(try_parse_task_marker("[X]"), Some(("DONE", "")));
5222 assert_eq!(try_parse_task_marker("[ ] foo"), Some(("TODO", "foo")));
5223 assert_eq!(try_parse_task_marker("[x] foo"), Some(("DONE", "foo")));
5224 assert_eq!(try_parse_task_marker("[ ]foo"), None);
5225 assert_eq!(try_parse_task_marker("[x]foo"), None);
5226 assert_eq!(try_parse_task_marker("[y] foo"), None);
5227 }
5228
5229 #[test]
5230 fn starts_with_task_marker_matches_parser() {
5231 assert!(starts_with_task_marker("[ ]"));
5234 assert!(starts_with_task_marker("[x]"));
5235 assert!(starts_with_task_marker("[X]"));
5236 assert!(starts_with_task_marker("[ ] foo"));
5237 assert!(starts_with_task_marker("[x] foo\n"));
5238 assert!(starts_with_task_marker("[ ]\n"));
5239 assert!(!starts_with_task_marker("[ ]foo"));
5241 assert!(!starts_with_task_marker("[y] foo"));
5242 assert!(!starts_with_task_marker("foo [ ] bar"));
5243 assert!(!starts_with_task_marker(""));
5244 }
5245
5246 #[test]
5250 fn round_trip_bullet_list_with_literal_checkbox_text() {
5251 let json = r#"{
5252 "version": 1,
5253 "type": "doc",
5254 "content": [{
5255 "type": "bulletList",
5256 "content": [{
5257 "type": "listItem",
5258 "content": [{
5259 "type": "paragraph",
5260 "content": [
5261 {"type": "text", "text": "[ ] Review the "},
5262 {"type": "text", "text": "config.yaml", "marks": [{"type": "code"}]},
5263 {"type": "text", "text": " file"}
5264 ]
5265 }]
5266 }]
5267 }]
5268 }"#;
5269 let original: AdfDocument = serde_json::from_str(json).unwrap();
5270 let md = adf_to_markdown(&original).unwrap();
5271 assert!(
5273 md.contains(r"- \[ ] Review the "),
5274 "rendered markdown: {md:?}"
5275 );
5276 let parsed = markdown_to_adf(&md).unwrap();
5277 assert_eq!(parsed.content[0].node_type, "bulletList");
5278 let item = &parsed.content[0].content.as_ref().unwrap()[0];
5279 assert_eq!(item.node_type, "listItem");
5280 let para = &item.content.as_ref().unwrap()[0];
5281 assert_eq!(para.node_type, "paragraph");
5282 let text_nodes = para.content.as_ref().unwrap();
5283 assert_eq!(text_nodes[0].text.as_deref().unwrap(), "[ ] Review the ");
5284 assert_eq!(text_nodes[1].text.as_deref().unwrap(), "config.yaml");
5285 assert_eq!(text_nodes[2].text.as_deref().unwrap(), " file");
5286 }
5287
5288 #[test]
5290 fn round_trip_bullet_list_with_literal_done_checkbox_text() {
5291 let json = r#"{
5292 "version": 1,
5293 "type": "doc",
5294 "content": [{
5295 "type": "bulletList",
5296 "content": [{
5297 "type": "listItem",
5298 "content": [{
5299 "type": "paragraph",
5300 "content": [{"type": "text", "text": "[x] not actually done"}]
5301 }]
5302 }]
5303 }]
5304 }"#;
5305 let original: AdfDocument = serde_json::from_str(json).unwrap();
5306 let md = adf_to_markdown(&original).unwrap();
5307 assert!(md.contains(r"- \[x] "), "rendered markdown: {md:?}");
5308 let parsed = markdown_to_adf(&md).unwrap();
5309 assert_eq!(parsed.content[0].node_type, "bulletList");
5310 let item = &parsed.content[0].content.as_ref().unwrap()[0];
5311 let para = &item.content.as_ref().unwrap()[0];
5312 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5313 assert_eq!(text, "[x] not actually done");
5314 }
5315
5316 #[test]
5318 fn round_trip_bullet_list_with_bare_literal_checkbox() {
5319 let json = r#"{
5320 "version": 1,
5321 "type": "doc",
5322 "content": [{
5323 "type": "bulletList",
5324 "content": [{
5325 "type": "listItem",
5326 "content": [{
5327 "type": "paragraph",
5328 "content": [{"type": "text", "text": "[ ]"}]
5329 }]
5330 }]
5331 }]
5332 }"#;
5333 let original: AdfDocument = serde_json::from_str(json).unwrap();
5334 let md = adf_to_markdown(&original).unwrap();
5335 let parsed = markdown_to_adf(&md).unwrap();
5336 assert_eq!(parsed.content[0].node_type, "bulletList");
5337 let item = &parsed.content[0].content.as_ref().unwrap()[0];
5338 let para = &item.content.as_ref().unwrap()[0];
5339 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5340 assert_eq!(text, "[ ]");
5341 }
5342
5343 #[test]
5346 fn bullet_list_non_task_bracket_text_not_escaped() {
5347 let json = r#"{
5348 "version": 1,
5349 "type": "doc",
5350 "content": [{
5351 "type": "bulletList",
5352 "content": [{
5353 "type": "listItem",
5354 "content": [{
5355 "type": "paragraph",
5356 "content": [{"type": "text", "text": "[?] unsure"}]
5357 }]
5358 }]
5359 }]
5360 }"#;
5361 let original: AdfDocument = serde_json::from_str(json).unwrap();
5362 let md = adf_to_markdown(&original).unwrap();
5363 assert!(!md.contains(r"\["), "should not escape: {md:?}");
5364 assert!(md.contains("- [?] unsure"), "rendered: {md:?}");
5365 }
5366
5367 #[test]
5370 fn round_trip_nested_bullet_list_with_literal_checkbox_text() {
5371 let json = r#"{
5372 "version": 1,
5373 "type": "doc",
5374 "content": [{
5375 "type": "bulletList",
5376 "content": [{
5377 "type": "listItem",
5378 "content": [
5379 {"type": "paragraph", "content": [{"type": "text", "text": "outer"}]},
5380 {"type": "bulletList", "content": [{
5381 "type": "listItem",
5382 "content": [{
5383 "type": "paragraph",
5384 "content": [{"type": "text", "text": "[ ] inner literal"}]
5385 }]
5386 }]}
5387 ]
5388 }]
5389 }]
5390 }"#;
5391 let original: AdfDocument = serde_json::from_str(json).unwrap();
5392 let md = adf_to_markdown(&original).unwrap();
5393 let parsed = markdown_to_adf(&md).unwrap();
5394 let outer = &parsed.content[0];
5395 assert_eq!(outer.node_type, "bulletList");
5396 let outer_item = &outer.content.as_ref().unwrap()[0];
5397 let inner_list = &outer_item.content.as_ref().unwrap()[1];
5398 assert_eq!(inner_list.node_type, "bulletList");
5399 let inner_item = &inner_list.content.as_ref().unwrap()[0];
5400 assert_eq!(inner_item.node_type, "listItem");
5401 let para = &inner_item.content.as_ref().unwrap()[0];
5402 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5403 assert_eq!(text, "[ ] inner literal");
5404 }
5405
5406 #[test]
5407 fn adf_task_list_to_markdown() {
5408 let doc = AdfDocument {
5409 version: 1,
5410 doc_type: "doc".to_string(),
5411 content: vec![AdfNode::task_list(vec![
5412 AdfNode::task_item(
5413 "TODO",
5414 vec![AdfNode::paragraph(vec![AdfNode::text("Todo")])],
5415 ),
5416 AdfNode::task_item(
5417 "DONE",
5418 vec![AdfNode::paragraph(vec![AdfNode::text("Done")])],
5419 ),
5420 ])],
5421 };
5422 let md = adf_to_markdown(&doc).unwrap();
5423 assert!(md.contains("- [ ] Todo"));
5424 assert!(md.contains("- [x] Done"));
5425 }
5426
5427 #[test]
5428 fn round_trip_task_list() {
5429 let md = "- [ ] Todo item\n- [x] Done item\n";
5430 let doc = markdown_to_adf(md).unwrap();
5431 let result = adf_to_markdown(&doc).unwrap();
5432 assert!(result.contains("- [ ] Todo item"));
5433 assert!(result.contains("- [x] Done item"));
5434 }
5435
5436 #[test]
5438 fn adf_task_item_unwrapped_inline_content() {
5439 let json = r#"{
5441 "version": 1,
5442 "type": "doc",
5443 "content": [{
5444 "type": "taskList",
5445 "attrs": {"localId": "list-001"},
5446 "content": [{
5447 "type": "taskItem",
5448 "attrs": {"localId": "task-001", "state": "TODO"},
5449 "content": [{"type": "text", "text": "Do something"}]
5450 }]
5451 }]
5452 }"#;
5453 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5454 let md = adf_to_markdown(&doc).unwrap();
5455 assert!(md.contains("- [ ] Do something"), "got: {md}");
5456 assert!(!md.contains("adf-unsupported"), "got: {md}");
5457 }
5458
5459 #[test]
5461 fn adf_task_list_multiple_unwrapped_items() {
5462 let json = r#"{
5463 "version": 1,
5464 "type": "doc",
5465 "content": [{
5466 "type": "taskList",
5467 "attrs": {"localId": "list-001"},
5468 "content": [
5469 {
5470 "type": "taskItem",
5471 "attrs": {"localId": "task-001", "state": "TODO"},
5472 "content": [{"type": "text", "text": "First task"}]
5473 },
5474 {
5475 "type": "taskItem",
5476 "attrs": {"localId": "task-002", "state": "DONE"},
5477 "content": [{"type": "text", "text": "Second task"}]
5478 }
5479 ]
5480 }]
5481 }"#;
5482 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5483 let md = adf_to_markdown(&doc).unwrap();
5484 assert!(md.contains("- [ ] First task"), "got: {md}");
5485 assert!(md.contains("- [x] Second task"), "got: {md}");
5486 assert!(!md.contains("adf-unsupported"), "got: {md}");
5487 }
5488
5489 #[test]
5491 fn adf_task_item_unwrapped_inline_with_marks() {
5492 let json = r#"{
5493 "version": 1,
5494 "type": "doc",
5495 "content": [{
5496 "type": "taskList",
5497 "attrs": {"localId": "list-001"},
5498 "content": [{
5499 "type": "taskItem",
5500 "attrs": {"localId": "task-001", "state": "TODO"},
5501 "content": [
5502 {"type": "text", "text": "Buy "},
5503 {"type": "text", "text": "groceries", "marks": [{"type": "strong"}]},
5504 {"type": "text", "text": " today"}
5505 ]
5506 }]
5507 }]
5508 }"#;
5509 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5510 let md = adf_to_markdown(&doc).unwrap();
5511 assert!(md.contains("- [ ] Buy **groceries** today"), "got: {md}");
5512 }
5513
5514 #[test]
5516 fn adf_task_item_unwrapped_preserves_local_id() {
5517 let json = r#"{
5518 "version": 1,
5519 "type": "doc",
5520 "content": [{
5521 "type": "taskList",
5522 "attrs": {"localId": "list-001"},
5523 "content": [{
5524 "type": "taskItem",
5525 "attrs": {"localId": "task-001", "state": "TODO"},
5526 "content": [{"type": "text", "text": "Do something"}]
5527 }]
5528 }]
5529 }"#;
5530 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5531 let md = adf_to_markdown(&doc).unwrap();
5532 assert!(md.contains("{localId=task-001}"), "got: {md}");
5533 assert!(md.contains("{localId=list-001}"), "got: {md}");
5534 }
5535
5536 #[test]
5538 fn round_trip_task_list_unwrapped_inline() {
5539 let json = r#"{
5540 "version": 1,
5541 "type": "doc",
5542 "content": [{
5543 "type": "taskList",
5544 "attrs": {"localId": "list-001"},
5545 "content": [
5546 {
5547 "type": "taskItem",
5548 "attrs": {"localId": "task-001", "state": "TODO"},
5549 "content": [{"type": "text", "text": "Do something"}]
5550 },
5551 {
5552 "type": "taskItem",
5553 "attrs": {"localId": "task-002", "state": "DONE"},
5554 "content": [{"type": "text", "text": "Already done"}]
5555 }
5556 ]
5557 }]
5558 }"#;
5559 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5560 let md = adf_to_markdown(&doc).unwrap();
5561
5562 let doc2 = markdown_to_adf(&md).unwrap();
5564 assert_eq!(doc2.content[0].node_type, "taskList");
5565
5566 let items = doc2.content[0].content.as_ref().unwrap();
5567 assert_eq!(items.len(), 2);
5568 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5569 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5570
5571 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5573 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "task-002");
5574 assert_eq!(
5575 doc2.content[0].attrs.as_ref().unwrap()["localId"],
5576 "list-001"
5577 );
5578 }
5579
5580 #[test]
5582 fn adf_task_item_unwrapped_inline_then_block() {
5583 let json = r#"{
5584 "version": 1,
5585 "type": "doc",
5586 "content": [{
5587 "type": "taskList",
5588 "attrs": {"localId": "list-001"},
5589 "content": [{
5590 "type": "taskItem",
5591 "attrs": {"localId": "task-001", "state": "TODO"},
5592 "content": [
5593 {"type": "text", "text": "Parent task"},
5594 {
5595 "type": "bulletList",
5596 "content": [{
5597 "type": "listItem",
5598 "content": [{
5599 "type": "paragraph",
5600 "content": [{"type": "text", "text": "sub-item"}]
5601 }]
5602 }]
5603 }
5604 ]
5605 }]
5606 }]
5607 }"#;
5608 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5609 let md = adf_to_markdown(&doc).unwrap();
5610 assert!(md.contains("- [ ] Parent task"), "got: {md}");
5611 assert!(md.contains(" - sub-item"), "got: {md}");
5612 assert!(!md.contains("adf-unsupported"), "got: {md}");
5613 }
5614
5615 #[test]
5617 fn adf_task_item_empty_content() {
5618 let json = r#"{
5619 "version": 1,
5620 "type": "doc",
5621 "content": [{
5622 "type": "taskList",
5623 "attrs": {"localId": "list-001"},
5624 "content": [{
5625 "type": "taskItem",
5626 "attrs": {"localId": "task-001", "state": "TODO"},
5627 "content": []
5628 }]
5629 }]
5630 }"#;
5631 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5632 let md = adf_to_markdown(&doc).unwrap();
5633 assert!(md.contains("- [ ] "), "got: {md}");
5634 assert!(!md.contains("adf-unsupported"), "got: {md}");
5635 }
5636
5637 #[test]
5640 fn adf_nested_task_item_renders_without_corruption() {
5641 let json = r#"{
5642 "type": "doc",
5643 "version": 1,
5644 "content": [{
5645 "type": "taskList",
5646 "attrs": {"localId": ""},
5647 "content": [
5648 {
5649 "type": "taskItem",
5650 "attrs": {"localId": "aabbccdd-1234-5678-abcd-aabbccdd1234", "state": "TODO"},
5651 "content": [{"type": "text", "text": "Normal task"}]
5652 },
5653 {
5654 "type": "taskItem",
5655 "attrs": {"localId": ""},
5656 "content": [
5657 {
5658 "type": "taskItem",
5659 "attrs": {"localId": "bbccddee-2345-6789-bcde-bbccddee2345", "state": "TODO"},
5660 "content": [{"type": "text", "text": "Nested task one"}]
5661 },
5662 {
5663 "type": "taskItem",
5664 "attrs": {"localId": "ccddee11-3456-7890-cdef-ccddee113456", "state": "DONE"},
5665 "content": [{"type": "text", "text": "Nested task two"}]
5666 }
5667 ]
5668 }
5669 ]
5670 }]
5671 }"#;
5672 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5673 let md = adf_to_markdown(&doc).unwrap();
5674 assert!(md.contains("- [ ] Normal task"), "got: {md}");
5676 assert!(!md.contains("adf-unsupported"), "got: {md}");
5678 assert!(md.contains(" - [ ] Nested task one"), "got: {md}");
5679 assert!(md.contains(" - [x] Nested task two"), "got: {md}");
5680 }
5681
5682 #[test]
5684 fn round_trip_nested_task_item() {
5685 let json = r#"{
5686 "type": "doc",
5687 "version": 1,
5688 "content": [{
5689 "type": "taskList",
5690 "attrs": {"localId": ""},
5691 "content": [
5692 {
5693 "type": "taskItem",
5694 "attrs": {"localId": "task-001", "state": "TODO"},
5695 "content": [{"type": "text", "text": "Normal task"}]
5696 },
5697 {
5698 "type": "taskItem",
5699 "attrs": {"localId": ""},
5700 "content": [
5701 {
5702 "type": "taskItem",
5703 "attrs": {"localId": "task-002", "state": "TODO"},
5704 "content": [{"type": "text", "text": "Nested one"}]
5705 },
5706 {
5707 "type": "taskItem",
5708 "attrs": {"localId": "task-003", "state": "DONE"},
5709 "content": [{"type": "text", "text": "Nested two"}]
5710 }
5711 ]
5712 }
5713 ]
5714 }]
5715 }"#;
5716 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5717 let md = adf_to_markdown(&doc).unwrap();
5718 let doc2 = markdown_to_adf(&md).unwrap();
5719
5720 assert_eq!(doc2.content[0].node_type, "taskList");
5722 let items = doc2.content[0].content.as_ref().unwrap();
5723 assert_eq!(items.len(), 2, "expected 2 top-level items, got: {items:?}");
5724
5725 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5727 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5728 let first_content = items[0].content.as_ref().unwrap();
5729 assert_eq!(first_content[0].text.as_deref(), Some("Normal task"));
5730
5731 let container = &items[1];
5733 assert_eq!(container.node_type, "taskItem");
5734 let c_attrs = container.attrs.as_ref().unwrap();
5735 assert!(
5736 c_attrs.get("state").is_none(),
5737 "container should have no state attr, got: {c_attrs:?}"
5738 );
5739
5740 let container_content = container.content.as_ref().unwrap();
5742 assert_eq!(
5743 container_content.len(),
5744 2,
5745 "expected 2 bare taskItem children"
5746 );
5747 assert_eq!(container_content[0].node_type, "taskItem");
5748 assert_eq!(
5749 container_content[0].attrs.as_ref().unwrap()["state"],
5750 "TODO"
5751 );
5752 assert_eq!(
5753 container_content[0].attrs.as_ref().unwrap()["localId"],
5754 "task-002"
5755 );
5756 assert_eq!(container_content[1].node_type, "taskItem");
5757 assert_eq!(
5758 container_content[1].attrs.as_ref().unwrap()["state"],
5759 "DONE"
5760 );
5761 assert_eq!(
5762 container_content[1].attrs.as_ref().unwrap()["localId"],
5763 "task-003"
5764 );
5765 }
5766
5767 #[test]
5769 fn adf_nested_task_item_preserves_local_ids() {
5770 let json = r#"{
5771 "type": "doc",
5772 "version": 1,
5773 "content": [{
5774 "type": "taskList",
5775 "attrs": {"localId": "list-001"},
5776 "content": [{
5777 "type": "taskItem",
5778 "attrs": {"localId": "container-001", "state": "TODO"},
5779 "content": [{
5780 "type": "taskItem",
5781 "attrs": {"localId": "child-001", "state": "DONE"},
5782 "content": [{"type": "text", "text": "Nested child"}]
5783 }]
5784 }]
5785 }]
5786 }"#;
5787 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5788 let md = adf_to_markdown(&doc).unwrap();
5789 assert!(
5791 md.contains("localId=container-001"),
5792 "container localId missing: {md}"
5793 );
5794 assert!(
5796 md.contains("localId=child-001"),
5797 "child localId missing: {md}"
5798 );
5799 assert!(!md.contains("adf-unsupported"), "got: {md}");
5800 }
5801
5802 #[test]
5805 fn adf_nested_task_item_mixed_with_block_node() {
5806 let json = r#"{
5807 "type": "doc",
5808 "version": 1,
5809 "content": [{
5810 "type": "taskList",
5811 "attrs": {"localId": ""},
5812 "content": [{
5813 "type": "taskItem",
5814 "attrs": {"localId": "", "state": "TODO"},
5815 "content": [
5816 {
5817 "type": "taskItem",
5818 "attrs": {"localId": "", "state": "TODO"},
5819 "content": [{"type": "text", "text": "A nested task"}]
5820 },
5821 {
5822 "type": "paragraph",
5823 "content": [{"type": "text", "text": "Stray paragraph"}]
5824 }
5825 ]
5826 }]
5827 }]
5828 }"#;
5829 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5830 let md = adf_to_markdown(&doc).unwrap();
5831 assert!(md.contains(" - [ ] A nested task"), "got: {md}");
5832 assert!(md.contains(" Stray paragraph"), "got: {md}");
5833 assert!(!md.contains("adf-unsupported"), "got: {md}");
5834 }
5835
5836 #[test]
5840 fn task_item_with_text_and_nested_sub_content() {
5841 let md = "- [ ] Parent task\n - [ ] Sub task\n";
5842 let doc = markdown_to_adf(md).unwrap();
5843 assert_eq!(doc.content[0].node_type, "taskList");
5844 let items = doc.content[0].content.as_ref().unwrap();
5845 assert_eq!(items.len(), 2, "got: {items:?}");
5848 let parent = &items[0];
5849 assert_eq!(parent.attrs.as_ref().unwrap()["state"], "TODO");
5850 let parent_content = parent.content.as_ref().unwrap();
5851 assert_eq!(parent_content[0].text.as_deref(), Some("Parent task"));
5852 assert_eq!(items[1].node_type, "taskList");
5854 let nested = items[1].content.as_ref().unwrap();
5855 assert_eq!(nested.len(), 1);
5856 assert_eq!(nested[0].attrs.as_ref().unwrap()["state"], "TODO");
5857 }
5858
5859 #[test]
5863 fn task_item_empty_with_non_tasklist_sub_content() {
5864 let md = "- [ ] \n Some paragraph text\n";
5865 let doc = markdown_to_adf(md).unwrap();
5866 assert_eq!(doc.content[0].node_type, "taskList");
5867 let items = doc.content[0].content.as_ref().unwrap();
5868 assert_eq!(items.len(), 1);
5869 let item = &items[0];
5870 assert_eq!(item.attrs.as_ref().unwrap()["state"], "TODO");
5871 let content = item.content.as_ref().unwrap();
5872 assert_eq!(content[0].node_type, "paragraph");
5874 }
5875
5876 #[test]
5878 fn adf_nested_task_item_single_child() {
5879 let json = r#"{
5880 "type": "doc",
5881 "version": 1,
5882 "content": [{
5883 "type": "taskList",
5884 "attrs": {"localId": ""},
5885 "content": [{
5886 "type": "taskItem",
5887 "attrs": {"localId": "", "state": "TODO"},
5888 "content": [{
5889 "type": "taskItem",
5890 "attrs": {"localId": "", "state": "DONE"},
5891 "content": [{"type": "text", "text": "Only child"}]
5892 }]
5893 }]
5894 }]
5895 }"#;
5896 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5897 let md = adf_to_markdown(&doc).unwrap();
5898 assert!(md.contains(" - [x] Only child"), "got: {md}");
5899 assert!(!md.contains("adf-unsupported"), "got: {md}");
5900 }
5901
5902 #[test]
5905 fn adf_nested_tasklist_sibling_renders_indented() {
5906 let json = r#"{
5907 "version": 1,
5908 "type": "doc",
5909 "content": [{
5910 "type": "taskList",
5911 "attrs": {"localId": ""},
5912 "content": [
5913 {
5914 "type": "taskItem",
5915 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000001", "state": "TODO"},
5916 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
5917 },
5918 {
5919 "type": "taskList",
5920 "attrs": {"localId": ""},
5921 "content": [{
5922 "type": "taskItem",
5923 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000002", "state": "TODO"},
5924 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
5925 }]
5926 },
5927 {
5928 "type": "taskItem",
5929 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000003", "state": "TODO"},
5930 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
5931 }
5932 ]
5933 }]
5934 }"#;
5935 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5936 let md = adf_to_markdown(&doc).unwrap();
5937 assert!(md.contains("- [ ] parent task one"), "got: {md}");
5939 assert!(md.contains(" - [ ] nested sub-task"), "got: {md}");
5940 assert!(md.contains("- [ ] parent task two"), "got: {md}");
5941 }
5942
5943 #[test]
5945 fn round_trip_nested_tasklist_preserves_type() {
5946 let json = r#"{
5947 "version": 1,
5948 "type": "doc",
5949 "content": [{
5950 "type": "taskList",
5951 "attrs": {"localId": ""},
5952 "content": [
5953 {
5954 "type": "taskItem",
5955 "attrs": {"localId": "", "state": "TODO"},
5956 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
5957 },
5958 {
5959 "type": "taskList",
5960 "attrs": {"localId": ""},
5961 "content": [{
5962 "type": "taskItem",
5963 "attrs": {"localId": "", "state": "TODO"},
5964 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
5965 }]
5966 },
5967 {
5968 "type": "taskItem",
5969 "attrs": {"localId": "", "state": "TODO"},
5970 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
5971 }
5972 ]
5973 }]
5974 }"#;
5975 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5976 let md = adf_to_markdown(&doc).unwrap();
5977 let rt_doc = markdown_to_adf(&md).unwrap();
5978 assert_eq!(rt_doc.content[0].node_type, "taskList");
5980 let items = rt_doc.content[0].content.as_ref().unwrap();
5981 assert_eq!(items.len(), 3, "got: {items:?}");
5984 assert_eq!(items[0].node_type, "taskItem");
5985 assert_eq!(
5986 items[1].node_type, "taskList",
5987 "nested taskList should survive round-trip"
5988 );
5989 assert_eq!(items[2].node_type, "taskItem");
5990 let nested_items = items[1].content.as_ref().unwrap();
5991 assert_eq!(nested_items[0].attrs.as_ref().unwrap()["state"], "TODO");
5992 }
5993
5994 #[test]
5996 fn adf_nested_tasklist_done_state() {
5997 let json = r#"{
5998 "version": 1,
5999 "type": "doc",
6000 "content": [{
6001 "type": "taskList",
6002 "attrs": {"localId": ""},
6003 "content": [
6004 {
6005 "type": "taskItem",
6006 "attrs": {"localId": "", "state": "TODO"},
6007 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
6008 },
6009 {
6010 "type": "taskList",
6011 "attrs": {"localId": ""},
6012 "content": [{
6013 "type": "taskItem",
6014 "attrs": {"localId": "", "state": "DONE"},
6015 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "done child"}]}]
6016 }]
6017 }
6018 ]
6019 }]
6020 }"#;
6021 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6022 let md = adf_to_markdown(&doc).unwrap();
6023 assert!(md.contains(" - [x] done child"), "got: {md}");
6024 let rt_doc = markdown_to_adf(&md).unwrap();
6026 let items = rt_doc.content[0].content.as_ref().unwrap();
6027 assert_eq!(
6028 items[1].node_type, "taskList",
6029 "nested taskList should survive round-trip"
6030 );
6031 let nested_item = &items[1].content.as_ref().unwrap()[0];
6032 assert_eq!(nested_item.attrs.as_ref().unwrap()["state"], "DONE");
6033 }
6034
6035 #[test]
6037 fn adf_multiple_nested_tasklists() {
6038 let json = r#"{
6039 "version": 1,
6040 "type": "doc",
6041 "content": [{
6042 "type": "taskList",
6043 "attrs": {"localId": ""},
6044 "content": [
6045 {
6046 "type": "taskItem",
6047 "attrs": {"localId": "", "state": "TODO"},
6048 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "first parent"}]}]
6049 },
6050 {
6051 "type": "taskList",
6052 "attrs": {"localId": ""},
6053 "content": [{
6054 "type": "taskItem",
6055 "attrs": {"localId": "", "state": "TODO"},
6056 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child A"}]}]
6057 }]
6058 },
6059 {
6060 "type": "taskItem",
6061 "attrs": {"localId": "", "state": "TODO"},
6062 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "second parent"}]}]
6063 },
6064 {
6065 "type": "taskList",
6066 "attrs": {"localId": ""},
6067 "content": [{
6068 "type": "taskItem",
6069 "attrs": {"localId": "", "state": "DONE"},
6070 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child B"}]}]
6071 }]
6072 }
6073 ]
6074 }]
6075 }"#;
6076 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6077 let md = adf_to_markdown(&doc).unwrap();
6078 assert!(md.contains("- [ ] first parent"), "got: {md}");
6079 assert!(md.contains(" - [ ] child A"), "got: {md}");
6080 assert!(md.contains("- [ ] second parent"), "got: {md}");
6081 assert!(md.contains(" - [x] child B"), "got: {md}");
6082 }
6083
6084 #[test]
6087 fn round_trip_nested_tasklist_stable() {
6088 let json = r#"{
6089 "version": 1,
6090 "type": "doc",
6091 "content": [{
6092 "type": "taskList",
6093 "attrs": {"localId": ""},
6094 "content": [
6095 {
6096 "type": "taskItem",
6097 "attrs": {"localId": "", "state": "TODO"},
6098 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
6099 },
6100 {
6101 "type": "taskList",
6102 "attrs": {"localId": ""},
6103 "content": [{
6104 "type": "taskItem",
6105 "attrs": {"localId": "", "state": "TODO"},
6106 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child"}]}]
6107 }]
6108 }
6109 ]
6110 }]
6111 }"#;
6112 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6113 let md1 = adf_to_markdown(&doc).unwrap();
6115 let rt1 = markdown_to_adf(&md1).unwrap();
6116 let md2 = adf_to_markdown(&rt1).unwrap();
6118 let rt2 = markdown_to_adf(&md2).unwrap();
6119 assert_eq!(md1, md2, "markdown should be stable across round-trips");
6121 let rt1_json = serde_json::to_string(&rt1).unwrap();
6123 let rt2_json = serde_json::to_string(&rt2).unwrap();
6124 assert_eq!(
6125 rt1_json, rt2_json,
6126 "ADF should be stable across round-trips"
6127 );
6128 }
6129
6130 #[test]
6135 fn task_item_mixed_sub_content_splits_siblings() {
6136 let md = "- [ ] Parent task\n - [ ] Sub task\n Some paragraph\n";
6137 let doc = markdown_to_adf(md).unwrap();
6138 let items = doc.content[0].content.as_ref().unwrap();
6139 assert_eq!(items.len(), 2, "got: {items:?}");
6141 assert_eq!(items[0].node_type, "taskItem");
6142 let parent_content = items[0].content.as_ref().unwrap();
6143 assert!(
6145 parent_content.iter().any(|n| n.node_type == "paragraph"),
6146 "non-taskList sub-content should stay as child: {parent_content:?}"
6147 );
6148 assert_eq!(items[1].node_type, "taskList");
6150 }
6151
6152 #[test]
6156 fn empty_task_item_mixed_sub_content_none_arm() {
6157 let md = "- [ ] \n Some paragraph\n - [ ] Sub task\n";
6158 let doc = markdown_to_adf(md).unwrap();
6159 let items = doc.content[0].content.as_ref().unwrap();
6160 assert_eq!(items.len(), 2, "got: {items:?}");
6162 assert_eq!(items[0].node_type, "taskItem");
6163 let parent_content = items[0].content.as_ref().unwrap();
6164 assert!(
6165 parent_content.iter().any(|n| n.node_type == "paragraph"),
6166 "paragraph should be assigned to taskItem: {parent_content:?}"
6167 );
6168 assert_eq!(items[1].node_type, "taskList");
6169 }
6170
6171 #[test]
6176 fn task_item_text_with_non_tasklist_sub_content_only() {
6177 let md = "- [ ] My task\n Extra paragraph content\n";
6178 let doc = markdown_to_adf(md).unwrap();
6179 let items = doc.content[0].content.as_ref().unwrap();
6180 assert_eq!(items.len(), 1, "got: {items:?}");
6182 assert_eq!(items[0].node_type, "taskItem");
6183 let content = items[0].content.as_ref().unwrap();
6184 assert!(
6186 content.iter().any(|n| n.node_type == "paragraph"),
6187 "paragraph sub-content should be a child of taskItem: {content:?}"
6188 );
6189 }
6190
6191 #[test]
6194 fn adf_list_item_leading_block_node() {
6195 let json = r#"{
6196 "version": 1,
6197 "type": "doc",
6198 "content": [{
6199 "type": "bulletList",
6200 "content": [{
6201 "type": "listItem",
6202 "content": [{
6203 "type": "codeBlock",
6204 "attrs": {"language": "rust"},
6205 "content": [{"type": "text", "text": "let x = 1;"}]
6206 }]
6207 }]
6208 }]
6209 }"#;
6210 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6211 let md = adf_to_markdown(&doc).unwrap();
6212 assert!(md.contains("```rust"), "got: {md}");
6213 assert!(md.contains("let x = 1;"), "got: {md}");
6214 for line in md.lines() {
6217 if line.starts_with("- ") {
6218 continue; }
6220 if line.trim().is_empty() {
6221 continue;
6222 }
6223 assert!(
6224 line.starts_with(" "),
6225 "continuation line not indented: {line:?}"
6226 );
6227 }
6228 }
6229
6230 #[test]
6233 fn code_block_in_list_item_backtick_roundtrip() {
6234 let json = r#"{
6235 "version": 1,
6236 "type": "doc",
6237 "content": [{
6238 "type": "bulletList",
6239 "content": [{
6240 "type": "listItem",
6241 "content": [{
6242 "type": "codeBlock",
6243 "attrs": {"language": ""},
6244 "content": [{"type": "text", "text": "error: some value with a backtick ` at end"}]
6245 }]
6246 }]
6247 }]
6248 }"#;
6249 let original: AdfDocument = serde_json::from_str(json).unwrap();
6250 let md = adf_to_markdown(&original).unwrap();
6251 let roundtripped = markdown_to_adf(&md).unwrap();
6252 let list = &roundtripped.content[0];
6253 assert_eq!(list.node_type, "bulletList", "top node: {}", list.node_type);
6254 let item = &list.content.as_ref().unwrap()[0];
6255 let first_child = &item.content.as_ref().unwrap()[0];
6256 assert_eq!(
6257 first_child.node_type, "codeBlock",
6258 "expected codeBlock, got: {}",
6259 first_child.node_type
6260 );
6261 let text = first_child.content.as_ref().unwrap()[0]
6262 .text
6263 .as_deref()
6264 .unwrap();
6265 assert_eq!(text, "error: some value with a backtick ` at end");
6266 }
6267
6268 #[test]
6270 fn code_block_with_language_in_list_item_roundtrip() {
6271 let json = r#"{
6272 "version": 1,
6273 "type": "doc",
6274 "content": [{
6275 "type": "bulletList",
6276 "content": [{
6277 "type": "listItem",
6278 "content": [{
6279 "type": "codeBlock",
6280 "attrs": {"language": "rust"},
6281 "content": [{"type": "text", "text": "fn main() {\n println!(\"hello\");\n}"}]
6282 }]
6283 }]
6284 }]
6285 }"#;
6286 let original: AdfDocument = serde_json::from_str(json).unwrap();
6287 let md = adf_to_markdown(&original).unwrap();
6288 let roundtripped = markdown_to_adf(&md).unwrap();
6289 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6290 let code = &item.content.as_ref().unwrap()[0];
6291 assert_eq!(code.node_type, "codeBlock");
6292 let lang = code
6293 .attrs
6294 .as_ref()
6295 .and_then(|a| a.get("language"))
6296 .and_then(serde_json::Value::as_str)
6297 .unwrap_or("");
6298 assert_eq!(lang, "rust");
6299 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6300 assert!(text.contains("println!"), "code content: {text}");
6301 }
6302
6303 #[test]
6305 fn code_block_in_ordered_list_item_roundtrip() {
6306 let json = r#"{
6307 "version": 1,
6308 "type": "doc",
6309 "content": [{
6310 "type": "orderedList",
6311 "attrs": {"order": 1},
6312 "content": [{
6313 "type": "listItem",
6314 "content": [{
6315 "type": "codeBlock",
6316 "attrs": {"language": ""},
6317 "content": [{"type": "text", "text": "backtick ` here"}]
6318 }]
6319 }]
6320 }]
6321 }"#;
6322 let original: AdfDocument = serde_json::from_str(json).unwrap();
6323 let md = adf_to_markdown(&original).unwrap();
6324 let roundtripped = markdown_to_adf(&md).unwrap();
6325 let list = &roundtripped.content[0];
6326 assert_eq!(list.node_type, "orderedList");
6327 let item = &list.content.as_ref().unwrap()[0];
6328 let code = &item.content.as_ref().unwrap()[0];
6329 assert_eq!(code.node_type, "codeBlock");
6330 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6331 assert_eq!(text, "backtick ` here");
6332 }
6333
6334 #[test]
6336 fn code_block_then_paragraph_in_list_item() {
6337 let json = r#"{
6338 "version": 1,
6339 "type": "doc",
6340 "content": [{
6341 "type": "bulletList",
6342 "content": [{
6343 "type": "listItem",
6344 "content": [
6345 {
6346 "type": "codeBlock",
6347 "attrs": {"language": ""},
6348 "content": [{"type": "text", "text": "code with ` backtick"}]
6349 },
6350 {
6351 "type": "paragraph",
6352 "content": [{"type": "text", "text": "description"}]
6353 }
6354 ]
6355 }]
6356 }]
6357 }"#;
6358 let original: AdfDocument = serde_json::from_str(json).unwrap();
6359 let md = adf_to_markdown(&original).unwrap();
6360 let roundtripped = markdown_to_adf(&md).unwrap();
6361 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6362 let children = item.content.as_ref().unwrap();
6363 assert_eq!(children[0].node_type, "codeBlock");
6364 assert_eq!(children[1].node_type, "paragraph");
6365 }
6366
6367 #[test]
6369 fn code_block_multiple_backticks_in_list_item() {
6370 let json = r#"{
6371 "version": 1,
6372 "type": "doc",
6373 "content": [{
6374 "type": "bulletList",
6375 "content": [{
6376 "type": "listItem",
6377 "content": [{
6378 "type": "codeBlock",
6379 "attrs": {"language": ""},
6380 "content": [{"type": "text", "text": "a ` b `` c ``` d"}]
6381 }]
6382 }]
6383 }]
6384 }"#;
6385 let original: AdfDocument = serde_json::from_str(json).unwrap();
6386 let md = adf_to_markdown(&original).unwrap();
6387 let roundtripped = markdown_to_adf(&md).unwrap();
6388 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6389 let code = &item.content.as_ref().unwrap()[0];
6390 assert_eq!(code.node_type, "codeBlock");
6391 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6392 assert_eq!(text, "a ` b `` c ``` d");
6393 }
6394
6395 #[test]
6398 fn media_first_child_with_sub_content_in_list_item() {
6399 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
6400 {"type":"listItem","content":[
6401 {"type":"mediaSingle","attrs":{"layout":"center"},
6402 "content":[{"type":"media","attrs":{"type":"file","id":"img-99","collection":"col-x","height":50,"width":100}}]},
6403 {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
6404 ]}
6405 ]}]}"#;
6406 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6407 let md = adf_to_markdown(&doc).unwrap();
6408 let rt = markdown_to_adf(&md).unwrap();
6409 let item = &rt.content[0].content.as_ref().unwrap()[0];
6410 let children = item.content.as_ref().unwrap();
6411 assert_eq!(
6412 children.len(),
6413 2,
6414 "expected 2 children, got {}",
6415 children.len()
6416 );
6417 assert_eq!(children[0].node_type, "mediaSingle");
6418 let media = &children[0].content.as_ref().unwrap()[0];
6419 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-99");
6420 assert_eq!(children[1].node_type, "paragraph");
6421 }
6422
6423 #[test]
6424 fn inline_bold() {
6425 let doc = markdown_to_adf("Some **bold** text").unwrap();
6426 let content = doc.content[0].content.as_ref().unwrap();
6427 assert!(content.len() >= 3);
6428 let bold_node = &content[1];
6429 assert_eq!(bold_node.text.as_deref(), Some("bold"));
6430 let marks = bold_node.marks.as_ref().unwrap();
6431 assert_eq!(marks[0].mark_type, "strong");
6432 }
6433
6434 #[test]
6435 fn inline_italic() {
6436 let doc = markdown_to_adf("Some *italic* text").unwrap();
6437 let content = doc.content[0].content.as_ref().unwrap();
6438 let italic_node = &content[1];
6439 assert_eq!(italic_node.text.as_deref(), Some("italic"));
6440 let marks = italic_node.marks.as_ref().unwrap();
6441 assert_eq!(marks[0].mark_type, "em");
6442 }
6443
6444 #[test]
6445 fn inline_code() {
6446 let doc = markdown_to_adf("Use `code` here").unwrap();
6447 let content = doc.content[0].content.as_ref().unwrap();
6448 let code_node = &content[1];
6449 assert_eq!(code_node.text.as_deref(), Some("code"));
6450 let marks = code_node.marks.as_ref().unwrap();
6451 assert_eq!(marks[0].mark_type, "code");
6452 }
6453
6454 #[test]
6458 fn inline_code_with_backtick_emitted_with_double_delimiters() {
6459 let doc = AdfDocument {
6460 version: 1,
6461 doc_type: "doc".to_string(),
6462 content: vec![AdfNode::paragraph(vec![
6463 AdfNode::text("Run "),
6464 AdfNode::text_with_marks(
6465 "ADD `custom_threshold` TEXT NOT NULL",
6466 vec![AdfMark::code()],
6467 ),
6468 AdfNode::text(" to update the schema."),
6469 ])],
6470 };
6471 let md = adf_to_markdown(&doc).unwrap();
6472 assert!(
6473 md.contains("``ADD `custom_threshold` TEXT NOT NULL``"),
6474 "expected double-backtick delimiters, got: {md}"
6475 );
6476 }
6477
6478 #[test]
6481 fn inline_code_double_backtick_delimiters_parse() {
6482 let doc = markdown_to_adf("Run ``ADD `custom_threshold` TEXT NOT NULL`` now").unwrap();
6483 let content = doc.content[0].content.as_ref().unwrap();
6484 assert_eq!(content.len(), 3, "content: {content:?}");
6485 let code_node = &content[1];
6486 assert_eq!(
6487 code_node.text.as_deref(),
6488 Some("ADD `custom_threshold` TEXT NOT NULL")
6489 );
6490 let marks = code_node.marks.as_ref().unwrap();
6491 assert_eq!(marks[0].mark_type, "code");
6492 }
6493
6494 #[test]
6497 fn inline_code_with_backtick_roundtrip() {
6498 let json = r#"{
6499 "version": 1,
6500 "type": "doc",
6501 "content": [{
6502 "type": "paragraph",
6503 "content": [
6504 {"type": "text", "text": "Run "},
6505 {
6506 "type": "text",
6507 "text": "ADD `custom_threshold` TEXT NOT NULL",
6508 "marks": [{"type": "code"}]
6509 },
6510 {"type": "text", "text": " to update the schema."}
6511 ]
6512 }]
6513 }"#;
6514 let original: AdfDocument = serde_json::from_str(json).unwrap();
6515 let md = adf_to_markdown(&original).unwrap();
6516 let roundtripped = markdown_to_adf(&md).unwrap();
6517 let para = &roundtripped.content[0];
6518 let children = para.content.as_ref().unwrap();
6519 assert_eq!(children.len(), 3, "expected 3 children, got: {children:?}");
6520 assert_eq!(children[0].text.as_deref(), Some("Run "));
6521 assert_eq!(
6522 children[1].text.as_deref(),
6523 Some("ADD `custom_threshold` TEXT NOT NULL")
6524 );
6525 let marks = children[1].marks.as_ref().unwrap();
6526 assert_eq!(marks.len(), 1);
6527 assert_eq!(marks[0].mark_type, "code");
6528 assert_eq!(children[2].text.as_deref(), Some(" to update the schema."));
6529 }
6530
6531 #[test]
6536 fn inline_code_with_double_backtick_roundtrip() {
6537 let doc = AdfDocument {
6538 version: 1,
6539 doc_type: "doc".to_string(),
6540 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6541 "x `` y",
6542 vec![AdfMark::code()],
6543 )])],
6544 };
6545 let md = adf_to_markdown(&doc).unwrap();
6546 let roundtripped = markdown_to_adf(&md).unwrap();
6547 let content = roundtripped.content[0].content.as_ref().unwrap();
6548 assert_eq!(content.len(), 1);
6549 assert_eq!(content[0].text.as_deref(), Some("x `` y"));
6550 let marks = content[0].marks.as_ref().unwrap();
6551 assert_eq!(marks[0].mark_type, "code");
6552 }
6553
6554 #[test]
6557 fn inline_code_leading_backtick_roundtrip() {
6558 let doc = AdfDocument {
6559 version: 1,
6560 doc_type: "doc".to_string(),
6561 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6562 "`start",
6563 vec![AdfMark::code()],
6564 )])],
6565 };
6566 let md = adf_to_markdown(&doc).unwrap();
6567 let roundtripped = markdown_to_adf(&md).unwrap();
6568 let content = roundtripped.content[0].content.as_ref().unwrap();
6569 assert_eq!(content[0].text.as_deref(), Some("`start"));
6570 assert_eq!(content[0].marks.as_ref().unwrap()[0].mark_type, "code");
6571 }
6572
6573 #[test]
6575 fn inline_code_trailing_backtick_roundtrip() {
6576 let doc = AdfDocument {
6577 version: 1,
6578 doc_type: "doc".to_string(),
6579 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6580 "end`",
6581 vec![AdfMark::code()],
6582 )])],
6583 };
6584 let md = adf_to_markdown(&doc).unwrap();
6585 let roundtripped = markdown_to_adf(&md).unwrap();
6586 let content = roundtripped.content[0].content.as_ref().unwrap();
6587 assert_eq!(content[0].text.as_deref(), Some("end`"));
6588 }
6589
6590 #[test]
6593 fn inline_code_space_padded_content_roundtrip() {
6594 let doc = AdfDocument {
6595 version: 1,
6596 doc_type: "doc".to_string(),
6597 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6598 " foo ",
6599 vec![AdfMark::code()],
6600 )])],
6601 };
6602 let md = adf_to_markdown(&doc).unwrap();
6603 let roundtripped = markdown_to_adf(&md).unwrap();
6604 let content = roundtripped.content[0].content.as_ref().unwrap();
6605 assert_eq!(content[0].text.as_deref(), Some(" foo "));
6606 }
6607
6608 #[test]
6611 fn inline_code_all_spaces_roundtrip() {
6612 let doc = AdfDocument {
6613 version: 1,
6614 doc_type: "doc".to_string(),
6615 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6616 " ",
6617 vec![AdfMark::code()],
6618 )])],
6619 };
6620 let md = adf_to_markdown(&doc).unwrap();
6621 let roundtripped = markdown_to_adf(&md).unwrap();
6622 let content = roundtripped.content[0].content.as_ref().unwrap();
6623 assert_eq!(content[0].text.as_deref(), Some(" "));
6624 }
6625
6626 #[test]
6629 fn inline_code_with_link_and_backtick_roundtrip() {
6630 let doc = AdfDocument {
6631 version: 1,
6632 doc_type: "doc".to_string(),
6633 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6634 "fn `inner`",
6635 vec![AdfMark::code(), AdfMark::link("https://example.com")],
6636 )])],
6637 };
6638 let md = adf_to_markdown(&doc).unwrap();
6639 assert!(
6640 md.contains("`` fn `inner` ``"),
6641 "expected padded double-backtick delimiters inside link, got: {md}"
6642 );
6643 let roundtripped = markdown_to_adf(&md).unwrap();
6644 let content = roundtripped.content[0].content.as_ref().unwrap();
6645 assert_eq!(content[0].text.as_deref(), Some("fn `inner`"));
6646 let mark_types: Vec<&str> = content[0]
6647 .marks
6648 .as_ref()
6649 .unwrap()
6650 .iter()
6651 .map(|m| m.mark_type.as_str())
6652 .collect();
6653 assert!(mark_types.contains(&"code"));
6654 assert!(mark_types.contains(&"link"));
6655 }
6656
6657 #[test]
6659 fn inline_code_unmatched_run_is_plain_text() {
6660 let doc = markdown_to_adf("foo ``bar baz").unwrap();
6661 let content = doc.content[0].content.as_ref().unwrap();
6662 assert_eq!(content.len(), 1);
6663 assert_eq!(content[0].text.as_deref(), Some("foo ``bar baz"));
6664 assert!(content[0].marks.is_none());
6665 }
6666
6667 #[test]
6671 fn inline_code_mismatched_delimiters_is_plain_text() {
6672 let doc = markdown_to_adf("``foo` bar").unwrap();
6673 let content = doc.content[0].content.as_ref().unwrap();
6674 assert_eq!(content.len(), 1);
6675 assert_eq!(content[0].text.as_deref(), Some("``foo` bar"));
6676 assert!(content[0].marks.is_none());
6677 }
6678
6679 #[test]
6680 fn inline_code_delimiter_chooses_correct_length() {
6681 assert_eq!(inline_code_delimiter("no ticks"), (1, false));
6682 assert_eq!(inline_code_delimiter("one ` here"), (2, false));
6683 assert_eq!(inline_code_delimiter("two `` here"), (3, false));
6684 assert_eq!(inline_code_delimiter("three ``` here"), (4, false));
6685 assert_eq!(inline_code_delimiter("`leading"), (2, true));
6686 assert_eq!(inline_code_delimiter("trailing`"), (2, true));
6687 assert_eq!(inline_code_delimiter(" foo "), (1, true));
6688 assert_eq!(inline_code_delimiter(" "), (1, false));
6689 assert_eq!(inline_code_delimiter(" "), (1, false));
6690 assert_eq!(inline_code_delimiter(" foo"), (1, false));
6691 }
6692
6693 #[test]
6694 fn try_parse_inline_code_strips_paired_spaces() {
6695 let (end, content) = try_parse_inline_code("`` `foo` ``", 0).unwrap();
6696 assert_eq!(end, 11);
6697 assert_eq!(content, "`foo`");
6698 }
6699
6700 #[test]
6701 fn try_parse_inline_code_all_space_content_is_preserved() {
6702 let (_end, content) = try_parse_inline_code("` `", 0).unwrap();
6703 assert_eq!(content, " ");
6704 }
6705
6706 #[test]
6707 fn try_parse_inline_code_single_run_matches_first_close() {
6708 let (end, content) = try_parse_inline_code("`foo` tail", 0).unwrap();
6709 assert_eq!(end, 5);
6710 assert_eq!(content, "foo");
6711 }
6712
6713 #[test]
6714 fn try_parse_inline_code_no_match_returns_none() {
6715 assert!(try_parse_inline_code("``unmatched", 0).is_none());
6716 assert!(try_parse_inline_code("plain text", 0).is_none());
6717 }
6718
6719 #[test]
6720 fn is_code_fence_opener_rejects_info_with_backtick() {
6721 assert!(is_code_fence_opener("```"));
6722 assert!(is_code_fence_opener("```rust"));
6723 assert!(is_code_fence_opener("```\"\""));
6724 assert!(!is_code_fence_opener("```x `` y```"));
6725 assert!(!is_code_fence_opener("``not-enough"));
6726 assert!(!is_code_fence_opener("no fence"));
6727 }
6728
6729 #[test]
6730 fn inline_strikethrough() {
6731 let doc = markdown_to_adf("Some ~~deleted~~ text").unwrap();
6732 let content = doc.content[0].content.as_ref().unwrap();
6733 let strike_node = &content[1];
6734 assert_eq!(strike_node.text.as_deref(), Some("deleted"));
6735 let marks = strike_node.marks.as_ref().unwrap();
6736 assert_eq!(marks[0].mark_type, "strike");
6737 }
6738
6739 #[test]
6740 fn inline_link() {
6741 let doc = markdown_to_adf("Click [here](https://example.com) now").unwrap();
6742 let content = doc.content[0].content.as_ref().unwrap();
6743 let link_node = &content[1];
6744 assert_eq!(link_node.text.as_deref(), Some("here"));
6745 let marks = link_node.marks.as_ref().unwrap();
6746 assert_eq!(marks[0].mark_type, "link");
6747 }
6748
6749 #[test]
6750 fn block_image() {
6751 let md = "";
6752 let doc = markdown_to_adf(md).unwrap();
6753 assert_eq!(doc.content[0].node_type, "mediaSingle");
6754 }
6755
6756 #[test]
6757 fn table() {
6758 let md = "| A | B |\n| --- | --- |\n| 1 | 2 |";
6759 let doc = markdown_to_adf(md).unwrap();
6760 assert_eq!(doc.content[0].node_type, "table");
6761 let rows = doc.content[0].content.as_ref().unwrap();
6762 assert_eq!(rows.len(), 2); }
6764
6765 #[test]
6768 fn adf_paragraph_to_markdown() {
6769 let doc = AdfDocument {
6770 version: 1,
6771 doc_type: "doc".to_string(),
6772 content: vec![AdfNode::paragraph(vec![AdfNode::text("Hello world")])],
6773 };
6774 let md = adf_to_markdown(&doc).unwrap();
6775 assert_eq!(md.trim(), "Hello world");
6776 }
6777
6778 #[test]
6779 fn adf_heading_to_markdown() {
6780 let doc = AdfDocument {
6781 version: 1,
6782 doc_type: "doc".to_string(),
6783 content: vec![AdfNode::heading(2, vec![AdfNode::text("Title")])],
6784 };
6785 let md = adf_to_markdown(&doc).unwrap();
6786 assert_eq!(md.trim(), "## Title");
6787 }
6788
6789 #[test]
6790 fn adf_bold_to_markdown() {
6791 let doc = AdfDocument {
6792 version: 1,
6793 doc_type: "doc".to_string(),
6794 content: vec![AdfNode::paragraph(vec![
6795 AdfNode::text("Normal "),
6796 AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
6797 AdfNode::text(" text"),
6798 ])],
6799 };
6800 let md = adf_to_markdown(&doc).unwrap();
6801 assert_eq!(md.trim(), "Normal **bold** text");
6802 }
6803
6804 #[test]
6805 fn adf_code_block_to_markdown() {
6806 let doc = AdfDocument {
6807 version: 1,
6808 doc_type: "doc".to_string(),
6809 content: vec![AdfNode::code_block(Some("rust"), "let x = 1;")],
6810 };
6811 let md = adf_to_markdown(&doc).unwrap();
6812 assert!(md.contains("```rust"));
6813 assert!(md.contains("let x = 1;"));
6814 assert!(md.contains("```"));
6815 }
6816
6817 #[test]
6818 fn adf_rule_to_markdown() {
6819 let doc = AdfDocument {
6820 version: 1,
6821 doc_type: "doc".to_string(),
6822 content: vec![AdfNode::rule()],
6823 };
6824 let md = adf_to_markdown(&doc).unwrap();
6825 assert!(md.contains("---"));
6826 }
6827
6828 #[test]
6829 fn adf_bullet_list_to_markdown() {
6830 let doc = AdfDocument {
6831 version: 1,
6832 doc_type: "doc".to_string(),
6833 content: vec![AdfNode::bullet_list(vec![
6834 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("A")])]),
6835 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("B")])]),
6836 ])],
6837 };
6838 let md = adf_to_markdown(&doc).unwrap();
6839 assert!(md.contains("- A"));
6840 assert!(md.contains("- B"));
6841 }
6842
6843 #[test]
6844 fn adf_link_to_markdown() {
6845 let doc = AdfDocument {
6846 version: 1,
6847 doc_type: "doc".to_string(),
6848 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6849 "click",
6850 vec![AdfMark::link("https://example.com")],
6851 )])],
6852 };
6853 let md = adf_to_markdown(&doc).unwrap();
6854 assert_eq!(md.trim(), "[click](https://example.com)");
6855 }
6856
6857 #[test]
6858 fn unsupported_block_preserved_as_json() {
6859 let doc = AdfDocument {
6860 version: 1,
6861 doc_type: "doc".to_string(),
6862 content: vec![AdfNode {
6863 node_type: "unknownBlock".to_string(),
6864 attrs: Some(serde_json::json!({"key": "value"})),
6865 content: None,
6866 text: None,
6867 marks: None,
6868 local_id: None,
6869 parameters: None,
6870 }],
6871 };
6872 let md = adf_to_markdown(&doc).unwrap();
6873 assert!(md.contains("```adf-unsupported"));
6874 assert!(md.contains("\"unknownBlock\""));
6875 }
6876
6877 #[test]
6878 fn unsupported_block_round_trips() {
6879 let original = AdfDocument {
6880 version: 1,
6881 doc_type: "doc".to_string(),
6882 content: vec![AdfNode {
6883 node_type: "unknownBlock".to_string(),
6884 attrs: Some(serde_json::json!({"key": "value"})),
6885 content: None,
6886 text: None,
6887 marks: None,
6888 local_id: None,
6889 parameters: None,
6890 }],
6891 };
6892 let md = adf_to_markdown(&original).unwrap();
6893 let restored = markdown_to_adf(&md).unwrap();
6894 assert_eq!(restored.content[0].node_type, "unknownBlock");
6895 assert_eq!(restored.content[0].attrs.as_ref().unwrap()["key"], "value");
6896 }
6897
6898 #[test]
6901 fn round_trip_simple_document() {
6902 let md = "# Hello\n\nSome text with **bold** and *italic*.\n\n- Item 1\n- Item 2\n";
6903 let adf = markdown_to_adf(md).unwrap();
6904 let restored = adf_to_markdown(&adf).unwrap();
6905
6906 assert!(restored.contains("# Hello"));
6907 assert!(restored.contains("**bold**"));
6908 assert!(restored.contains("*italic*"));
6909 assert!(restored.contains("- Item 1"));
6910 assert!(restored.contains("- Item 2"));
6911 }
6912
6913 #[test]
6914 fn round_trip_code_block() {
6915 let md = "```python\nprint('hello')\n```\n";
6916 let adf = markdown_to_adf(md).unwrap();
6917 let restored = adf_to_markdown(&adf).unwrap();
6918
6919 assert!(restored.contains("```python"));
6920 assert!(restored.contains("print('hello')"));
6921 }
6922
6923 #[test]
6924 fn round_trip_code_block_no_attrs() {
6925 let adf_json = r#"{"version":1,"type":"doc","content":[
6926 {"type":"codeBlock","content":[{"type":"text","text":"plain code"}]}
6927 ]}"#;
6928 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6929 assert!(doc.content[0].attrs.is_none());
6930 let md = adf_to_markdown(&doc).unwrap();
6931 let round_tripped = markdown_to_adf(&md).unwrap();
6932 assert!(round_tripped.content[0].attrs.is_none());
6933 }
6934
6935 #[test]
6936 fn round_trip_code_block_empty_language() {
6937 let adf_json = r#"{"version":1,"type":"doc","content":[
6938 {"type":"codeBlock","attrs":{"language":""},"content":[{"type":"text","text":"simple code block no backtick"}]}
6939 ]}"#;
6940 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6941 let attrs = doc.content[0].attrs.as_ref().unwrap();
6942 assert_eq!(attrs["language"], "");
6943 let md = adf_to_markdown(&doc).unwrap();
6944 let round_tripped = markdown_to_adf(&md).unwrap();
6945 let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
6946 assert_eq!(rt_attrs["language"], "");
6947 }
6948
6949 #[test]
6950 fn round_trip_code_block_with_language() {
6951 let adf_json = r#"{"version":1,"type":"doc","content":[
6952 {"type":"codeBlock","attrs":{"language":"python"},"content":[{"type":"text","text":"print('hi')"}]}
6953 ]}"#;
6954 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6955 let md = adf_to_markdown(&doc).unwrap();
6956 let round_tripped = markdown_to_adf(&md).unwrap();
6957 let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
6958 assert_eq!(rt_attrs["language"], "python");
6959 }
6960
6961 #[test]
6962 fn multiple_paragraphs() {
6963 let md = "First paragraph.\n\nSecond paragraph.\n";
6964 let adf = markdown_to_adf(md).unwrap();
6965 assert_eq!(adf.content.len(), 2);
6966 assert_eq!(adf.content[0].node_type, "paragraph");
6967 assert_eq!(adf.content[1].node_type, "paragraph");
6968 }
6969
6970 #[test]
6973 fn horizontal_rule_underscores() {
6974 let doc = markdown_to_adf("___").unwrap();
6975 assert_eq!(doc.content[0].node_type, "rule");
6976 }
6977
6978 #[test]
6979 fn not_a_horizontal_rule_too_short() {
6980 let doc = markdown_to_adf("--").unwrap();
6981 assert_eq!(doc.content[0].node_type, "paragraph");
6982 }
6983
6984 #[test]
6985 fn bullet_list_star_marker() {
6986 let md = "* Apple\n* Banana";
6987 let doc = markdown_to_adf(md).unwrap();
6988 assert_eq!(doc.content[0].node_type, "bulletList");
6989 let items = doc.content[0].content.as_ref().unwrap();
6990 assert_eq!(items.len(), 2);
6991 }
6992
6993 #[test]
6994 fn bullet_list_plus_marker() {
6995 let md = "+ One\n+ Two";
6996 let doc = markdown_to_adf(md).unwrap();
6997 assert_eq!(doc.content[0].node_type, "bulletList");
6998 }
6999
7000 #[test]
7001 fn ordered_list_non_one_start() {
7002 let md = "5. Fifth\n6. Sixth";
7003 let doc = markdown_to_adf(md).unwrap();
7004 let node = &doc.content[0];
7005 assert_eq!(node.node_type, "orderedList");
7006 let attrs = node.attrs.as_ref().unwrap();
7007 assert_eq!(attrs["order"], 5);
7008 }
7009
7010 #[test]
7011 fn ordered_list_start_at_one_omits_order_attr() {
7012 let md = "1. First\n2. Second";
7016 let doc = markdown_to_adf(md).unwrap();
7017 let node = &doc.content[0];
7018 assert_eq!(node.node_type, "orderedList");
7019 assert!(
7020 node.attrs.is_none(),
7021 "attrs should be omitted when order=1, got: {:?}",
7022 node.attrs
7023 );
7024 }
7025
7026 #[test]
7027 fn blockquote_bare_marker() {
7028 let md = ">quoted text";
7030 let doc = markdown_to_adf(md).unwrap();
7031 assert_eq!(doc.content[0].node_type, "blockquote");
7032 }
7033
7034 #[test]
7035 fn image_no_alt() {
7036 let md = "";
7037 let doc = markdown_to_adf(md).unwrap();
7038 let node = &doc.content[0];
7039 assert_eq!(node.node_type, "mediaSingle");
7040 let media = &node.content.as_ref().unwrap()[0];
7042 let attrs = media.attrs.as_ref().unwrap();
7043 assert!(attrs.get("alt").is_none());
7044 }
7045
7046 #[test]
7047 fn image_with_alt() {
7048 let md = "";
7049 let doc = markdown_to_adf(md).unwrap();
7050 let media = &doc.content[0].content.as_ref().unwrap()[0];
7051 let attrs = media.attrs.as_ref().unwrap();
7052 assert_eq!(attrs["alt"], "A photo");
7053 }
7054
7055 #[test]
7056 fn table_multi_body_rows() {
7057 let md = "| H1 | H2 |\n| --- | --- |\n| a | b |\n| c | d |";
7058 let doc = markdown_to_adf(md).unwrap();
7059 let rows = doc.content[0].content.as_ref().unwrap();
7060 assert_eq!(rows.len(), 3); let header_cells = rows[0].content.as_ref().unwrap();
7063 assert_eq!(header_cells[0].node_type, "tableHeader");
7064 let body_cells = rows[1].content.as_ref().unwrap();
7066 assert_eq!(body_cells[0].node_type, "tableCell");
7067 }
7068
7069 #[test]
7070 fn table_no_separator_is_not_table() {
7071 let md = "| not | a table |";
7073 let doc = markdown_to_adf(md).unwrap();
7074 assert_eq!(doc.content[0].node_type, "paragraph");
7075 }
7076
7077 #[test]
7078 fn inline_underscore_bold() {
7079 let doc = markdown_to_adf("Some __bold__ text").unwrap();
7080 let content = doc.content[0].content.as_ref().unwrap();
7081 let bold_node = &content[1];
7082 assert_eq!(bold_node.text.as_deref(), Some("bold"));
7083 let marks = bold_node.marks.as_ref().unwrap();
7084 assert_eq!(marks[0].mark_type, "strong");
7085 }
7086
7087 #[test]
7088 fn inline_underscore_italic() {
7089 let doc = markdown_to_adf("Some _italic_ text").unwrap();
7090 let content = doc.content[0].content.as_ref().unwrap();
7091 let italic_node = &content[1];
7092 assert_eq!(italic_node.text.as_deref(), Some("italic"));
7093 let marks = italic_node.marks.as_ref().unwrap();
7094 assert_eq!(marks[0].mark_type, "em");
7095 }
7096
7097 #[test]
7098 fn intraword_underscore_not_emphasis() {
7099 let doc = markdown_to_adf("call do_something_useful now").unwrap();
7101 let content = doc.content[0].content.as_ref().unwrap();
7102 assert_eq!(content.len(), 1, "should be a single text node");
7103 assert_eq!(
7104 content[0].text.as_deref(),
7105 Some("call do_something_useful now")
7106 );
7107 assert!(content[0].marks.is_none());
7108 }
7109
7110 #[test]
7111 fn intraword_underscore_multiple() {
7112 let doc = markdown_to_adf("use a_b_c_d here").unwrap();
7114 let content = doc.content[0].content.as_ref().unwrap();
7115 assert_eq!(content.len(), 1);
7116 assert_eq!(content[0].text.as_deref(), Some("use a_b_c_d here"));
7117 assert!(content[0].marks.is_none());
7118 }
7119
7120 #[test]
7121 fn intraword_double_underscore_not_bold() {
7122 let doc = markdown_to_adf("foo__bar__baz").unwrap();
7124 let content = doc.content[0].content.as_ref().unwrap();
7125 assert_eq!(content.len(), 1);
7126 assert_eq!(content[0].text.as_deref(), Some("foo__bar__baz"));
7127 assert!(content[0].marks.is_none());
7128 }
7129
7130 #[test]
7131 fn intraword_triple_underscore_not_bold_italic() {
7132 let doc = markdown_to_adf("x___y___z").unwrap();
7134 let content = doc.content[0].content.as_ref().unwrap();
7135 assert_eq!(content.len(), 1);
7136 assert_eq!(content[0].text.as_deref(), Some("x___y___z"));
7137 assert!(content[0].marks.is_none());
7138 }
7139
7140 #[test]
7141 fn underscore_emphasis_still_works_with_spaces() {
7142 let doc = markdown_to_adf("some _italic_ here").unwrap();
7144 let content = doc.content[0].content.as_ref().unwrap();
7145 assert_eq!(content.len(), 3);
7146 assert_eq!(content[1].text.as_deref(), Some("italic"));
7147 let marks = content[1].marks.as_ref().unwrap();
7148 assert_eq!(marks[0].mark_type, "em");
7149 }
7150
7151 #[test]
7152 fn underscore_bold_still_works_with_spaces() {
7153 let doc = markdown_to_adf("some __bold__ here").unwrap();
7155 let content = doc.content[0].content.as_ref().unwrap();
7156 assert_eq!(content.len(), 3);
7157 assert_eq!(content[1].text.as_deref(), Some("bold"));
7158 let marks = content[1].marks.as_ref().unwrap();
7159 assert_eq!(marks[0].mark_type, "strong");
7160 }
7161
7162 #[test]
7163 fn intraword_underscore_closing_only() {
7164 let doc = markdown_to_adf("_foo_bar").unwrap();
7166 let content = doc.content[0].content.as_ref().unwrap();
7167 assert_eq!(content.len(), 1);
7168 assert_eq!(content[0].text.as_deref(), Some("_foo_bar"));
7169 }
7170
7171 #[test]
7172 fn intraword_double_underscore_closing_only() {
7173 let doc = markdown_to_adf("__foo__bar").unwrap();
7175 let content = doc.content[0].content.as_ref().unwrap();
7176 assert_eq!(content.len(), 1);
7177 assert_eq!(content[0].text.as_deref(), Some("__foo__bar"));
7178 }
7179
7180 #[test]
7181 fn intraword_triple_underscore_closing_only() {
7182 let doc = markdown_to_adf("___foo___bar").unwrap();
7184 let content = doc.content[0].content.as_ref().unwrap();
7185 assert_eq!(content.len(), 1);
7186 assert_eq!(content[0].text.as_deref(), Some("___foo___bar"));
7187 }
7188
7189 #[test]
7190 fn asterisk_emphasis_unaffected_by_intraword_fix() {
7191 let doc = markdown_to_adf("foo*bar*baz").unwrap();
7193 let content = doc.content[0].content.as_ref().unwrap();
7194 assert!(content.len() > 1 || content[0].marks.is_some());
7196 }
7197
7198 #[test]
7199 fn intraword_underscore_at_start_of_text() {
7200 let doc = markdown_to_adf("_italic_ word").unwrap();
7202 let content = doc.content[0].content.as_ref().unwrap();
7203 assert_eq!(content[0].text.as_deref(), Some("italic"));
7204 let marks = content[0].marks.as_ref().unwrap();
7205 assert_eq!(marks[0].mark_type, "em");
7206 }
7207
7208 #[test]
7209 fn intraword_underscore_at_end_of_text() {
7210 let doc = markdown_to_adf("word _italic_").unwrap();
7212 let content = doc.content[0].content.as_ref().unwrap();
7213 let last = content.last().unwrap();
7214 assert_eq!(last.text.as_deref(), Some("italic"));
7215 let marks = last.marks.as_ref().unwrap();
7216 assert_eq!(marks[0].mark_type, "em");
7217 }
7218
7219 #[test]
7220 fn intraword_underscore_opening_only() {
7221 let doc = markdown_to_adf("a_b c_d").unwrap();
7224 let content = doc.content[0].content.as_ref().unwrap();
7225 assert_eq!(content.len(), 1);
7226 assert_eq!(content[0].text.as_deref(), Some("a_b c_d"));
7227 }
7228
7229 #[test]
7230 fn intraword_underscore_roundtrip() {
7231 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call the do_something_useful function"}]}]}"#;
7233 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7234 let jfm = adf_to_markdown(&adf).unwrap();
7235 let roundtripped = markdown_to_adf(&jfm).unwrap();
7236 let content = roundtripped.content[0].content.as_ref().unwrap();
7237 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7238 assert_eq!(
7239 content[0].text.as_deref(),
7240 Some("call the do_something_useful function")
7241 );
7242 assert!(content[0].marks.is_none());
7243 }
7244
7245 #[test]
7246 fn asterisk_emphasis_roundtrip() {
7247 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status: *confirmed* and active"}]}]}"#;
7249 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7250 let jfm = adf_to_markdown(&adf).unwrap();
7251 let roundtripped = markdown_to_adf(&jfm).unwrap();
7252 let content = roundtripped.content[0].content.as_ref().unwrap();
7253 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7254 assert_eq!(
7255 content[0].text.as_deref(),
7256 Some("Status: *confirmed* and active")
7257 );
7258 assert!(content[0].marks.is_none());
7259 }
7260
7261 #[test]
7262 fn double_asterisk_roundtrip() {
7263 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use **kwargs in Python"}]}]}"#;
7265 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7266 let jfm = adf_to_markdown(&adf).unwrap();
7267 let roundtripped = markdown_to_adf(&jfm).unwrap();
7268 let content = roundtripped.content[0].content.as_ref().unwrap();
7269 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7270 assert_eq!(content[0].text.as_deref(), Some("Use **kwargs in Python"));
7271 assert!(content[0].marks.is_none());
7272 }
7273
7274 #[test]
7275 fn asterisk_with_em_mark_roundtrip() {
7276 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a*b","marks":[{"type":"em"}]}]}]}"#;
7278 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7279 let jfm = adf_to_markdown(&adf).unwrap();
7280 let roundtripped = markdown_to_adf(&jfm).unwrap();
7281 let content = roundtripped.content[0].content.as_ref().unwrap();
7282 let em_node = content.iter().find(|n| {
7284 n.marks
7285 .as_ref()
7286 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7287 });
7288 assert!(em_node.is_some(), "should have an em-marked node");
7289 assert_eq!(em_node.unwrap().text.as_deref(), Some("a*b"));
7290 }
7291
7292 #[test]
7293 fn lone_asterisk_roundtrip() {
7294 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"rating: 5 * stars"}]}]}"#;
7296 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7297 let jfm = adf_to_markdown(&adf).unwrap();
7298 let roundtripped = markdown_to_adf(&jfm).unwrap();
7299 let content = roundtripped.content[0].content.as_ref().unwrap();
7300 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7301 assert_eq!(content[0].text.as_deref(), Some("rating: 5 * stars"));
7302 }
7303
7304 #[test]
7305 fn escape_emphasis_markers_unit() {
7306 assert_eq!(escape_emphasis_markers("hello"), "hello");
7307 assert_eq!(escape_emphasis_markers("*bold*"), r"\*bold\*");
7308 assert_eq!(escape_emphasis_markers("**strong**"), r"\*\*strong\*\*");
7309 assert_eq!(escape_emphasis_markers("no stars"), "no stars");
7310 assert_eq!(escape_emphasis_markers("a * b"), r"a \* b");
7311 assert_eq!(escape_emphasis_markers(""), "");
7312 }
7313
7314 #[test]
7315 fn escape_emphasis_markers_underscore_intraword() {
7316 assert_eq!(escape_emphasis_markers("foo_bar"), "foo_bar");
7319 assert_eq!(escape_emphasis_markers("a_b_c"), "a_b_c");
7320 assert_eq!(escape_emphasis_markers("foo__bar"), "foo__bar");
7321 assert_eq!(
7322 escape_emphasis_markers("call do_something_useful"),
7323 "call do_something_useful"
7324 );
7325 }
7326
7327 #[test]
7328 fn escape_emphasis_markers_underscore_at_boundary() {
7329 assert_eq!(escape_emphasis_markers("_Action"), r"\_Action");
7332 assert_eq!(escape_emphasis_markers("Action_"), r"Action\_");
7333 assert_eq!(escape_emphasis_markers("_ "), r"\_ ");
7334 assert_eq!(escape_emphasis_markers(" _"), r" \_");
7335 assert_eq!(escape_emphasis_markers("_"), r"\_");
7336 }
7337
7338 #[test]
7339 fn escape_emphasis_markers_underscore_with_punctuation() {
7340 assert_eq!(escape_emphasis_markers("foo _bar"), r"foo \_bar");
7342 assert_eq!(escape_emphasis_markers("foo_ bar"), r"foo\_ bar");
7343 assert_eq!(escape_emphasis_markers("(_x_)"), r"(\_x\_)");
7344 }
7345
7346 #[test]
7347 fn find_unescaped_skips_backslash_escaped() {
7348 assert_eq!(find_unescaped(r"a\*\*b**", "**"), Some(6));
7350 assert_eq!(find_unescaped(r"a\*\*b", "**"), None);
7352 assert_eq!(find_unescaped("a**b", "**"), Some(1));
7354 assert_eq!(find_unescaped("", "**"), None);
7356 }
7357
7358 #[test]
7359 fn find_unescaped_char_skips_backslash_escaped() {
7360 assert_eq!(find_unescaped_char(r"a\*b*", b'*'), Some(4));
7362 assert_eq!(find_unescaped_char(r"\*", b'*'), None);
7364 assert_eq!(find_unescaped_char("a*b", b'*'), Some(1));
7366 assert_eq!(find_unescaped_char("", b'*'), None);
7368 }
7369
7370 #[test]
7371 fn double_asterisk_in_strong_mark_roundtrip() {
7372 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call **kwargs","marks":[{"type":"strong"}]}]}]}"#;
7374 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7375 let jfm = adf_to_markdown(&adf).unwrap();
7376 let roundtripped = markdown_to_adf(&jfm).unwrap();
7377 let content = roundtripped.content[0].content.as_ref().unwrap();
7378 let strong_node = content.iter().find(|n| {
7379 n.marks
7380 .as_ref()
7381 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7382 });
7383 assert!(strong_node.is_some(), "should have a strong-marked node");
7384 assert_eq!(strong_node.unwrap().text.as_deref(), Some("call **kwargs"));
7385 }
7386
7387 #[test]
7388 fn backtick_code_roundtrip() {
7389 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Set `max_retries` to 3 in the config"}]}]}"#;
7391 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7392 let jfm = adf_to_markdown(&adf).unwrap();
7393 let roundtripped = markdown_to_adf(&jfm).unwrap();
7394 let content = roundtripped.content[0].content.as_ref().unwrap();
7395 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7396 assert_eq!(
7397 content[0].text.as_deref(),
7398 Some("Set `max_retries` to 3 in the config")
7399 );
7400 assert!(content[0].marks.is_none());
7401 }
7402
7403 #[test]
7404 fn multiple_backtick_spans_roundtrip() {
7405 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use `foo` and `bar` together"}]}]}"#;
7407 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7408 let jfm = adf_to_markdown(&adf).unwrap();
7409 let roundtripped = markdown_to_adf(&jfm).unwrap();
7410 let content = roundtripped.content[0].content.as_ref().unwrap();
7411 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7412 assert_eq!(
7413 content[0].text.as_deref(),
7414 Some("Use `foo` and `bar` together")
7415 );
7416 assert!(content[0].marks.is_none());
7417 }
7418
7419 #[test]
7420 fn lone_backtick_roundtrip() {
7421 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use a ` character"}]}]}"#;
7423 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7424 let jfm = adf_to_markdown(&adf).unwrap();
7425 let roundtripped = markdown_to_adf(&jfm).unwrap();
7426 let content = roundtripped.content[0].content.as_ref().unwrap();
7427 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7428 assert_eq!(content[0].text.as_deref(), Some("Use a ` character"));
7429 assert!(content[0].marks.is_none());
7430 }
7431
7432 #[test]
7433 fn backtick_with_code_mark_roundtrip() {
7434 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"max_retries","marks":[{"type":"code"}]}]}]}"#;
7436 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7437 let jfm = adf_to_markdown(&adf).unwrap();
7438 assert_eq!(jfm.trim(), "`max_retries`");
7439 let roundtripped = markdown_to_adf(&jfm).unwrap();
7440 let content = roundtripped.content[0].content.as_ref().unwrap();
7441 let code_node = content.iter().find(|n| {
7442 n.marks
7443 .as_ref()
7444 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7445 });
7446 assert!(code_node.is_some(), "should have a code-marked node");
7447 assert_eq!(code_node.unwrap().text.as_deref(), Some("max_retries"));
7448 }
7449
7450 #[test]
7451 fn backtick_with_em_mark_roundtrip() {
7452 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"use `cfg`","marks":[{"type":"em"}]}]}]}"#;
7454 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7455 let jfm = adf_to_markdown(&adf).unwrap();
7456 let roundtripped = markdown_to_adf(&jfm).unwrap();
7457 let content = roundtripped.content[0].content.as_ref().unwrap();
7458 let em_node = content.iter().find(|n| {
7459 n.marks
7460 .as_ref()
7461 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7462 });
7463 assert!(em_node.is_some(), "should have an em-marked node");
7464 assert_eq!(em_node.unwrap().text.as_deref(), Some("use `cfg`"));
7465 }
7466
7467 #[test]
7468 fn escape_pipes_in_cell_unit() {
7469 assert_eq!(escape_pipes_in_cell("hello"), "hello");
7470 assert_eq!(escape_pipes_in_cell("a|b"), r"a\|b");
7471 assert_eq!(escape_pipes_in_cell("|"), r"\|");
7472 assert_eq!(escape_pipes_in_cell("|a|b|"), r"\|a\|b\|");
7473 assert_eq!(escape_pipes_in_cell(""), "");
7474 assert_eq!(
7475 escape_pipes_in_cell("`parser.decode[T|json]`"),
7476 r"`parser.decode[T\|json]`"
7477 );
7478 }
7479
7480 #[test]
7481 fn escape_backticks_unit() {
7482 assert_eq!(escape_backticks("hello"), "hello");
7483 assert_eq!(escape_backticks("`code`"), r"\`code\`");
7484 assert_eq!(escape_backticks("no ticks"), "no ticks");
7485 assert_eq!(escape_backticks("a ` b"), r"a \` b");
7486 assert_eq!(escape_backticks(""), "");
7487 assert_eq!(escape_backticks("`a` and `b`"), r"\`a\` and \`b\`");
7488 }
7489
7490 #[test]
7493 fn backslash_in_text_roundtrip() {
7494 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"The path is C:\\Users\\admin\\file.txt"}]}]}"#;
7496 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7497 let jfm = adf_to_markdown(&adf).unwrap();
7498 let roundtripped = markdown_to_adf(&jfm).unwrap();
7499 let content = roundtripped.content[0].content.as_ref().unwrap();
7500 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7501 assert_eq!(
7502 content[0].text.as_deref(),
7503 Some(r"The path is C:\Users\admin\file.txt")
7504 );
7505 }
7506
7507 #[test]
7508 fn backslash_emitted_as_double_backslash() {
7509 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\b"}]}]}"#;
7510 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7511 let jfm = adf_to_markdown(&adf).unwrap();
7512 assert!(
7513 jfm.contains(r"a\\b"),
7514 "JFM should contain escaped backslash: {jfm}"
7515 );
7516 }
7517
7518 #[test]
7519 fn consecutive_backslashes_roundtrip() {
7520 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\\\b"}]}]}"#;
7521 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7522 let jfm = adf_to_markdown(&adf).unwrap();
7523 let roundtripped = markdown_to_adf(&jfm).unwrap();
7524 let content = roundtripped.content[0].content.as_ref().unwrap();
7525 assert_eq!(
7526 content[0].text.as_deref(),
7527 Some(r"a\\b"),
7528 "consecutive backslashes should survive round-trip"
7529 );
7530 }
7531
7532 #[test]
7533 fn backslash_with_strong_mark_roundtrip() {
7534 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"strong"}]}]}]}"#;
7535 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7536 let jfm = adf_to_markdown(&adf).unwrap();
7537 let roundtripped = markdown_to_adf(&jfm).unwrap();
7538 let content = roundtripped.content[0].content.as_ref().unwrap();
7539 let strong_node = content.iter().find(|n| {
7540 n.marks
7541 .as_ref()
7542 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7543 });
7544 assert!(strong_node.is_some(), "should have a strong-marked node");
7545 assert_eq!(strong_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7546 }
7547
7548 #[test]
7549 fn backslash_with_code_mark_not_escaped() {
7550 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"code"}]}]}]}"#;
7552 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7553 let jfm = adf_to_markdown(&adf).unwrap();
7554 assert_eq!(jfm.trim(), r"`C:\Users`");
7555 let roundtripped = markdown_to_adf(&jfm).unwrap();
7556 let content = roundtripped.content[0].content.as_ref().unwrap();
7557 let code_node = content.iter().find(|n| {
7558 n.marks
7559 .as_ref()
7560 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7561 });
7562 assert!(code_node.is_some(), "should have a code-marked node");
7563 assert_eq!(code_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7564 }
7565
7566 #[test]
7567 fn backslash_before_special_chars_roundtrip() {
7568 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"\\*not bold\\*"}]}]}"#;
7570 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7571 let jfm = adf_to_markdown(&adf).unwrap();
7572 let roundtripped = markdown_to_adf(&jfm).unwrap();
7573 let content = roundtripped.content[0].content.as_ref().unwrap();
7574 assert_eq!(
7575 content[0].text.as_deref(),
7576 Some(r"\*not bold\*"),
7577 "backslash before special char should survive round-trip"
7578 );
7579 }
7580
7581 #[test]
7582 fn backslash_and_newline_in_text_roundtrip() {
7583 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\path\nline2"}]}]}"#;
7585 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7586 let jfm = adf_to_markdown(&adf).unwrap();
7587 let roundtripped = markdown_to_adf(&jfm).unwrap();
7588 let content = roundtripped.content[0].content.as_ref().unwrap();
7589 assert_eq!(
7590 content[0].text.as_deref(),
7591 Some("C:\\path\nline2"),
7592 "backslash and newline should both survive round-trip"
7593 );
7594 }
7595
7596 #[test]
7597 fn lone_backslash_roundtrip() {
7598 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a \\ b"}]}]}"#;
7599 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7600 let jfm = adf_to_markdown(&adf).unwrap();
7601 let roundtripped = markdown_to_adf(&jfm).unwrap();
7602 let content = roundtripped.content[0].content.as_ref().unwrap();
7603 assert_eq!(content[0].text.as_deref(), Some(r"a \ b"));
7604 }
7605
7606 #[test]
7607 fn trailing_backslash_in_text_roundtrip() {
7608 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\"}]}]}"#;
7610 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7611 let jfm = adf_to_markdown(&adf).unwrap();
7612 let roundtripped = markdown_to_adf(&jfm).unwrap();
7613 let content = roundtripped.content[0].content.as_ref().unwrap();
7614 assert_eq!(
7615 content[0].text.as_deref(),
7616 Some(r"end\"),
7617 "trailing backslash should survive round-trip"
7618 );
7619 }
7620
7621 #[test]
7622 fn escape_bare_urls_unit() {
7623 assert_eq!(escape_bare_urls("hello"), "hello");
7624 assert_eq!(escape_bare_urls(""), "");
7625 assert_eq!(
7626 escape_bare_urls("https://example.com"),
7627 r"\https://example.com"
7628 );
7629 assert_eq!(
7630 escape_bare_urls("http://example.com"),
7631 r"\http://example.com"
7632 );
7633 assert_eq!(
7634 escape_bare_urls("see https://a.com and https://b.com"),
7635 r"see \https://a.com and \https://b.com"
7636 );
7637 assert_eq!(escape_bare_urls("http header"), "http header");
7639 assert_eq!(escape_bare_urls("https is secure"), "https is secure");
7640 }
7641
7642 #[test]
7643 fn heading_not_valid_without_space() {
7644 let doc = markdown_to_adf("#Title").unwrap();
7646 assert_eq!(doc.content[0].node_type, "paragraph");
7647 }
7648
7649 #[test]
7650 fn heading_level_too_high() {
7651 let doc = markdown_to_adf("####### Not a heading").unwrap();
7653 assert_eq!(doc.content[0].node_type, "paragraph");
7654 }
7655
7656 #[test]
7657 fn empty_document() {
7658 let doc = markdown_to_adf("").unwrap();
7659 assert!(doc.content.is_empty());
7660 }
7661
7662 #[test]
7663 fn only_blank_lines() {
7664 let doc = markdown_to_adf("\n\n\n").unwrap();
7665 assert!(doc.content.is_empty());
7666 }
7667
7668 #[test]
7669 fn code_block_unterminated() {
7670 let md = "```rust\nfn main() {}";
7672 let doc = markdown_to_adf(md).unwrap();
7673 assert_eq!(doc.content[0].node_type, "codeBlock");
7674 }
7675
7676 #[test]
7677 fn mixed_document() {
7678 let md = "# Title\n\nA paragraph.\n\n- Item\n\n```\ncode\n```\n\n> quote\n\n---\n\n1. numbered\n";
7679 let doc = markdown_to_adf(md).unwrap();
7680 let types: Vec<&str> = doc.content.iter().map(|n| n.node_type.as_str()).collect();
7681 assert_eq!(
7682 types,
7683 vec![
7684 "heading",
7685 "paragraph",
7686 "bulletList",
7687 "codeBlock",
7688 "blockquote",
7689 "rule",
7690 "orderedList",
7691 ]
7692 );
7693 }
7694
7695 #[test]
7698 fn adf_ordered_list_to_markdown() {
7699 let doc = AdfDocument {
7700 version: 1,
7701 doc_type: "doc".to_string(),
7702 content: vec![AdfNode::ordered_list(
7703 vec![
7704 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("First")])]),
7705 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("Second")])]),
7706 ],
7707 None,
7708 )],
7709 };
7710 let md = adf_to_markdown(&doc).unwrap();
7711 assert!(md.contains("1. First"));
7712 assert!(md.contains("2. Second"));
7713 }
7714
7715 #[test]
7716 fn adf_ordered_list_custom_start() {
7717 let doc = AdfDocument {
7718 version: 1,
7719 doc_type: "doc".to_string(),
7720 content: vec![AdfNode::ordered_list(
7721 vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
7722 AdfNode::text("Third"),
7723 ])])],
7724 Some(3),
7725 )],
7726 };
7727 let md = adf_to_markdown(&doc).unwrap();
7728 assert!(md.contains("3. Third"));
7729 }
7730
7731 #[test]
7732 fn adf_blockquote_to_markdown() {
7733 let doc = AdfDocument {
7734 version: 1,
7735 doc_type: "doc".to_string(),
7736 content: vec![AdfNode::blockquote(vec![AdfNode::paragraph(vec![
7737 AdfNode::text("A quote"),
7738 ])])],
7739 };
7740 let md = adf_to_markdown(&doc).unwrap();
7741 assert!(md.contains("> A quote"));
7742 }
7743
7744 #[test]
7745 fn adf_table_to_markdown() {
7746 let doc = AdfDocument {
7747 version: 1,
7748 doc_type: "doc".to_string(),
7749 content: vec![AdfNode::table(vec![
7750 AdfNode::table_row(vec![
7751 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Name")])]),
7752 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Value")])]),
7753 ]),
7754 AdfNode::table_row(vec![
7755 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a")])]),
7756 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("1")])]),
7757 ]),
7758 ])],
7759 };
7760 let md = adf_to_markdown(&doc).unwrap();
7761 assert!(md.contains("| Name | Value |"));
7762 assert!(md.contains("| --- | --- |"));
7763 assert!(md.contains("| a | 1 |"));
7764 }
7765
7766 #[test]
7767 fn adf_media_to_markdown() {
7768 let doc = AdfDocument {
7769 version: 1,
7770 doc_type: "doc".to_string(),
7771 content: vec![AdfNode::media_single(
7772 "https://example.com/img.png",
7773 Some("Alt"),
7774 )],
7775 };
7776 let md = adf_to_markdown(&doc).unwrap();
7777 assert!(md.contains(""));
7778 }
7779
7780 #[test]
7781 fn adf_media_no_alt_to_markdown() {
7782 let doc = AdfDocument {
7783 version: 1,
7784 doc_type: "doc".to_string(),
7785 content: vec![AdfNode::media_single("https://example.com/img.png", None)],
7786 };
7787 let md = adf_to_markdown(&doc).unwrap();
7788 assert!(md.contains(""));
7789 }
7790
7791 #[test]
7792 fn adf_italic_to_markdown() {
7793 let doc = AdfDocument {
7794 version: 1,
7795 doc_type: "doc".to_string(),
7796 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7797 "emphasis",
7798 vec![AdfMark::em()],
7799 )])],
7800 };
7801 let md = adf_to_markdown(&doc).unwrap();
7802 assert_eq!(md.trim(), "*emphasis*");
7803 }
7804
7805 #[test]
7806 fn adf_strikethrough_to_markdown() {
7807 let doc = AdfDocument {
7808 version: 1,
7809 doc_type: "doc".to_string(),
7810 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7811 "deleted",
7812 vec![AdfMark::strike()],
7813 )])],
7814 };
7815 let md = adf_to_markdown(&doc).unwrap();
7816 assert_eq!(md.trim(), "~~deleted~~");
7817 }
7818
7819 #[test]
7820 fn adf_inline_code_to_markdown() {
7821 let doc = AdfDocument {
7822 version: 1,
7823 doc_type: "doc".to_string(),
7824 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7825 "code",
7826 vec![AdfMark::code()],
7827 )])],
7828 };
7829 let md = adf_to_markdown(&doc).unwrap();
7830 assert_eq!(md.trim(), "`code`");
7831 }
7832
7833 #[test]
7834 fn adf_code_with_link_to_markdown() {
7835 let doc = AdfDocument {
7836 version: 1,
7837 doc_type: "doc".to_string(),
7838 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7839 "func",
7840 vec![AdfMark::code(), AdfMark::link("https://example.com")],
7841 )])],
7842 };
7843 let md = adf_to_markdown(&doc).unwrap();
7844 assert_eq!(md.trim(), "[`func`](https://example.com)");
7845 }
7846
7847 #[test]
7848 fn adf_bold_italic_to_markdown() {
7849 let doc = AdfDocument {
7850 version: 1,
7851 doc_type: "doc".to_string(),
7852 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7853 "both",
7854 vec![AdfMark::strong(), AdfMark::em()],
7855 )])],
7856 };
7857 let md = adf_to_markdown(&doc).unwrap();
7858 assert_eq!(md.trim(), "***both***");
7859 }
7860
7861 #[test]
7862 fn adf_bold_link_to_markdown() {
7863 let doc = AdfDocument {
7864 version: 1,
7865 doc_type: "doc".to_string(),
7866 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7867 "bold link",
7868 vec![AdfMark::strong(), AdfMark::link("https://example.com")],
7869 )])],
7870 };
7871 let md = adf_to_markdown(&doc).unwrap();
7872 assert_eq!(md.trim(), "**[bold link](https://example.com)**");
7873 }
7874
7875 #[test]
7876 fn adf_strikethrough_bold_to_markdown() {
7877 let doc = AdfDocument {
7878 version: 1,
7879 doc_type: "doc".to_string(),
7880 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7881 "struck",
7882 vec![AdfMark::strike(), AdfMark::strong()],
7883 )])],
7884 };
7885 let md = adf_to_markdown(&doc).unwrap();
7886 assert_eq!(md.trim(), "~~**struck**~~");
7887 }
7888
7889 #[test]
7890 fn adf_hard_break_to_markdown() {
7891 let doc = AdfDocument {
7892 version: 1,
7893 doc_type: "doc".to_string(),
7894 content: vec![AdfNode::paragraph(vec![
7895 AdfNode::text("Line 1"),
7896 AdfNode::hard_break(),
7897 AdfNode::text("Line 2"),
7898 ])],
7899 };
7900 let md = adf_to_markdown(&doc).unwrap();
7901 assert!(md.contains("Line 1\\\n Line 2"));
7902 }
7903
7904 #[test]
7905 #[test]
7906 fn adf_unsupported_inline_to_markdown() {
7907 let doc = AdfDocument {
7908 version: 1,
7909 doc_type: "doc".to_string(),
7910 content: vec![AdfNode::paragraph(vec![AdfNode {
7911 node_type: "unknownInline".to_string(),
7912 attrs: None,
7913 content: None,
7914 text: None,
7915 marks: None,
7916 local_id: None,
7917 parameters: None,
7918 }])],
7919 };
7920 let md = adf_to_markdown(&doc).unwrap();
7921 assert!(md.contains("<!-- unsupported inline: unknownInline -->"));
7922 }
7923
7924 #[test]
7927 fn adf_media_inline_to_markdown() {
7928 let doc = AdfDocument {
7929 version: 1,
7930 doc_type: "doc".to_string(),
7931 content: vec![AdfNode::paragraph(vec![
7932 AdfNode::text("see "),
7933 AdfNode::media_inline(serde_json::json!({
7934 "type": "image",
7935 "id": "abcdef01-2345-6789-abcd-abcdef012345",
7936 "collection": "contentId-111111",
7937 "width": 200,
7938 "height": 100
7939 })),
7940 AdfNode::text(" for details"),
7941 ])],
7942 };
7943 let md = adf_to_markdown(&doc).unwrap();
7944 assert!(md.contains(":media-inline[]{"), "got: {md}");
7945 assert!(md.contains("type=image"));
7946 assert!(md.contains("id=abcdef01-2345-6789-abcd-abcdef012345"));
7947 assert!(md.contains("collection=contentId-111111"));
7948 assert!(md.contains("width=200"));
7949 assert!(md.contains("height=100"));
7950 assert!(!md.contains("<!-- unsupported inline"));
7951 }
7952
7953 #[test]
7954 fn media_inline_round_trip() {
7955 let doc = AdfDocument {
7956 version: 1,
7957 doc_type: "doc".to_string(),
7958 content: vec![AdfNode::paragraph(vec![
7959 AdfNode::text("see "),
7960 AdfNode::media_inline(serde_json::json!({
7961 "type": "image",
7962 "id": "abcdef01-2345-6789-abcd-abcdef012345",
7963 "collection": "contentId-111111",
7964 "width": 200,
7965 "height": 100
7966 })),
7967 AdfNode::text(" for details"),
7968 ])],
7969 };
7970 let md = adf_to_markdown(&doc).unwrap();
7971 let rt = markdown_to_adf(&md).unwrap();
7972
7973 let content = rt.content[0].content.as_ref().unwrap();
7974 assert_eq!(content[0].text.as_deref(), Some("see "));
7975 assert_eq!(content[1].node_type, "mediaInline");
7976 let attrs = content[1].attrs.as_ref().unwrap();
7977 assert_eq!(attrs["type"], "image");
7978 assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
7979 assert_eq!(attrs["collection"], "contentId-111111");
7980 assert_eq!(attrs["width"], 200);
7981 assert_eq!(attrs["height"], 100);
7982 assert_eq!(content[2].text.as_deref(), Some(" for details"));
7983 }
7984
7985 #[test]
7986 fn media_inline_external_url_round_trip() {
7987 let doc = AdfDocument {
7988 version: 1,
7989 doc_type: "doc".to_string(),
7990 content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
7991 serde_json::json!({
7992 "type": "external",
7993 "url": "https://example.com/image.png",
7994 "alt": "example",
7995 "width": 400,
7996 "height": 300
7997 }),
7998 )])],
7999 };
8000 let md = adf_to_markdown(&doc).unwrap();
8001 let rt = markdown_to_adf(&md).unwrap();
8002
8003 let content = rt.content[0].content.as_ref().unwrap();
8004 assert_eq!(content[0].node_type, "mediaInline");
8005 let attrs = content[0].attrs.as_ref().unwrap();
8006 assert_eq!(attrs["type"], "external");
8007 assert_eq!(attrs["url"], "https://example.com/image.png");
8008 assert_eq!(attrs["alt"], "example");
8009 assert_eq!(attrs["width"], 400);
8010 assert_eq!(attrs["height"], 300);
8011 }
8012
8013 #[test]
8014 fn media_inline_minimal_attrs() {
8015 let doc = AdfDocument {
8016 version: 1,
8017 doc_type: "doc".to_string(),
8018 content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
8019 serde_json::json!({"type": "file", "id": "abc-123"}),
8020 )])],
8021 };
8022 let md = adf_to_markdown(&doc).unwrap();
8023 let rt = markdown_to_adf(&md).unwrap();
8024
8025 let content = rt.content[0].content.as_ref().unwrap();
8026 assert_eq!(content[0].node_type, "mediaInline");
8027 let attrs = content[0].attrs.as_ref().unwrap();
8028 assert_eq!(attrs["type"], "file");
8029 assert_eq!(attrs["id"], "abc-123");
8030 }
8031
8032 #[test]
8033 fn media_inline_from_issue_476_reproducer() {
8034 let adf_json: serde_json::Value = serde_json::json!({
8036 "type": "doc",
8037 "version": 1,
8038 "content": [
8039 {
8040 "type": "paragraph",
8041 "content": [
8042 {"type": "text", "text": "see "},
8043 {
8044 "type": "mediaInline",
8045 "attrs": {
8046 "collection": "contentId-111111",
8047 "height": 100,
8048 "id": "abcdef01-2345-6789-abcd-abcdef012345",
8049 "localId": "aabbccdd-1234-5678-abcd-aabbccdd1234",
8050 "type": "image",
8051 "width": 200
8052 }
8053 },
8054 {"type": "text", "text": " for details"}
8055 ]
8056 }
8057 ]
8058 });
8059 let doc: AdfDocument = serde_json::from_value(adf_json).unwrap();
8060 let md = adf_to_markdown(&doc).unwrap();
8061 assert!(
8062 !md.contains("<!-- unsupported inline"),
8063 "mediaInline should not be unsupported; got: {md}"
8064 );
8065
8066 let rt = markdown_to_adf(&md).unwrap();
8067 let content = rt.content[0].content.as_ref().unwrap();
8068 assert_eq!(content[1].node_type, "mediaInline");
8069 let attrs = content[1].attrs.as_ref().unwrap();
8070 assert_eq!(attrs["type"], "image");
8071 assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
8072 assert_eq!(attrs["collection"], "contentId-111111");
8073 assert_eq!(attrs["width"], 200);
8074 assert_eq!(attrs["height"], 100);
8075 assert_eq!(attrs["localId"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
8076 }
8077
8078 #[test]
8079 fn emoji_shortcode() {
8080 let doc = markdown_to_adf("Hello :wave: world").unwrap();
8081 let content = doc.content[0].content.as_ref().unwrap();
8082 assert_eq!(content[0].text.as_deref(), Some("Hello "));
8083 assert_eq!(content[1].node_type, "emoji");
8084 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":wave:");
8085 assert_eq!(content[2].text.as_deref(), Some(" world"));
8086 }
8087
8088 #[test]
8089 fn adf_emoji_to_markdown() {
8090 let doc = AdfDocument {
8091 version: 1,
8092 doc_type: "doc".to_string(),
8093 content: vec![AdfNode::paragraph(vec![AdfNode::emoji("thumbsup")])],
8094 };
8095 let md = adf_to_markdown(&doc).unwrap();
8096 assert!(md.contains(":thumbsup:"));
8097 }
8098
8099 #[test]
8100 fn adf_emoji_with_colon_prefix_to_markdown() {
8101 let doc = AdfDocument {
8103 version: 1,
8104 doc_type: "doc".to_string(),
8105 content: vec![AdfNode::paragraph(vec![AdfNode {
8106 node_type: "emoji".to_string(),
8107 attrs: Some(serde_json::json!({"shortName": ":thumbsup:"})),
8108 content: None,
8109 text: None,
8110 marks: None,
8111 local_id: None,
8112 parameters: None,
8113 }])],
8114 };
8115 let md = adf_to_markdown(&doc).unwrap();
8116 assert!(md.contains(":thumbsup:"));
8117 assert!(!md.contains("::thumbsup::"));
8119 }
8120
8121 #[test]
8122 fn round_trip_emoji() {
8123 let md = "Hello :wave: world\n";
8124 let doc = markdown_to_adf(md).unwrap();
8125 let result = adf_to_markdown(&doc).unwrap();
8126 assert!(result.contains(":wave:"));
8127 }
8128
8129 #[test]
8130 fn emoji_with_id_and_text_round_trips() {
8131 let doc = AdfDocument {
8132 version: 1,
8133 doc_type: "doc".to_string(),
8134 content: vec![AdfNode::paragraph(vec![AdfNode {
8135 node_type: "emoji".to_string(),
8136 attrs: Some(
8137 serde_json::json!({"shortName": ":check_mark:", "id": "2705", "text": "✅"}),
8138 ),
8139 content: None,
8140 text: None,
8141 marks: None,
8142 local_id: None,
8143 parameters: None,
8144 }])],
8145 };
8146 let md = adf_to_markdown(&doc).unwrap();
8147 assert!(md.contains(":check_mark:"), "shortcode present: {md}");
8148 assert!(md.contains("id="), "id attr present: {md}");
8149 assert!(md.contains("text="), "text attr present: {md}");
8150
8151 let round_tripped = markdown_to_adf(&md).unwrap();
8153 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8154 let attrs = emoji.attrs.as_ref().unwrap();
8155 assert_eq!(attrs["shortName"], ":check_mark:");
8156 assert_eq!(attrs["id"], "2705");
8157 assert_eq!(attrs["text"], "✅");
8158 }
8159
8160 #[test]
8161 fn emoji_without_extra_attrs_still_works() {
8162 let md = "Hello :wave: world\n";
8163 let doc = markdown_to_adf(md).unwrap();
8164 let emoji = &doc.content[0].content.as_ref().unwrap()[1];
8165 assert_eq!(emoji.attrs.as_ref().unwrap()["shortName"], ":wave:");
8166 assert!(emoji.attrs.as_ref().unwrap().get("id").is_none());
8168 }
8169
8170 #[test]
8171 fn emoji_shortname_preserves_colons_round_trip() {
8172 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8174 {"type":"emoji","attrs":{"shortName":":cross_mark:","id":"atlassian-cross_mark","text":"❌"}}
8175 ]}]}"#;
8176 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8177
8178 let md = adf_to_markdown(&doc).unwrap();
8180 let round_tripped = markdown_to_adf(&md).unwrap();
8181 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8182 let attrs = emoji.attrs.as_ref().unwrap();
8183 assert_eq!(
8184 attrs["shortName"], ":cross_mark:",
8185 "shortName should preserve colons, got: {}",
8186 attrs["shortName"]
8187 );
8188 assert_eq!(attrs["id"], "atlassian-cross_mark");
8189 assert_eq!(attrs["text"], "❌");
8190 }
8191
8192 #[test]
8193 fn emoji_shortname_without_colons_preserved() {
8194 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8196 {"type":"emoji","attrs":{"shortName":"white_check_mark","id":"2705","text":"✅"}}
8197 ]}]}"#;
8198 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8199 let md = adf_to_markdown(&doc).unwrap();
8200 let round_tripped = markdown_to_adf(&md).unwrap();
8201 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8202 let attrs = emoji.attrs.as_ref().unwrap();
8203 assert_eq!(
8204 attrs["shortName"], "white_check_mark",
8205 "shortName without colons should stay without colons, got: {}",
8206 attrs["shortName"]
8207 );
8208 }
8209
8210 #[test]
8211 fn colon_in_text_not_emoji() {
8212 let doc = markdown_to_adf("Time is 10:30 today").unwrap();
8214 let content = doc.content[0].content.as_ref().unwrap();
8215 assert_eq!(content.len(), 1);
8216 assert_eq!(content[0].node_type, "text");
8217 }
8218
8219 #[test]
8220 fn text_with_shortcode_pattern_round_trips_as_text() {
8221 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Alert :fire: triggered on pod:pod42"}]}]}"#;
8223 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8224
8225 let md = adf_to_markdown(&doc).unwrap();
8226 let round_tripped = markdown_to_adf(&md).unwrap();
8227 let content = round_tripped.content[0].content.as_ref().unwrap();
8228
8229 assert_eq!(
8230 content.len(),
8231 1,
8232 "should be a single text node, got: {content:?}"
8233 );
8234 assert_eq!(content[0].node_type, "text");
8235 assert_eq!(
8236 content[0].text.as_deref().unwrap(),
8237 "Alert :fire: triggered on pod:pod42"
8238 );
8239 }
8240
8241 #[test]
8242 fn double_colon_pattern_round_trips_as_text() {
8243 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status::Active::Running"}]}]}"#;
8245 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8246
8247 let md = adf_to_markdown(&doc).unwrap();
8248 let round_tripped = markdown_to_adf(&md).unwrap();
8249 let content = round_tripped.content[0].content.as_ref().unwrap();
8250
8251 assert_eq!(
8252 content.len(),
8253 1,
8254 "should be a single text node, got: {content:?}"
8255 );
8256 assert_eq!(content[0].node_type, "text");
8257 assert_eq!(
8258 content[0].text.as_deref().unwrap(),
8259 "Status::Active::Running"
8260 );
8261 }
8262
8263 #[test]
8264 fn real_emoji_node_still_round_trips() {
8265 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8267 {"type":"text","text":"Hello "},
8268 {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8269 {"type":"text","text":" world"}
8270 ]}]}"#;
8271 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8272
8273 let md = adf_to_markdown(&doc).unwrap();
8274 let round_tripped = markdown_to_adf(&md).unwrap();
8275 let content = round_tripped.content[0].content.as_ref().unwrap();
8276
8277 assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8279 assert_eq!(content[0].text.as_deref(), Some("Hello "));
8280 assert_eq!(content[1].node_type, "emoji");
8281 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":fire:");
8282 assert_eq!(content[2].text.as_deref(), Some(" world"));
8283 }
8284
8285 #[test]
8286 fn combined_emoji_shortname_round_trips_as_single_node() {
8287 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8292 {"type":"text","text":"Thanks for the help "},
8293 {"type":"emoji","attrs":{"shortName":":slightly_smiling_face::bow:","id":"","text":""}}
8294 ]}]}"#;
8295 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8296
8297 let md = adf_to_markdown(&doc).unwrap();
8298 let round_tripped = markdown_to_adf(&md).unwrap();
8299 let content = round_tripped.content[0].content.as_ref().unwrap();
8300
8301 assert_eq!(
8302 content.len(),
8303 2,
8304 "should have text + single combined emoji: {content:?}"
8305 );
8306 assert_eq!(content[0].text.as_deref(), Some("Thanks for the help "));
8307 assert_eq!(content[1].node_type, "emoji");
8308 let attrs = content[1].attrs.as_ref().unwrap();
8309 assert_eq!(attrs["shortName"], ":slightly_smiling_face::bow:");
8310 assert_eq!(attrs["id"], "");
8311 assert_eq!(attrs["text"], "");
8312 }
8313
8314 #[test]
8315 fn triple_combined_emoji_shortname_round_trips_as_single_node() {
8316 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8318 {"type":"emoji","attrs":{"shortName":":a::b::c:","id":"x","text":""}}
8319 ]}]}"#;
8320 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8321
8322 let md = adf_to_markdown(&doc).unwrap();
8323 let round_tripped = markdown_to_adf(&md).unwrap();
8324 let content = round_tripped.content[0].content.as_ref().unwrap();
8325
8326 assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8327 assert_eq!(content[0].node_type, "emoji");
8328 let attrs = content[0].attrs.as_ref().unwrap();
8329 assert_eq!(attrs["shortName"], ":a::b::c:");
8330 assert_eq!(attrs["id"], "x");
8331 }
8332
8333 #[test]
8334 fn consecutive_emojis_remain_separate_nodes() {
8335 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8339 {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8340 {"type":"emoji","attrs":{"shortName":":water:","id":"1f4a7","text":"💧"}}
8341 ]}]}"#;
8342 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8343
8344 let md = adf_to_markdown(&doc).unwrap();
8345 let round_tripped = markdown_to_adf(&md).unwrap();
8346 let content = round_tripped.content[0].content.as_ref().unwrap();
8347
8348 assert_eq!(content.len(), 2, "should be two emoji nodes: {content:?}");
8349 assert_eq!(content[0].node_type, "emoji");
8350 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8351 assert_eq!(content[1].node_type, "emoji");
8352 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8353 }
8354
8355 #[test]
8356 fn adjacent_shortcodes_without_directive_parse_as_two_emojis() {
8357 let md = ":fire::water:";
8360 let doc = markdown_to_adf(md).unwrap();
8361 let content = doc.content[0].content.as_ref().unwrap();
8362
8363 assert_eq!(content.len(), 2, "should be two emojis: {content:?}");
8364 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8365 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8366 }
8367
8368 #[test]
8369 fn combined_emoji_shortname_preserves_local_id() {
8370 let md = r#":a::b:{shortName=":a::b:" id="x" text="y" localId="abc"}"#;
8373 let doc = markdown_to_adf(md).unwrap();
8374 let content = doc.content[0].content.as_ref().unwrap();
8375
8376 assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8377 let attrs = content[0].attrs.as_ref().unwrap();
8378 assert_eq!(attrs["shortName"], ":a::b:");
8379 assert_eq!(attrs["id"], "x");
8380 assert_eq!(attrs["text"], "y");
8381 assert_eq!(attrs["localId"], "abc");
8382 }
8383
8384 #[test]
8385 fn text_shortcode_with_marks_round_trips() {
8386 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8388 {"type":"text","text":"Alert :fire: triggered","marks":[{"type":"strong"}]}
8389 ]}]}"#;
8390 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8391
8392 let md = adf_to_markdown(&doc).unwrap();
8393 let round_tripped = markdown_to_adf(&md).unwrap();
8394 let content = round_tripped.content[0].content.as_ref().unwrap();
8395
8396 assert_eq!(
8397 content.len(),
8398 1,
8399 "should be single bold text node: {content:?}"
8400 );
8401 assert_eq!(content[0].node_type, "text");
8402 assert_eq!(
8403 content[0].text.as_deref().unwrap(),
8404 "Alert :fire: triggered"
8405 );
8406 assert!(content[0]
8407 .marks
8408 .as_ref()
8409 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong")));
8410 }
8411
8412 #[test]
8413 fn mixed_emoji_node_and_text_shortcode_round_trips() {
8414 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8416 {"type":"emoji","attrs":{"shortName":":wave:","id":"1f44b","text":"👋"}},
8417 {"type":"text","text":" says :hello: to you"}
8418 ]}]}"#;
8419 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8420
8421 let md = adf_to_markdown(&doc).unwrap();
8422 let round_tripped = markdown_to_adf(&md).unwrap();
8423 let content = round_tripped.content[0].content.as_ref().unwrap();
8424
8425 assert_eq!(content.len(), 2, "should have 2 nodes: {content:?}");
8427 assert_eq!(content[0].node_type, "emoji");
8428 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":wave:");
8429 assert_eq!(content[1].node_type, "text");
8430 assert_eq!(content[1].text.as_deref().unwrap(), " says :hello: to you");
8431 }
8432
8433 #[test]
8434 fn code_block_with_shortcode_pattern_round_trips() {
8435 let adf_json = r#"{"version":1,"type":"doc","content":[
8438 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8439 {"type":"text","text":"module Foo::Bar::Baz\n def hello\n puts 'world'\n end\nend"}
8440 ]}
8441 ]}"#;
8442 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8443
8444 let md = adf_to_markdown(&doc).unwrap();
8445 let round_tripped = markdown_to_adf(&md).unwrap();
8446
8447 assert_eq!(
8448 round_tripped.content.len(),
8449 1,
8450 "should be a single codeBlock"
8451 );
8452 let cb = &round_tripped.content[0];
8453 assert_eq!(cb.node_type, "codeBlock");
8454 let content = cb.content.as_ref().expect("codeBlock content");
8455 assert_eq!(
8456 content.len(),
8457 1,
8458 "should be a single text node: {content:?}"
8459 );
8460 assert_eq!(content[0].node_type, "text");
8461 assert_eq!(
8462 content[0].text.as_deref().unwrap(),
8463 "module Foo::Bar::Baz\n def hello\n puts 'world'\n end\nend"
8464 );
8465 assert!(
8466 content.iter().all(|n| n.node_type != "emoji"),
8467 "no emoji nodes should be present, got: {content:?}"
8468 );
8469 }
8470
8471 #[test]
8472 fn code_block_with_exact_namespaced_shortcode_pattern_round_trips() {
8473 let adf_json = r#"{"version":1,"type":"doc","content":[
8475 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8476 {"type":"text","text":"class ZBC::Acme::PlanType::Professional < Base"}
8477 ]}
8478 ]}"#;
8479 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8480
8481 let md = adf_to_markdown(&doc).unwrap();
8482 let round_tripped = markdown_to_adf(&md).unwrap();
8483
8484 let cb = &round_tripped.content[0];
8485 assert_eq!(cb.node_type, "codeBlock");
8486 let content = cb.content.as_ref().expect("codeBlock content");
8487 assert_eq!(content.len(), 1, "should be a single text node");
8488 assert_eq!(
8489 content[0].text.as_deref().unwrap(),
8490 "class ZBC::Acme::PlanType::Professional < Base"
8491 );
8492 }
8493
8494 #[test]
8495 fn code_block_with_literal_shortcode_round_trips() {
8496 let adf_json = r#"{"version":1,"type":"doc","content":[
8499 {"type":"codeBlock","attrs":{"language":"text"},"content":[
8500 {"type":"text","text":":fire: :wave: :thumbsup:"}
8501 ]}
8502 ]}"#;
8503 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8504
8505 let md = adf_to_markdown(&doc).unwrap();
8506 let round_tripped = markdown_to_adf(&md).unwrap();
8507
8508 let cb = &round_tripped.content[0];
8509 assert_eq!(cb.node_type, "codeBlock");
8510 let content = cb.content.as_ref().expect("codeBlock content");
8511 assert_eq!(
8512 content.len(),
8513 1,
8514 "should be a single text node: {content:?}"
8515 );
8516 assert_eq!(content[0].node_type, "text");
8517 assert_eq!(
8518 content[0].text.as_deref().unwrap(),
8519 ":fire: :wave: :thumbsup:"
8520 );
8521 }
8522
8523 #[test]
8524 fn inline_code_with_shortcode_pattern_round_trips() {
8525 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8528 {"type":"text","text":"See "},
8529 {"type":"text","text":"Foo::Bar::Baz","marks":[{"type":"code"}]},
8530 {"type":"text","text":" for details"}
8531 ]}]}"#;
8532 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8533
8534 let md = adf_to_markdown(&doc).unwrap();
8535 let round_tripped = markdown_to_adf(&md).unwrap();
8536 let content = round_tripped.content[0].content.as_ref().unwrap();
8537
8538 assert_eq!(content.len(), 3, "should have 3 text nodes: {content:?}");
8539 assert_eq!(content[0].text.as_deref(), Some("See "));
8540 assert_eq!(content[1].text.as_deref(), Some("Foo::Bar::Baz"));
8541 assert!(content[1]
8542 .marks
8543 .as_ref()
8544 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8545 assert_eq!(content[2].text.as_deref(), Some(" for details"));
8546 assert!(
8547 content.iter().all(|n| n.node_type != "emoji"),
8548 "no emoji nodes should be present"
8549 );
8550 }
8551
8552 #[test]
8553 fn inline_code_with_literal_shortcode_round_trips() {
8554 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8557 {"type":"text","text":":fire:","marks":[{"type":"code"}]}
8558 ]}]}"#;
8559 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8560
8561 let md = adf_to_markdown(&doc).unwrap();
8562 let round_tripped = markdown_to_adf(&md).unwrap();
8563 let content = round_tripped.content[0].content.as_ref().unwrap();
8564
8565 assert_eq!(
8566 content.len(),
8567 1,
8568 "should be a single code node: {content:?}"
8569 );
8570 assert_eq!(content[0].node_type, "text");
8571 assert_eq!(content[0].text.as_deref(), Some(":fire:"));
8572 assert!(content[0]
8573 .marks
8574 .as_ref()
8575 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8576 }
8577
8578 #[test]
8579 fn code_block_in_list_with_shortcode_pattern_round_trips() {
8580 let adf_json = r#"{"version":1,"type":"doc","content":[
8583 {"type":"bulletList","content":[
8584 {"type":"listItem","content":[
8585 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8586 {"type":"text","text":"Foo::Bar::Baz"}
8587 ]}
8588 ]}
8589 ]}
8590 ]}"#;
8591 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8592
8593 let md = adf_to_markdown(&doc).unwrap();
8594 let round_tripped = markdown_to_adf(&md).unwrap();
8595
8596 let list = &round_tripped.content[0];
8597 assert_eq!(list.node_type, "bulletList");
8598 let item = &list.content.as_ref().unwrap()[0];
8599 assert_eq!(item.node_type, "listItem");
8600 let cb = &item.content.as_ref().unwrap()[0];
8601 assert_eq!(cb.node_type, "codeBlock");
8602 let cb_content = cb.content.as_ref().unwrap();
8603 assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8604 assert_eq!(cb_content[0].node_type, "text");
8605 }
8606
8607 #[test]
8608 fn code_block_with_unicode_shortcode_pattern_round_trips() {
8609 let adf_json = r#"{"version":1,"type":"doc","content":[
8613 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8614 {"type":"text","text":"class ZBC::配置::Production < Base"}
8615 ]}
8616 ]}"#;
8617 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8618
8619 let md = adf_to_markdown(&doc).unwrap();
8620 let round_tripped = markdown_to_adf(&md).unwrap();
8621
8622 let cb = &round_tripped.content[0];
8623 assert_eq!(cb.node_type, "codeBlock");
8624 let content = cb.content.as_ref().expect("codeBlock content");
8625 assert_eq!(content.len(), 1);
8626 assert_eq!(
8627 content[0].text.as_deref().unwrap(),
8628 "class ZBC::配置::Production < Base"
8629 );
8630 }
8631
8632 #[test]
8633 fn list_item_hardbreak_then_code_block_round_trips() {
8634 let adf_json = r#"{"version":1,"type":"doc","content":[
8640 {"type":"bulletList","content":[
8641 {"type":"listItem","content":[
8642 {"type":"paragraph","content":[
8643 {"type":"text","text":"Consider removing this check."},
8644 {"type":"hardBreak"}
8645 ]},
8646 {"type":"codeBlock","content":[
8647 {"type":"text","text":"x = Foo::Bar::Baz.new"}
8648 ]}
8649 ]}
8650 ]}
8651 ]}"#;
8652 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8653
8654 let md = adf_to_markdown(&doc).unwrap();
8655 let round_tripped = markdown_to_adf(&md).unwrap();
8656
8657 let list = &round_tripped.content[0];
8658 assert_eq!(list.node_type, "bulletList");
8659 let item = &list.content.as_ref().unwrap()[0];
8660 assert_eq!(item.node_type, "listItem");
8661 let item_content = item.content.as_ref().unwrap();
8662 assert_eq!(
8663 item_content.len(),
8664 2,
8665 "listItem should have paragraph + codeBlock, got: {item_content:?}"
8666 );
8667 assert_eq!(item_content[0].node_type, "paragraph");
8668 assert_eq!(item_content[1].node_type, "codeBlock");
8669
8670 let cb_content = item_content[1].content.as_ref().unwrap();
8672 assert_eq!(cb_content[0].node_type, "text");
8673 assert_eq!(
8674 cb_content[0].text.as_deref().unwrap(),
8675 "x = Foo::Bar::Baz.new"
8676 );
8677
8678 assert!(
8680 item_content
8681 .iter()
8682 .flat_map(|n| n.content.iter().flat_map(|c| c.iter()))
8683 .all(|n| n.node_type != "emoji"),
8684 "no emoji nodes should be present, got: {item_content:?}"
8685 );
8686 }
8687
8688 #[test]
8689 fn list_item_hardbreak_then_nested_list_still_works() {
8690 let md = "- first\\\n continuation text\\\n - nested item\n";
8694 let doc = markdown_to_adf(md).unwrap();
8695 let list = &doc.content[0];
8696 assert_eq!(list.node_type, "bulletList");
8697 let item = &list.content.as_ref().unwrap()[0];
8698 let item_content = item.content.as_ref().unwrap();
8700 let para = &item_content[0];
8701 assert_eq!(para.node_type, "paragraph");
8702 let para_nodes = para.content.as_ref().unwrap();
8703 assert!(
8704 para_nodes
8705 .iter()
8706 .any(|n| n.text.as_deref() == Some("continuation text")),
8707 "continuation text should survive: {para_nodes:?}"
8708 );
8709 }
8710
8711 #[test]
8712 fn list_item_hardbreak_then_image_still_works() {
8713 let md = "- first\\\n {type=file id=x}\n";
8718 let doc = markdown_to_adf(md).unwrap();
8719 let list = &doc.content[0];
8720 let item = &list.content.as_ref().unwrap()[0];
8721 let item_content = item.content.as_ref().unwrap();
8722 assert!(
8724 item_content.iter().any(|n| n.node_type == "mediaSingle"),
8725 "mediaSingle should still be a block-level sibling, got: {item_content:?}"
8726 );
8727 }
8728
8729 #[test]
8730 fn list_item_hardbreak_then_container_directive_round_trips() {
8731 let adf_json = r#"{"version":1,"type":"doc","content":[
8735 {"type":"bulletList","content":[
8736 {"type":"listItem","content":[
8737 {"type":"paragraph","content":[
8738 {"type":"text","text":"intro"},
8739 {"type":"hardBreak"}
8740 ]},
8741 {"type":"panel","attrs":{"panelType":"info"},"content":[
8742 {"type":"paragraph","content":[
8743 {"type":"text","text":"panel body"}
8744 ]}
8745 ]}
8746 ]}
8747 ]}
8748 ]}"#;
8749 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8750 let md = adf_to_markdown(&doc).unwrap();
8751 let round_tripped = markdown_to_adf(&md).unwrap();
8752
8753 let item = &round_tripped.content[0].content.as_ref().unwrap()[0];
8754 let item_content = item.content.as_ref().unwrap();
8755 assert!(
8756 item_content.iter().any(|n| n.node_type == "panel"),
8757 "panel should survive as block-level sibling, got: {item_content:?}"
8758 );
8759 }
8760
8761 #[test]
8762 fn inline_code_with_unicode_shortcode_pattern_round_trips() {
8763 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8766 {"type":"text","text":"See "},
8767 {"type":"text","text":"ZBC::配置::Production","marks":[{"type":"code"}]},
8768 {"type":"text","text":" for prod"}
8769 ]}]}"#;
8770 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8771
8772 let md = adf_to_markdown(&doc).unwrap();
8773 let round_tripped = markdown_to_adf(&md).unwrap();
8774 let content = round_tripped.content[0].content.as_ref().unwrap();
8775
8776 assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8777 assert_eq!(content[1].text.as_deref(), Some("ZBC::配置::Production"));
8778 assert!(content[1]
8779 .marks
8780 .as_ref()
8781 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8782 }
8783
8784 #[test]
8785 fn code_block_followed_by_shortcode_text_round_trips() {
8786 let adf_json = r#"{"version":1,"type":"doc","content":[
8789 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8790 {"type":"text","text":"Foo::Bar::Baz"}
8791 ]},
8792 {"type":"paragraph","content":[
8793 {"type":"text","text":"Status::Active::Running"}
8794 ]}
8795 ]}"#;
8796 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8797
8798 let md = adf_to_markdown(&doc).unwrap();
8799 let round_tripped = markdown_to_adf(&md).unwrap();
8800
8801 assert_eq!(round_tripped.content.len(), 2);
8802 let cb = &round_tripped.content[0];
8803 assert_eq!(cb.node_type, "codeBlock");
8804 let cb_content = cb.content.as_ref().unwrap();
8805 assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8806
8807 let para = &round_tripped.content[1];
8808 assert_eq!(para.node_type, "paragraph");
8809 let para_content = para.content.as_ref().unwrap();
8810 assert_eq!(para_content.len(), 1);
8811 assert_eq!(para_content[0].node_type, "text");
8812 assert_eq!(
8813 para_content[0].text.as_deref(),
8814 Some("Status::Active::Running")
8815 );
8816 }
8817
8818 #[test]
8819 fn adf_inline_card_to_markdown() {
8820 let doc = AdfDocument {
8821 version: 1,
8822 doc_type: "doc".to_string(),
8823 content: vec![AdfNode::paragraph(vec![AdfNode {
8824 node_type: "inlineCard".to_string(),
8825 attrs: Some(
8826 serde_json::json!({"url": "https://org.atlassian.net/browse/ACCS-4382"}),
8827 ),
8828 content: None,
8829 text: None,
8830 marks: None,
8831 local_id: None,
8832 parameters: None,
8833 }])],
8834 };
8835 let md = adf_to_markdown(&doc).unwrap();
8836 assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
8837 assert!(!md.contains("<!-- unsupported inline"));
8838 }
8839
8840 #[test]
8841 fn inline_card_directive_round_trips() {
8842 let original = AdfDocument {
8844 version: 1,
8845 doc_type: "doc".to_string(),
8846 content: vec![AdfNode::paragraph(vec![AdfNode::inline_card(
8847 "https://org.atlassian.net/browse/ACCS-4382",
8848 )])],
8849 };
8850 let md = adf_to_markdown(&original).unwrap();
8851 assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
8852 let restored = markdown_to_adf(&md).unwrap();
8853 let node = &restored.content[0].content.as_ref().unwrap()[0];
8854 assert_eq!(node.node_type, "inlineCard");
8855 assert_eq!(
8856 node.attrs.as_ref().unwrap()["url"],
8857 "https://org.atlassian.net/browse/ACCS-4382"
8858 );
8859 }
8860
8861 #[test]
8862 fn inline_card_directive_parsed_from_jfm() {
8863 let doc = markdown_to_adf("See :card[https://example.com/issue/123] for details.").unwrap();
8865 let nodes = doc.content[0].content.as_ref().unwrap();
8866 assert_eq!(nodes[0].node_type, "text");
8867 assert_eq!(nodes[0].text.as_deref(), Some("See "));
8868 assert_eq!(nodes[1].node_type, "inlineCard");
8869 assert_eq!(
8870 nodes[1].attrs.as_ref().unwrap()["url"],
8871 "https://example.com/issue/123"
8872 );
8873 assert_eq!(nodes[2].node_type, "text");
8874 assert_eq!(nodes[2].text.as_deref(), Some(" for details."));
8875 }
8876
8877 #[test]
8878 fn self_link_becomes_link_mark_not_inline_card() {
8879 let doc = markdown_to_adf("[https://example.com](https://example.com)").unwrap();
8882 let node = &doc.content[0].content.as_ref().unwrap()[0];
8883 assert_eq!(node.node_type, "text");
8884 assert_eq!(node.text.as_deref(), Some("https://example.com"));
8885 let mark = &node.marks.as_ref().unwrap()[0];
8886 assert_eq!(mark.mark_type, "link");
8887 assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
8888 }
8889
8890 #[test]
8891 fn url_link_text_with_trailing_slash_mismatch_becomes_link_mark() {
8892 let doc =
8895 markdown_to_adf("[https://octopz.example.com](https://octopz.example.com/)").unwrap();
8896 let node = &doc.content[0].content.as_ref().unwrap()[0];
8897 assert_eq!(node.node_type, "text");
8898 assert_eq!(node.text.as_deref(), Some("https://octopz.example.com"));
8899 let mark = &node.marks.as_ref().unwrap()[0];
8900 assert_eq!(mark.mark_type, "link");
8901 assert_eq!(
8902 mark.attrs.as_ref().unwrap()["href"],
8903 "https://octopz.example.com/"
8904 );
8905 }
8906
8907 #[test]
8908 fn named_link_does_not_become_inline_card() {
8909 let doc = markdown_to_adf("[#4668](https://github.com/org/repo/pull/4668)").unwrap();
8911 let node = &doc.content[0].content.as_ref().unwrap()[0];
8912 assert_eq!(node.node_type, "text");
8913 assert_eq!(node.text.as_deref(), Some("#4668"));
8914 let mark = &node.marks.as_ref().unwrap()[0];
8915 assert_eq!(mark.mark_type, "link");
8916 }
8917
8918 #[test]
8919 fn adf_inline_card_no_url_to_markdown() {
8920 let doc = AdfDocument {
8921 version: 1,
8922 doc_type: "doc".to_string(),
8923 content: vec![AdfNode::paragraph(vec![AdfNode {
8924 node_type: "inlineCard".to_string(),
8925 attrs: Some(serde_json::json!({})),
8926 content: None,
8927 text: None,
8928 marks: None,
8929 local_id: None,
8930 parameters: None,
8931 }])],
8932 };
8933 let md = adf_to_markdown(&doc).unwrap();
8934 assert!(!md.contains("<!-- unsupported inline"));
8936 }
8937
8938 #[test]
8939 fn adf_code_block_no_language_to_markdown() {
8940 let doc = AdfDocument {
8941 version: 1,
8942 doc_type: "doc".to_string(),
8943 content: vec![AdfNode::code_block(None, "plain code")],
8944 };
8945 let md = adf_to_markdown(&doc).unwrap();
8946 assert!(md.contains("```\n"));
8947 assert!(md.contains("plain code"));
8948 }
8949
8950 #[test]
8951 fn adf_code_block_empty_language_to_markdown() {
8952 let doc = AdfDocument {
8953 version: 1,
8954 doc_type: "doc".to_string(),
8955 content: vec![AdfNode::code_block(Some(""), "plain code")],
8956 };
8957 let md = adf_to_markdown(&doc).unwrap();
8958 assert!(md.contains("```\"\"\n"));
8959 assert!(md.contains("plain code"));
8960 }
8961
8962 #[test]
8965 fn round_trip_table() {
8966 let md = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
8967 let adf = markdown_to_adf(md).unwrap();
8968 let restored = adf_to_markdown(&adf).unwrap();
8969 assert!(restored.contains("| A | B |"));
8970 assert!(restored.contains("| 1 | 2 |"));
8971 }
8972
8973 #[test]
8974 fn round_trip_blockquote() {
8975 let md = "> This is quoted\n";
8976 let adf = markdown_to_adf(md).unwrap();
8977 let restored = adf_to_markdown(&adf).unwrap();
8978 assert!(restored.contains("> This is quoted"));
8979 }
8980
8981 #[test]
8982 fn round_trip_image() {
8983 let md = "\n";
8984 let adf = markdown_to_adf(md).unwrap();
8985 let restored = adf_to_markdown(&adf).unwrap();
8986 assert!(restored.contains(""));
8987 }
8988
8989 #[test]
8990 fn round_trip_ordered_list() {
8991 let md = "1. A\n2. B\n3. C\n";
8992 let adf = markdown_to_adf(md).unwrap();
8993 let restored = adf_to_markdown(&adf).unwrap();
8994 assert!(restored.contains("1. A"));
8995 assert!(restored.contains("2. B"));
8996 assert!(restored.contains("3. C"));
8997 }
8998
8999 #[test]
9000 fn round_trip_inline_marks() {
9001 let md = "Text with `code` and ~~strike~~ and [link](https://x.com).\n";
9002 let adf = markdown_to_adf(md).unwrap();
9003 let restored = adf_to_markdown(&adf).unwrap();
9004 assert!(restored.contains("`code`"));
9005 assert!(restored.contains("~~strike~~"));
9006 assert!(restored.contains("[link](https://x.com)"));
9007 }
9008
9009 #[test]
9012 fn panel_info() {
9013 let md = ":::panel{type=info}\nThis is informational.\n:::";
9014 let doc = markdown_to_adf(md).unwrap();
9015 assert_eq!(doc.content[0].node_type, "panel");
9016 assert_eq!(doc.content[0].attrs.as_ref().unwrap()["panelType"], "info");
9017 let inner = doc.content[0].content.as_ref().unwrap();
9018 assert_eq!(inner[0].node_type, "paragraph");
9019 }
9020
9021 #[test]
9022 fn adf_panel_to_markdown() {
9023 let doc = AdfDocument {
9024 version: 1,
9025 doc_type: "doc".to_string(),
9026 content: vec![AdfNode::panel(
9027 "warning",
9028 vec![AdfNode::paragraph(vec![AdfNode::text("Be careful.")])],
9029 )],
9030 };
9031 let md = adf_to_markdown(&doc).unwrap();
9032 assert!(md.contains(":::panel{type=warning}"));
9033 assert!(md.contains("Be careful."));
9034 assert!(md.contains(":::"));
9035 }
9036
9037 #[test]
9038 fn round_trip_panel() {
9039 let md = ":::panel{type=info}\nThis is informational.\n:::\n";
9040 let doc = markdown_to_adf(md).unwrap();
9041 let result = adf_to_markdown(&doc).unwrap();
9042 assert!(result.contains(":::panel{type=info}"));
9043 assert!(result.contains("This is informational."));
9044 }
9045
9046 #[test]
9047 fn expand_with_title() {
9048 let md = ":::expand{title=\"Click me\"}\nHidden content.\n:::";
9049 let doc = markdown_to_adf(md).unwrap();
9050 assert_eq!(doc.content[0].node_type, "expand");
9051 assert_eq!(doc.content[0].attrs.as_ref().unwrap()["title"], "Click me");
9052 }
9053
9054 #[test]
9055 fn adf_expand_to_markdown() {
9056 let doc = AdfDocument {
9057 version: 1,
9058 doc_type: "doc".to_string(),
9059 content: vec![AdfNode::expand(
9060 Some("Details"),
9061 vec![AdfNode::paragraph(vec![AdfNode::text("Inner.")])],
9062 )],
9063 };
9064 let md = adf_to_markdown(&doc).unwrap();
9065 assert!(md.contains(":::expand{title=\"Details\"}"));
9066 assert!(md.contains("Inner."));
9067 }
9068
9069 #[test]
9070 fn round_trip_expand() {
9071 let md = ":::expand{title=\"Details\"}\nInner content.\n:::\n";
9072 let doc = markdown_to_adf(md).unwrap();
9073 let result = adf_to_markdown(&doc).unwrap();
9074 assert!(result.contains(":::expand{title=\"Details\"}"));
9075 assert!(result.contains("Inner content."));
9076 }
9077
9078 #[test]
9079 fn layout_two_columns() {
9080 let md =
9081 "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
9082 let doc = markdown_to_adf(md).unwrap();
9083 assert_eq!(doc.content[0].node_type, "layoutSection");
9084 let columns = doc.content[0].content.as_ref().unwrap();
9085 assert_eq!(columns.len(), 2);
9086 assert_eq!(columns[0].node_type, "layoutColumn");
9087 assert_eq!(columns[1].node_type, "layoutColumn");
9088 }
9089
9090 #[test]
9091 fn adf_layout_to_markdown() {
9092 let doc = AdfDocument {
9093 version: 1,
9094 doc_type: "doc".to_string(),
9095 content: vec![AdfNode::layout_section(vec![
9096 AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Left.")])]),
9097 AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Right.")])]),
9098 ])],
9099 };
9100 let md = adf_to_markdown(&doc).unwrap();
9101 assert!(md.contains("::::layout"));
9102 assert!(md.contains(":::column{width=50}"));
9103 assert!(md.contains("Left."));
9104 assert!(md.contains("Right."));
9105 }
9106
9107 #[test]
9108 fn layout_column_localid_roundtrip() {
9109 let adf_json = r#"{
9110 "version": 1,
9111 "type": "doc",
9112 "content": [{
9113 "type": "layoutSection",
9114 "content": [
9115 {
9116 "type": "layoutColumn",
9117 "attrs": {"width": 50.0, "localId": "aabb112233cc"},
9118 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Left"}]}]
9119 },
9120 {
9121 "type": "layoutColumn",
9122 "attrs": {"width": 50.0, "localId": "ddeeff445566"},
9123 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Right"}]}]
9124 }
9125 ]
9126 }]
9127 }"#;
9128 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9129 let md = adf_to_markdown(&doc).unwrap();
9130 assert!(
9131 md.contains("localId=aabb112233cc"),
9132 "first column localId should appear in markdown: {md}"
9133 );
9134 assert!(
9135 md.contains("localId=ddeeff445566"),
9136 "second column localId should appear in markdown: {md}"
9137 );
9138 let rt = markdown_to_adf(&md).unwrap();
9139 let cols = rt.content[0].content.as_ref().unwrap();
9140 assert_eq!(
9141 cols[0].attrs.as_ref().unwrap()["localId"],
9142 "aabb112233cc",
9143 "first column localId should round-trip"
9144 );
9145 assert_eq!(
9146 cols[1].attrs.as_ref().unwrap()["localId"],
9147 "ddeeff445566",
9148 "second column localId should round-trip"
9149 );
9150 }
9151
9152 #[test]
9153 fn layout_column_without_localid() {
9154 let md =
9155 "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
9156 let doc = markdown_to_adf(md).unwrap();
9157 let cols = doc.content[0].content.as_ref().unwrap();
9158 assert!(
9159 cols[0].attrs.as_ref().unwrap().get("localId").is_none(),
9160 "column without localId should not gain one"
9161 );
9162 let md2 = adf_to_markdown(&doc).unwrap();
9163 assert!(
9164 !md2.contains("localId"),
9165 "no localId should appear in output: {md2}"
9166 );
9167 }
9168
9169 #[test]
9170 fn layout_column_localid_stripped_when_option_set() {
9171 let adf_json = r#"{
9172 "version": 1,
9173 "type": "doc",
9174 "content": [{
9175 "type": "layoutSection",
9176 "content": [{
9177 "type": "layoutColumn",
9178 "attrs": {"width": 50.0, "localId": "aabb112233cc"},
9179 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Col"}]}]
9180 }]
9181 }]
9182 }"#;
9183 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9184 let opts = RenderOptions {
9185 strip_local_ids: true,
9186 ..Default::default()
9187 };
9188 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
9189 assert!(!md.contains("localId"), "localId should be stripped: {md}");
9190 }
9191
9192 #[test]
9193 fn layout_column_localid_flush_previous() {
9194 let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nLeft.\n:::column{width=50 localId=ddeeff445566}\nRight.\n:::\n::::";
9196 let doc = markdown_to_adf(md).unwrap();
9197 let cols = doc.content[0].content.as_ref().unwrap();
9198 assert_eq!(
9199 cols[0].attrs.as_ref().unwrap()["localId"],
9200 "aabb112233cc",
9201 "flush-previous column should preserve localId"
9202 );
9203 assert_eq!(
9204 cols[1].attrs.as_ref().unwrap()["localId"],
9205 "ddeeff445566",
9206 "second column localId should be preserved"
9207 );
9208 }
9209
9210 #[test]
9211 fn layout_column_localid_flush_last() {
9212 let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nOnly column.";
9214 let doc = markdown_to_adf(md).unwrap();
9215 let cols = doc.content[0].content.as_ref().unwrap();
9216 assert_eq!(
9217 cols[0].attrs.as_ref().unwrap()["localId"],
9218 "aabb112233cc",
9219 "flush-last column should preserve localId"
9220 );
9221 }
9222
9223 #[test]
9225 fn issue_555_layout_column_fractional_width_roundtrip() {
9226 let adf_json = r#"{
9227 "version": 1,
9228 "type": "doc",
9229 "content": [{
9230 "type": "layoutSection",
9231 "content": [
9232 {
9233 "type": "layoutColumn",
9234 "attrs": {"width": 66.66},
9235 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9236 },
9237 {
9238 "type": "layoutColumn",
9239 "attrs": {"width": 33.34},
9240 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9241 }
9242 ]
9243 }]
9244 }"#;
9245 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9246 let md = adf_to_markdown(&doc).unwrap();
9247 assert!(md.contains("width=66.66"), "fractional width in md: {md}");
9248 assert!(md.contains("width=33.34"), "fractional width in md: {md}");
9249 let rt = markdown_to_adf(&md).unwrap();
9250 let cols = rt.content[0].content.as_ref().unwrap();
9251 assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 66.66);
9252 assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 33.34);
9253 }
9254
9255 #[test]
9257 fn issue_555_layout_column_five_sixths_width_roundtrip() {
9258 let adf_json = r#"{
9259 "version": 1,
9260 "type": "doc",
9261 "content": [{
9262 "type": "layoutSection",
9263 "content": [
9264 {
9265 "type": "layoutColumn",
9266 "attrs": {"width": 83.33},
9267 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9268 },
9269 {
9270 "type": "layoutColumn",
9271 "attrs": {"width": 16.67},
9272 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9273 }
9274 ]
9275 }]
9276 }"#;
9277 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9278 let md = adf_to_markdown(&doc).unwrap();
9279 let rt = markdown_to_adf(&md).unwrap();
9280 let cols = rt.content[0].content.as_ref().unwrap();
9281 assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 83.33);
9282 assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 16.67);
9283 }
9284
9285 #[test]
9287 fn issue_555_layout_column_integer_width_preserved() {
9288 let adf_json = r#"{
9289 "version": 1,
9290 "type": "doc",
9291 "content": [{
9292 "type": "layoutSection",
9293 "content": [
9294 {
9295 "type": "layoutColumn",
9296 "attrs": {"width": 50},
9297 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "A"}]}]
9298 },
9299 {
9300 "type": "layoutColumn",
9301 "attrs": {"width": 50},
9302 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "B"}]}]
9303 }
9304 ]
9305 }]
9306 }"#;
9307 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9308 let md = adf_to_markdown(&doc).unwrap();
9309 assert!(
9310 md.contains("width=50") && !md.contains("width=50."),
9311 "integer width should render without decimal: {md}"
9312 );
9313 let rt = markdown_to_adf(&md).unwrap();
9314 let cols = rt.content[0].content.as_ref().unwrap();
9315 let w0 = &cols[0].attrs.as_ref().unwrap()["width"];
9316 assert!(
9317 w0.is_i64() || w0.is_u64(),
9318 "width should remain a JSON integer, got: {w0}"
9319 );
9320 assert_eq!(w0.as_i64(), Some(50));
9321 }
9322
9323 #[test]
9325 fn issue_555_media_single_integer_width_preserved() {
9326 let adf_json = r#"{
9327 "version": 1,
9328 "type": "doc",
9329 "content": [{
9330 "type": "mediaSingle",
9331 "attrs": {"layout": "center", "width": 800},
9332 "content": [
9333 {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9334 ]
9335 }]
9336 }"#;
9337 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9338 let md = adf_to_markdown(&doc).unwrap();
9339 assert!(
9340 md.contains("width=800") && !md.contains("width=800."),
9341 "integer width should render without decimal: {md}"
9342 );
9343 let rt = markdown_to_adf(&md).unwrap();
9344 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9345 let w = &ms_attrs["width"];
9346 assert!(
9347 w.is_i64() || w.is_u64(),
9348 "mediaSingle width should remain JSON integer, got: {w}"
9349 );
9350 assert_eq!(w.as_i64(), Some(800));
9351 }
9352
9353 #[test]
9357 fn issue_555_media_single_fractional_width_preserved() {
9358 let adf_json = r#"{
9359 "version": 1,
9360 "type": "doc",
9361 "content": [{
9362 "type": "mediaSingle",
9363 "attrs": {"layout": "center", "width": 66.5},
9364 "content": [
9365 {"type": "media", "attrs": {"type": "external", "url": "https://example.com/diagram.png"}}
9366 ]
9367 }]
9368 }"#;
9369 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9370 let md = adf_to_markdown(&doc).unwrap();
9371 assert!(
9372 md.contains("width=66.5"),
9373 "fractional width must appear in JFM: {md}"
9374 );
9375 let rt = markdown_to_adf(&md).unwrap();
9376 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9377 assert_eq!(ms_attrs["width"], 66.5);
9378 }
9379
9380 #[test]
9382 fn issue_555_media_single_float_width_preserved() {
9383 let adf_json = r#"{
9384 "version": 1,
9385 "type": "doc",
9386 "content": [{
9387 "type": "mediaSingle",
9388 "attrs": {"layout": "center", "width": 800.0},
9389 "content": [
9390 {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9391 ]
9392 }]
9393 }"#;
9394 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9395 let md = adf_to_markdown(&doc).unwrap();
9396 assert!(
9397 md.contains("width=800.0"),
9398 "float width should render with decimal: {md}"
9399 );
9400 let rt = markdown_to_adf(&md).unwrap();
9401 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9402 let w = &ms_attrs["width"];
9403 assert!(
9404 w.is_f64(),
9405 "mediaSingle float width should stay a JSON float, got: {w}"
9406 );
9407 assert_eq!(w.as_f64(), Some(800.0));
9408 }
9409
9410 #[test]
9412 fn issue_555_media_single_wide_layout_integer_width_roundtrip() {
9413 let adf_json = r#"{
9414 "version": 1,
9415 "type": "doc",
9416 "content": [{
9417 "type": "mediaSingle",
9418 "attrs": {"layout": "wide", "width": 1420},
9419 "content": [
9420 {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9421 ]
9422 }]
9423 }"#;
9424 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9425 let md = adf_to_markdown(&doc).unwrap();
9426 let rt = markdown_to_adf(&md).unwrap();
9427 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9428 assert_eq!(ms_attrs["layout"], "wide");
9429 let w = &ms_attrs["width"];
9430 assert!(
9431 w.is_i64() || w.is_u64(),
9432 "mediaSingle width should remain JSON integer, got: {w}"
9433 );
9434 assert_eq!(w.as_i64(), Some(1420));
9435 }
9436
9437 #[test]
9440 fn issue_555_file_media_single_integer_width_preserved() {
9441 let adf_json = r#"{
9442 "version": 1,
9443 "type": "doc",
9444 "content": [{
9445 "type": "mediaSingle",
9446 "attrs": {"layout": "wide", "width": 1420},
9447 "content": [
9448 {"type": "media", "attrs": {"id": "abc-123", "type": "file", "collection": "col-1", "width": 1200, "height": 800}}
9449 ]
9450 }]
9451 }"#;
9452 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9453 let md = adf_to_markdown(&doc).unwrap();
9454 let rt = markdown_to_adf(&md).unwrap();
9455 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9456 let ms_w = &ms_attrs["width"];
9457 assert!(ms_w.is_i64() || ms_w.is_u64(), "ms width: {ms_w}");
9458 assert_eq!(ms_w.as_i64(), Some(1420));
9459 let media = &rt.content[0].content.as_ref().unwrap()[0];
9460 let media_attrs = media.attrs.as_ref().unwrap();
9461 let mw = &media_attrs["width"];
9462 assert!(mw.is_i64() || mw.is_u64(), "media width: {mw}");
9463 assert_eq!(mw.as_i64(), Some(1200));
9464 let mh = &media_attrs["height"];
9465 assert!(mh.is_i64() || mh.is_u64(), "media height: {mh}");
9466 assert_eq!(mh.as_i64(), Some(800));
9467 }
9468
9469 #[test]
9471 fn issue_555_fmt_numeric_attr_preserves_type() {
9472 assert_eq!(
9473 fmt_numeric_attr(&serde_json::json!(50)).as_deref(),
9474 Some("50")
9475 );
9476 assert_eq!(
9477 fmt_numeric_attr(&serde_json::json!(50.0)).as_deref(),
9478 Some("50.0")
9479 );
9480 assert_eq!(
9481 fmt_numeric_attr(&serde_json::json!(66.66)).as_deref(),
9482 Some("66.66")
9483 );
9484 assert_eq!(
9485 fmt_numeric_attr(&serde_json::json!(-5)).as_deref(),
9486 Some("-5")
9487 );
9488 assert_eq!(fmt_numeric_attr(&serde_json::json!("not a number")), None);
9489 let big = serde_json::Value::Number(serde_json::Number::from(u64::MAX));
9491 assert_eq!(
9492 fmt_numeric_attr(&big).as_deref(),
9493 Some("18446744073709551615")
9494 );
9495 assert_eq!(fmt_numeric_attr(&serde_json::Value::Null), None);
9497 }
9498
9499 #[test]
9501 fn issue_555_parse_numeric_attr_detects_type() {
9502 let v = parse_numeric_attr("800").unwrap();
9503 assert!(v.is_i64() || v.is_u64(), "'800' should parse as integer");
9504 assert_eq!(v.as_i64(), Some(800));
9505
9506 let v = parse_numeric_attr("800.0").unwrap();
9507 assert!(v.is_f64(), "'800.0' should parse as float");
9508 assert_eq!(v.as_f64(), Some(800.0));
9509
9510 let v = parse_numeric_attr("66.66").unwrap();
9511 assert!(v.is_f64());
9512 assert_eq!(v.as_f64(), Some(66.66));
9513
9514 let v = parse_numeric_attr("1e2").unwrap();
9516 assert!(v.is_f64());
9517 assert_eq!(v.as_f64(), Some(100.0));
9518 let v = parse_numeric_attr("1E2").unwrap();
9519 assert!(v.is_f64());
9520 assert_eq!(v.as_f64(), Some(100.0));
9521
9522 let v = parse_numeric_attr("-42").unwrap();
9524 assert!(v.is_i64());
9525 assert_eq!(v.as_i64(), Some(-42));
9526
9527 assert!(parse_numeric_attr("not-a-number").is_none());
9528 assert!(parse_numeric_attr("").is_none());
9529 assert!(parse_numeric_attr("1.2.3").is_none());
9530 }
9531
9532 #[test]
9535 fn issue_555_media_single_wide_layout_fractional_width_roundtrip() {
9536 let adf_json = r#"{
9537 "version": 1,
9538 "type": "doc",
9539 "content": [{
9540 "type": "mediaSingle",
9541 "attrs": {"layout": "wide", "width": 83.33},
9542 "content": [
9543 {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9544 ]
9545 }]
9546 }"#;
9547 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9548 let md = adf_to_markdown(&doc).unwrap();
9549 assert!(md.contains("layout=wide"), "layout must appear in md: {md}");
9550 assert!(md.contains("width=83.33"), "width must appear in md: {md}");
9551 let rt = markdown_to_adf(&md).unwrap();
9552 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9553 assert_eq!(ms_attrs["layout"], "wide");
9554 assert_eq!(ms_attrs["width"], 83.33);
9555 }
9556
9557 #[test]
9561 fn issue_555_file_media_single_fractional_media_width_preserved() {
9562 let adf_json = r#"{
9563 "version": 1,
9564 "type": "doc",
9565 "content": [{
9566 "type": "mediaSingle",
9567 "attrs": {"layout": "wide", "width": 66.5},
9568 "content": [
9569 {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c"}}
9570 ]
9571 }]
9572 }"#;
9573 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9574 let md = adf_to_markdown(&doc).unwrap();
9575 assert!(md.contains("mediaWidth=66.5"), "mediaWidth in md: {md}");
9576 let rt = markdown_to_adf(&md).unwrap();
9577 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9578 assert_eq!(ms_attrs["width"], 66.5);
9579 }
9580
9581 #[test]
9585 fn issue_555_file_media_fractional_inner_dimensions_preserved() {
9586 let adf_json = r#"{
9587 "version": 1,
9588 "type": "doc",
9589 "content": [{
9590 "type": "mediaSingle",
9591 "attrs": {"layout": "center"},
9592 "content": [
9593 {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c", "width": 1200.5, "height": 800.25}}
9594 ]
9595 }]
9596 }"#;
9597 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9598 let md = adf_to_markdown(&doc).unwrap();
9599 assert!(md.contains("width=1200.5"), "width in md: {md}");
9600 assert!(md.contains("height=800.25"), "height in md: {md}");
9601 let rt = markdown_to_adf(&md).unwrap();
9602 let media = &rt.content[0].content.as_ref().unwrap()[0];
9603 let attrs = media.attrs.as_ref().unwrap();
9604 assert_eq!(attrs["width"], 1200.5);
9605 assert_eq!(attrs["height"], 800.25);
9606 }
9607
9608 #[test]
9609 fn decisions_list() {
9610 let md = ":::decisions\n- <> Use PostgreSQL\n- <> REST API\n:::";
9611 let doc = markdown_to_adf(md).unwrap();
9612 assert_eq!(doc.content[0].node_type, "decisionList");
9613 let items = doc.content[0].content.as_ref().unwrap();
9614 assert_eq!(items.len(), 2);
9615 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DECIDED");
9616 }
9617
9618 #[test]
9621 fn decision_item_content_is_inline_not_paragraph() {
9622 let md = ":::decisions\n- <> Use Rust\n:::";
9623 let doc = markdown_to_adf(md).unwrap();
9624 let items = doc.content[0].content.as_ref().unwrap();
9625 let first_child = &items[0].content.as_ref().unwrap()[0];
9626 assert_eq!(
9627 first_child.node_type, "text",
9628 "decisionItem must contain inline nodes directly, not a paragraph wrapper"
9629 );
9630 assert_eq!(first_child.text.as_deref(), Some("Use Rust"));
9631 }
9632
9633 #[test]
9634 fn adf_decisions_to_markdown() {
9635 let doc = AdfDocument {
9636 version: 1,
9637 doc_type: "doc".to_string(),
9638 content: vec![AdfNode::decision_list(vec![AdfNode::decision_item(
9639 "DECIDED",
9640 vec![AdfNode::paragraph(vec![AdfNode::text("Use PostgreSQL")])],
9641 )])],
9642 };
9643 let md = adf_to_markdown(&doc).unwrap();
9644 assert!(md.contains(":::decisions"));
9645 assert!(md.contains("- <> Use PostgreSQL"));
9646 }
9647
9648 #[test]
9649 fn bodied_extension_container() {
9650 let md = ":::extension{type=com.forge key=my-macro}\nContent.\n:::";
9651 let doc = markdown_to_adf(md).unwrap();
9652 assert_eq!(doc.content[0].node_type, "bodiedExtension");
9653 assert_eq!(
9654 doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9655 "com.forge"
9656 );
9657 }
9658
9659 #[test]
9660 fn adf_bodied_extension_to_markdown() {
9661 let doc = AdfDocument {
9662 version: 1,
9663 doc_type: "doc".to_string(),
9664 content: vec![AdfNode::bodied_extension(
9665 "com.forge",
9666 "my-macro",
9667 vec![AdfNode::paragraph(vec![AdfNode::text("Content.")])],
9668 )],
9669 };
9670 let md = adf_to_markdown(&doc).unwrap();
9671 assert!(md.contains(":::extension{type=com.forge key=my-macro}"));
9672 assert!(md.contains("Content."));
9673 }
9674
9675 #[test]
9678 fn leaf_block_card() {
9679 let doc = markdown_to_adf("::card[https://example.com/browse/PROJ-123]").unwrap();
9680 assert_eq!(doc.content[0].node_type, "blockCard");
9681 assert_eq!(
9682 doc.content[0].attrs.as_ref().unwrap()["url"],
9683 "https://example.com/browse/PROJ-123"
9684 );
9685 }
9686
9687 #[test]
9688 fn adf_block_card_to_markdown() {
9689 let doc = AdfDocument {
9690 version: 1,
9691 doc_type: "doc".to_string(),
9692 content: vec![AdfNode::block_card("https://example.com/browse/PROJ-123")],
9693 };
9694 let md = adf_to_markdown(&doc).unwrap();
9695 assert!(md.contains("::card[https://example.com/browse/PROJ-123]"));
9696 }
9697
9698 #[test]
9699 fn round_trip_block_card() {
9700 let md = "::card[https://example.com/browse/PROJ-123]\n";
9701 let doc = markdown_to_adf(md).unwrap();
9702 let result = adf_to_markdown(&doc).unwrap();
9703 assert!(result.contains("::card[https://example.com/browse/PROJ-123]"));
9704 }
9705
9706 #[test]
9707 fn leaf_embed_card() {
9708 let doc =
9709 markdown_to_adf("::embed[https://figma.com/file/abc]{layout=wide width=80}").unwrap();
9710 assert_eq!(doc.content[0].node_type, "embedCard");
9711 let attrs = doc.content[0].attrs.as_ref().unwrap();
9712 assert_eq!(attrs["url"], "https://figma.com/file/abc");
9713 assert_eq!(attrs["layout"], "wide");
9714 assert_eq!(attrs["width"], 80.0);
9715 }
9716
9717 #[test]
9718 fn leaf_embed_card_with_original_height() {
9719 let doc = markdown_to_adf(
9720 "::embed[https://example.com]{layout=center originalHeight=732 width=100}",
9721 )
9722 .unwrap();
9723 assert_eq!(doc.content[0].node_type, "embedCard");
9724 let attrs = doc.content[0].attrs.as_ref().unwrap();
9725 assert_eq!(attrs["url"], "https://example.com");
9726 assert_eq!(attrs["layout"], "center");
9727 assert_eq!(attrs["originalHeight"], 732.0);
9728 assert_eq!(attrs["width"], 100.0);
9729 }
9730
9731 #[test]
9732 fn adf_embed_card_to_markdown() {
9733 let doc = AdfDocument {
9734 version: 1,
9735 doc_type: "doc".to_string(),
9736 content: vec![AdfNode::embed_card(
9737 "https://figma.com/file/abc",
9738 Some("wide"),
9739 None,
9740 Some(80.0),
9741 )],
9742 };
9743 let md = adf_to_markdown(&doc).unwrap();
9744 assert!(md.contains("::embed[https://figma.com/file/abc]{layout=wide width=80}"));
9745 }
9746
9747 #[test]
9748 fn adf_embed_card_original_height_to_markdown() {
9749 let doc = AdfDocument {
9750 version: 1,
9751 doc_type: "doc".to_string(),
9752 content: vec![AdfNode::embed_card(
9753 "https://example.com",
9754 Some("center"),
9755 Some(732.0),
9756 Some(100.0),
9757 )],
9758 };
9759 let md = adf_to_markdown(&doc).unwrap();
9760 assert!(
9761 md.contains("::embed[https://example.com]{layout=center originalHeight=732 width=100}"),
9762 "expected originalHeight and width in md: {md}"
9763 );
9764 }
9765
9766 #[test]
9767 fn embed_card_roundtrip_with_all_attrs() {
9768 let adf_json = r#"{"version":1,"type":"doc","content":[{
9769 "type":"embedCard",
9770 "attrs":{"layout":"center","originalHeight":732.0,"url":"https://example.com","width":100.0}
9771 }]}"#;
9772 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9773 let md = adf_to_markdown(&doc).unwrap();
9774 assert!(
9775 md.contains("originalHeight=732"),
9776 "originalHeight missing from md: {md}"
9777 );
9778 assert!(md.contains("width=100"), "width missing from md: {md}");
9779 let rt = markdown_to_adf(&md).unwrap();
9780 let attrs = rt.content[0].attrs.as_ref().unwrap();
9781 assert_eq!(attrs["originalHeight"], 732.0);
9782 assert_eq!(attrs["width"], 100.0);
9783 assert_eq!(attrs["layout"], "center");
9784 assert_eq!(attrs["url"], "https://example.com");
9785 }
9786
9787 #[test]
9788 fn embed_card_fractional_dimensions() {
9789 let doc = AdfDocument {
9790 version: 1,
9791 doc_type: "doc".to_string(),
9792 content: vec![AdfNode::embed_card(
9793 "https://example.com",
9794 Some("center"),
9795 Some(732.5),
9796 Some(99.9),
9797 )],
9798 };
9799 let md = adf_to_markdown(&doc).unwrap();
9800 assert!(
9801 md.contains("originalHeight=732.5"),
9802 "fractional originalHeight missing: {md}"
9803 );
9804 assert!(md.contains("width=99.9"), "fractional width missing: {md}");
9805 let rt = markdown_to_adf(&md).unwrap();
9806 let attrs = rt.content[0].attrs.as_ref().unwrap();
9807 assert_eq!(attrs["originalHeight"], 732.5);
9808 assert_eq!(attrs["width"], 99.9);
9809 }
9810
9811 #[test]
9812 fn embed_card_integer_width_in_json() {
9813 let adf_json = r#"{"version":1,"type":"doc","content":[{
9815 "type":"embedCard",
9816 "attrs":{"url":"https://example.com","width":100}
9817 }]}"#;
9818 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9819 let md = adf_to_markdown(&doc).unwrap();
9820 assert!(
9821 md.contains("width=100"),
9822 "integer width missing from md: {md}"
9823 );
9824 let rt = markdown_to_adf(&md).unwrap();
9825 assert_eq!(rt.content[0].attrs.as_ref().unwrap()["width"], 100.0);
9826 }
9827
9828 #[test]
9829 fn embed_card_only_original_height() {
9830 let adf_json = r#"{"version":1,"type":"doc","content":[{
9832 "type":"embedCard",
9833 "attrs":{"url":"https://example.com","originalHeight":500.0}
9834 }]}"#;
9835 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9836 let md = adf_to_markdown(&doc).unwrap();
9837 assert!(
9838 md.contains("originalHeight=500"),
9839 "originalHeight missing: {md}"
9840 );
9841 assert!(!md.contains("width="), "width should not appear: {md}");
9842 let rt = markdown_to_adf(&md).unwrap();
9843 let attrs = rt.content[0].attrs.as_ref().unwrap();
9844 assert_eq!(attrs["originalHeight"], 500.0);
9845 assert!(attrs.get("width").is_none());
9846 }
9847
9848 #[test]
9849 fn leaf_void_extension() {
9850 let doc = markdown_to_adf("::extension{type=com.atlassian.macro key=jira-chart}").unwrap();
9851 assert_eq!(doc.content[0].node_type, "extension");
9852 assert_eq!(
9853 doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9854 "com.atlassian.macro"
9855 );
9856 assert_eq!(
9857 doc.content[0].attrs.as_ref().unwrap()["extensionKey"],
9858 "jira-chart"
9859 );
9860 }
9861
9862 #[test]
9863 fn adf_void_extension_to_markdown() {
9864 let doc = AdfDocument {
9865 version: 1,
9866 doc_type: "doc".to_string(),
9867 content: vec![AdfNode::extension(
9868 "com.atlassian.macro",
9869 "jira-chart",
9870 None,
9871 )],
9872 };
9873 let md = adf_to_markdown(&doc).unwrap();
9874 assert!(md.contains("::extension{type=com.atlassian.macro key=jira-chart}"));
9875 }
9876
9877 #[test]
9880 fn bare_url_autolink() {
9881 let doc = markdown_to_adf("Visit https://example.com today").unwrap();
9882 let content = doc.content[0].content.as_ref().unwrap();
9883 assert_eq!(content[0].text.as_deref(), Some("Visit "));
9884 assert_eq!(content[1].node_type, "inlineCard");
9885 assert_eq!(
9886 content[1].attrs.as_ref().unwrap()["url"],
9887 "https://example.com"
9888 );
9889 assert_eq!(content[2].text.as_deref(), Some(" today"));
9890 }
9891
9892 #[test]
9893 fn bare_url_strips_trailing_punctuation() {
9894 let doc = markdown_to_adf("See https://example.com.").unwrap();
9895 let content = doc.content[0].content.as_ref().unwrap();
9896 assert_eq!(
9897 content[1].attrs.as_ref().unwrap()["url"],
9898 "https://example.com"
9899 );
9900 }
9901
9902 #[test]
9903 fn bare_url_round_trip() {
9904 let doc = markdown_to_adf("Visit https://example.com/path today").unwrap();
9905 let md = adf_to_markdown(&doc).unwrap();
9906 assert!(md.contains(":card[https://example.com/path]"));
9907 }
9908
9909 #[test]
9912 fn plain_text_url_round_trips_as_text() {
9913 let adf_json = r#"{
9916 "version": 1,
9917 "type": "doc",
9918 "content": [{
9919 "type": "paragraph",
9920 "content": [
9921 {"type": "text", "text": "https://example.com/some/path/to/resource"}
9922 ]
9923 }]
9924 }"#;
9925 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9926 let jfm = adf_to_markdown(&adf).unwrap();
9927 let roundtripped = markdown_to_adf(&jfm).unwrap();
9928 let content = roundtripped.content[0].content.as_ref().unwrap();
9929 assert_eq!(content.len(), 1, "should be a single node");
9930 assert_eq!(content[0].node_type, "text");
9931 assert_eq!(
9932 content[0].text.as_deref(),
9933 Some("https://example.com/some/path/to/resource")
9934 );
9935 }
9936
9937 #[test]
9938 fn url_text_with_link_mark_round_trips_as_text_node() {
9939 let adf_json = r#"{
9943 "version": 1,
9944 "type": "doc",
9945 "content": [{
9946 "type": "paragraph",
9947 "content": [{
9948 "type": "text",
9949 "text": "https://octopz.example.com",
9950 "marks": [{"type": "link", "attrs": {"href": "https://octopz.example.com/"}}]
9951 }]
9952 }]
9953 }"#;
9954 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9955 let jfm = adf_to_markdown(&adf).unwrap();
9956 let roundtripped = markdown_to_adf(&jfm).unwrap();
9957 let content = roundtripped.content[0].content.as_ref().unwrap();
9958 assert_eq!(content.len(), 1, "should be a single node");
9959 assert_eq!(content[0].node_type, "text", "must be text, not inlineCard");
9960 assert_eq!(
9961 content[0].text.as_deref(),
9962 Some("https://octopz.example.com")
9963 );
9964 let mark = &content[0].marks.as_ref().unwrap()[0];
9965 assert_eq!(mark.mark_type, "link");
9966 assert_eq!(
9967 mark.attrs.as_ref().unwrap()["href"],
9968 "https://octopz.example.com/"
9969 );
9970 }
9971
9972 #[test]
9973 fn url_text_with_exact_link_mark_round_trips() {
9974 let adf_json = r#"{
9976 "version": 1,
9977 "type": "doc",
9978 "content": [{
9979 "type": "paragraph",
9980 "content": [{
9981 "type": "text",
9982 "text": "https://example.com/path",
9983 "marks": [{"type": "link", "attrs": {"href": "https://example.com/path"}}]
9984 }]
9985 }]
9986 }"#;
9987 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9988 let jfm = adf_to_markdown(&adf).unwrap();
9989 let roundtripped = markdown_to_adf(&jfm).unwrap();
9990 let content = roundtripped.content[0].content.as_ref().unwrap();
9991 assert_eq!(content.len(), 1, "should be a single node");
9992 assert_eq!(content[0].node_type, "text");
9993 assert_eq!(content[0].text.as_deref(), Some("https://example.com/path"));
9994 let mark = &content[0].marks.as_ref().unwrap()[0];
9995 assert_eq!(mark.mark_type, "link");
9996 }
9997
9998 #[test]
9999 fn plain_text_url_amid_text_round_trips() {
10000 let adf_json = r#"{
10002 "version": 1,
10003 "type": "doc",
10004 "content": [{
10005 "type": "paragraph",
10006 "content": [
10007 {"type": "text", "text": "see https://example.com for info"}
10008 ]
10009 }]
10010 }"#;
10011 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10012 let jfm = adf_to_markdown(&adf).unwrap();
10013 let roundtripped = markdown_to_adf(&jfm).unwrap();
10014 let content = roundtripped.content[0].content.as_ref().unwrap();
10015 assert_eq!(content.len(), 1);
10016 assert_eq!(content[0].node_type, "text");
10017 assert_eq!(
10018 content[0].text.as_deref(),
10019 Some("see https://example.com for info")
10020 );
10021 }
10022
10023 #[test]
10024 fn plain_text_multiple_urls_round_trips() {
10025 let adf_json = r#"{
10026 "version": 1,
10027 "type": "doc",
10028 "content": [{
10029 "type": "paragraph",
10030 "content": [
10031 {"type": "text", "text": "http://a.com and https://b.com"}
10032 ]
10033 }]
10034 }"#;
10035 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10036 let jfm = adf_to_markdown(&adf).unwrap();
10037 let roundtripped = markdown_to_adf(&jfm).unwrap();
10038 let content = roundtripped.content[0].content.as_ref().unwrap();
10039 assert_eq!(content.len(), 1);
10040 assert_eq!(content[0].node_type, "text");
10041 assert_eq!(
10042 content[0].text.as_deref(),
10043 Some("http://a.com and https://b.com")
10044 );
10045 }
10046
10047 #[test]
10048 fn plain_text_http_prefix_no_url_unchanged() {
10049 let adf_json = r#"{
10051 "version": 1,
10052 "type": "doc",
10053 "content": [{
10054 "type": "paragraph",
10055 "content": [
10056 {"type": "text", "text": "the http header is important"}
10057 ]
10058 }]
10059 }"#;
10060 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10061 let jfm = adf_to_markdown(&adf).unwrap();
10062 let roundtripped = markdown_to_adf(&jfm).unwrap();
10063 let content = roundtripped.content[0].content.as_ref().unwrap();
10064 assert_eq!(
10065 content[0].text.as_deref(),
10066 Some("the http header is important")
10067 );
10068 }
10069
10070 #[test]
10071 fn linked_url_text_round_trips() {
10072 let adf_json = r#"{
10076 "version": 1,
10077 "type": "doc",
10078 "content": [{
10079 "type": "paragraph",
10080 "content": [{
10081 "type": "text",
10082 "text": "https://example.com",
10083 "marks": [{"type": "link", "attrs": {"href": "https://example.com"}}]
10084 }]
10085 }]
10086 }"#;
10087 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10088 let jfm = adf_to_markdown(&adf).unwrap();
10089 let roundtripped = markdown_to_adf(&jfm).unwrap();
10090 let content = roundtripped.content[0].content.as_ref().unwrap();
10091 assert_eq!(content.len(), 1);
10092 assert_eq!(content[0].node_type, "text");
10093 assert_eq!(content[0].text.as_deref(), Some("https://example.com"));
10094 let mark = &content[0].marks.as_ref().unwrap()[0];
10095 assert_eq!(mark.mark_type, "link");
10096 assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
10097 }
10098
10099 #[test]
10102 fn escape_link_brackets_unit() {
10103 assert_eq!(escape_link_brackets("hello"), "hello");
10104 assert_eq!(escape_link_brackets("["), "\\[");
10105 assert_eq!(escape_link_brackets("]"), "\\]");
10106 assert_eq!(escape_link_brackets("[PROJ-456]"), "\\[PROJ-456\\]");
10107 assert_eq!(escape_link_brackets("a[b]c"), "a\\[b\\]c");
10108 }
10109
10110 #[test]
10111 fn bracket_text_with_link_mark_escapes_brackets() {
10112 let doc = AdfDocument {
10115 version: 1,
10116 doc_type: "doc".to_string(),
10117 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10118 "[",
10119 vec![AdfMark::link("https://example.com")],
10120 )])],
10121 };
10122 let md = adf_to_markdown(&doc).unwrap();
10123 assert_eq!(md.trim(), "[\\[](https://example.com)");
10124 }
10125
10126 #[test]
10127 fn bracket_text_with_link_mark_round_trips() {
10128 let adf_json = r#"{
10131 "type": "doc",
10132 "version": 1,
10133 "content": [{
10134 "type": "paragraph",
10135 "content": [
10136 {
10137 "type": "text",
10138 "text": "[",
10139 "marks": [{"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}]
10140 },
10141 {
10142 "type": "text",
10143 "text": "PROJ-456] Fix the auth bug",
10144 "marks": [
10145 {"type": "underline"},
10146 {"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}
10147 ]
10148 }
10149 ]
10150 }]
10151 }"#;
10152 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10153 let jfm = adf_to_markdown(&adf).unwrap();
10154
10155 assert!(jfm.contains("\\["), "opening bracket should be escaped");
10157
10158 let rt = markdown_to_adf(&jfm).unwrap();
10160 let content = rt.content[0].content.as_ref().unwrap();
10161
10162 let link_nodes: Vec<_> = content
10164 .iter()
10165 .filter(|n| {
10166 n.marks
10167 .as_ref()
10168 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
10169 })
10170 .collect();
10171 assert!(
10172 !link_nodes.is_empty(),
10173 "link mark must be preserved on round-trip"
10174 );
10175
10176 let all_text: String = content.iter().filter_map(|n| n.text.as_deref()).collect();
10178 assert!(
10179 all_text.contains('['),
10180 "literal '[' must survive round-trip"
10181 );
10182 assert!(
10183 all_text.contains("PROJ-456]"),
10184 "continuation text must survive round-trip"
10185 );
10186 }
10187
10188 #[test]
10189 fn closing_bracket_in_link_text_round_trips() {
10190 let doc = AdfDocument {
10193 version: 1,
10194 doc_type: "doc".to_string(),
10195 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10196 "item]",
10197 vec![AdfMark::link("https://example.com")],
10198 )])],
10199 };
10200 let md = adf_to_markdown(&doc).unwrap();
10201 assert_eq!(md.trim(), "[item\\]](https://example.com)");
10202
10203 let rt = markdown_to_adf(&md).unwrap();
10204 let content = rt.content[0].content.as_ref().unwrap();
10205 assert_eq!(content[0].text.as_deref(), Some("item]"));
10206 assert!(content[0]
10207 .marks
10208 .as_ref()
10209 .unwrap()
10210 .iter()
10211 .any(|m| m.mark_type == "link"));
10212 }
10213
10214 #[test]
10215 fn brackets_in_link_text_round_trip() {
10216 let doc = AdfDocument {
10218 version: 1,
10219 doc_type: "doc".to_string(),
10220 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10221 "[PROJ-123]",
10222 vec![AdfMark::link("https://example.com")],
10223 )])],
10224 };
10225 let md = adf_to_markdown(&doc).unwrap();
10226 assert_eq!(md.trim(), "[\\[PROJ-123\\]](https://example.com)");
10227
10228 let rt = markdown_to_adf(&md).unwrap();
10229 let content = rt.content[0].content.as_ref().unwrap();
10230 assert_eq!(content[0].text.as_deref(), Some("[PROJ-123]"));
10231 assert!(content[0]
10232 .marks
10233 .as_ref()
10234 .unwrap()
10235 .iter()
10236 .any(|m| m.mark_type == "link"));
10237 }
10238
10239 #[test]
10240 fn plain_text_brackets_not_escaped() {
10241 let doc = AdfDocument {
10243 version: 1,
10244 doc_type: "doc".to_string(),
10245 content: vec![AdfNode::paragraph(vec![AdfNode::text(
10246 "see [PROJ-123] for details",
10247 )])],
10248 };
10249 let md = adf_to_markdown(&doc).unwrap();
10250 assert_eq!(md.trim(), "see [PROJ-123] for details");
10251 }
10252
10253 #[test]
10254 fn link_with_no_brackets_unchanged() {
10255 let doc = AdfDocument {
10257 version: 1,
10258 doc_type: "doc".to_string(),
10259 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10260 "click here",
10261 vec![AdfMark::link("https://example.com")],
10262 )])],
10263 };
10264 let md = adf_to_markdown(&doc).unwrap();
10265 assert_eq!(md.trim(), "[click here](https://example.com)");
10266 }
10267
10268 #[test]
10271 fn url_with_brackets_as_link_text_round_trips() {
10272 let href = "https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10277 let doc = AdfDocument {
10278 version: 1,
10279 doc_type: "doc".to_string(),
10280 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10281 href,
10282 vec![AdfMark::link(href)],
10283 )])],
10284 };
10285 let md = adf_to_markdown(&doc).unwrap();
10286 let rt = markdown_to_adf(&md).unwrap();
10287 let content = rt.content[0].content.as_ref().unwrap();
10288 assert_eq!(content.len(), 1);
10289 assert_eq!(content[0].node_type, "text");
10290 assert_eq!(content[0].text.as_deref(), Some(href));
10291 let mark = &content[0].marks.as_ref().unwrap()[0];
10292 assert_eq!(mark.mark_type, "link");
10293 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10294 }
10295
10296 #[test]
10297 fn url_with_brackets_embedded_in_link_text_round_trips() {
10298 let href = "https://example.com/logs?query=service%20environment%20data&from=100&to=200";
10305 let text =
10306 "See the logs: https://example.com/logs?query=service[\u{2026}]data&from=100&to=200";
10307 let doc = AdfDocument {
10308 version: 1,
10309 doc_type: "doc".to_string(),
10310 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10311 text,
10312 vec![AdfMark::link(href)],
10313 )])],
10314 };
10315 let md = adf_to_markdown(&doc).unwrap();
10316 let rt = markdown_to_adf(&md).unwrap();
10317 let content = rt.content[0].content.as_ref().unwrap();
10318 assert_eq!(content.len(), 1, "content split unexpectedly: {content:?}");
10319 assert_eq!(content[0].node_type, "text");
10320 assert_eq!(content[0].text.as_deref(), Some(text));
10321 let mark = &content[0].marks.as_ref().unwrap()[0];
10322 assert_eq!(mark.mark_type, "link");
10323 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10324 }
10325
10326 #[test]
10327 fn url_with_brackets_plain_text_round_trips() {
10328 let text =
10331 "See the dashboard: https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10332 let doc = AdfDocument {
10333 version: 1,
10334 doc_type: "doc".to_string(),
10335 content: vec![AdfNode::paragraph(vec![AdfNode::text(text)])],
10336 };
10337 let md = adf_to_markdown(&doc).unwrap();
10338 let rt = markdown_to_adf(&md).unwrap();
10339 let content = rt.content[0].content.as_ref().unwrap();
10340 assert_eq!(content.len(), 1);
10341 assert_eq!(content[0].node_type, "text");
10342 assert_eq!(content[0].text.as_deref(), Some(text));
10343 assert!(content[0].marks.is_none());
10344 }
10345
10346 #[test]
10347 fn url_with_link_mark_embedded_no_brackets_round_trips() {
10348 let href = "https://example.com/";
10351 let text = "See https://example.com/ for more";
10352 let doc = AdfDocument {
10353 version: 1,
10354 doc_type: "doc".to_string(),
10355 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10356 text,
10357 vec![AdfMark::link(href)],
10358 )])],
10359 };
10360 let md = adf_to_markdown(&doc).unwrap();
10361 let rt = markdown_to_adf(&md).unwrap();
10362 let content = rt.content[0].content.as_ref().unwrap();
10363 assert_eq!(content.len(), 1);
10364 assert_eq!(content[0].node_type, "text");
10365 assert_eq!(content[0].text.as_deref(), Some(text));
10366 let mark = &content[0].marks.as_ref().unwrap()[0];
10367 assert_eq!(mark.mark_type, "link");
10368 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10369 }
10370
10371 #[test]
10372 fn nested_brackets_in_link_text_round_trip() {
10373 let href = "https://x.com";
10376 let text = "foo [a[b]c] bar";
10377 let doc = AdfDocument {
10378 version: 1,
10379 doc_type: "doc".to_string(),
10380 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10381 text,
10382 vec![AdfMark::link(href)],
10383 )])],
10384 };
10385 let md = adf_to_markdown(&doc).unwrap();
10386 let rt = markdown_to_adf(&md).unwrap();
10387 let content = rt.content[0].content.as_ref().unwrap();
10388 assert_eq!(content.len(), 1);
10389 assert_eq!(content[0].node_type, "text");
10390 assert_eq!(content[0].text.as_deref(), Some(text));
10391 }
10392
10393 #[test]
10394 fn bracket_url_bracket_in_link_text_round_trips() {
10395 let href = "https://y.com";
10401 let text = "[see https://x.com/a[0]=1 here]";
10402 let doc = AdfDocument {
10403 version: 1,
10404 doc_type: "doc".to_string(),
10405 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10406 text,
10407 vec![AdfMark::link(href)],
10408 )])],
10409 };
10410 let md = adf_to_markdown(&doc).unwrap();
10411 let rt = markdown_to_adf(&md).unwrap();
10412 let content = rt.content[0].content.as_ref().unwrap();
10413 assert_eq!(content.len(), 1);
10414 assert_eq!(content[0].node_type, "text");
10415 assert_eq!(content[0].text.as_deref(), Some(text));
10416 let mark = &content[0].marks.as_ref().unwrap()[0];
10417 assert_eq!(mark.mark_type, "link");
10418 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10419 }
10420
10421 #[test]
10422 fn escape_bare_urls_applied_inside_link_text() {
10423 let doc = AdfDocument {
10429 version: 1,
10430 doc_type: "doc".to_string(),
10431 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10432 "See https://example.com/",
10433 vec![AdfMark::link("https://target.example.com/")],
10434 )])],
10435 };
10436 let md = adf_to_markdown(&doc).unwrap();
10437 assert!(
10438 md.contains(r"\https://example.com/"),
10439 "bare URL inside link text must be escaped, got: {md}"
10440 );
10441 }
10442
10443 #[test]
10444 fn inline_card_still_round_trips() {
10445 let adf_json = r#"{
10448 "version": 1,
10449 "type": "doc",
10450 "content": [{
10451 "type": "paragraph",
10452 "content": [
10453 {"type": "inlineCard", "attrs": {"url": "https://example.com/page"}}
10454 ]
10455 }]
10456 }"#;
10457 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10458 let jfm = adf_to_markdown(&adf).unwrap();
10459 assert!(jfm.contains(":card[https://example.com/page]"));
10460 let roundtripped = markdown_to_adf(&jfm).unwrap();
10461 let content = roundtripped.content[0].content.as_ref().unwrap();
10462 assert_eq!(content[0].node_type, "inlineCard");
10463 assert_eq!(
10464 content[0].attrs.as_ref().unwrap()["url"],
10465 "https://example.com/page"
10466 );
10467 }
10468
10469 #[test]
10472 fn url_safe_in_bracket_content_balanced() {
10473 assert!(url_safe_in_bracket_content("https://example.com"));
10475 assert!(url_safe_in_bracket_content("https://example.com/[id]"));
10476 assert!(url_safe_in_bracket_content("a[b[c]d]e"));
10477 assert!(url_safe_in_bracket_content(""));
10478 }
10479
10480 #[test]
10481 fn url_safe_in_bracket_content_unbalanced() {
10482 assert!(!url_safe_in_bracket_content("a]b"));
10484 assert!(!url_safe_in_bracket_content("https://example.com/path]end"));
10485 assert!(!url_safe_in_bracket_content("a\nb"));
10487 }
10488
10489 #[test]
10490 fn inline_card_url_with_closing_bracket_round_trip() {
10491 let adf_json = r#"{
10496 "version": 1,
10497 "type": "doc",
10498 "content": [{
10499 "type": "paragraph",
10500 "content": [
10501 {"type": "text", "text": "See: "},
10502 {"type": "inlineCard", "attrs": {"url": "https://example.com/path]end/?q=1"}}
10503 ]
10504 }]
10505 }"#;
10506 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10507 let jfm = adf_to_markdown(&adf).unwrap();
10508 assert!(
10509 jfm.contains(r#":card[]{url="https://example.com/path]end/?q=1"}"#),
10510 "expected attr-form for URL with `]`, got: {jfm}"
10511 );
10512 let rt = markdown_to_adf(&jfm).unwrap();
10513 let content = rt.content[0].content.as_ref().unwrap();
10514 assert_eq!(content.len(), 2, "expected 2 inline nodes, got {content:?}");
10515 assert_eq!(content[0].node_type, "text");
10516 assert_eq!(content[0].text.as_deref(), Some("See: "));
10517 assert_eq!(content[1].node_type, "inlineCard");
10518 assert_eq!(
10519 content[1].attrs.as_ref().unwrap()["url"],
10520 "https://example.com/path]end/?q=1"
10521 );
10522 }
10523
10524 #[test]
10525 fn inline_card_url_with_closing_bracket_preserves_local_id() {
10526 let adf_json = r#"{
10528 "version": 1,
10529 "type": "doc",
10530 "content": [{
10531 "type": "paragraph",
10532 "content": [
10533 {"type": "inlineCard", "attrs": {
10534 "url": "https://example.com/a]b",
10535 "localId": "c-77"
10536 }}
10537 ]
10538 }]
10539 }"#;
10540 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10541 let jfm = adf_to_markdown(&adf).unwrap();
10542 assert!(
10543 jfm.contains(r#"url="https://example.com/a]b""#),
10544 "jfm: {jfm}"
10545 );
10546 assert!(jfm.contains("localId=c-77"), "jfm: {jfm}");
10547 let rt = markdown_to_adf(&jfm).unwrap();
10548 let card = &rt.content[0].content.as_ref().unwrap()[0];
10549 assert_eq!(card.node_type, "inlineCard");
10550 assert_eq!(
10551 card.attrs.as_ref().unwrap()["url"],
10552 "https://example.com/a]b"
10553 );
10554 assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-77");
10555 }
10556
10557 #[test]
10558 fn block_card_url_with_closing_bracket_round_trip() {
10559 let adf_json = r#"{
10561 "version": 1,
10562 "type": "doc",
10563 "content": [
10564 {"type": "blockCard", "attrs": {"url": "https://example.com/path]end"}}
10565 ]
10566 }"#;
10567 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10568 let jfm = adf_to_markdown(&adf).unwrap();
10569 assert!(
10570 jfm.contains(r#"::card[]{url="https://example.com/path]end"}"#),
10571 "expected attr-form for blockCard with `]`, got: {jfm}"
10572 );
10573 let rt = markdown_to_adf(&jfm).unwrap();
10574 assert_eq!(rt.content[0].node_type, "blockCard");
10575 assert_eq!(
10576 rt.content[0].attrs.as_ref().unwrap()["url"],
10577 "https://example.com/path]end"
10578 );
10579 }
10580
10581 #[test]
10582 fn block_card_attr_form_parses_without_renderer() {
10583 let doc = markdown_to_adf(r#"::card[]{url="https://example.com/a"}"#).unwrap();
10587 assert_eq!(doc.content[0].node_type, "blockCard");
10588 assert_eq!(
10589 doc.content[0].attrs.as_ref().unwrap()["url"],
10590 "https://example.com/a"
10591 );
10592 }
10593
10594 #[test]
10595 fn block_card_attr_form_url_overrides_content() {
10596 let doc =
10600 markdown_to_adf(r#"::card[https://old.example.com]{url="https://new.example.com"}"#)
10601 .unwrap();
10602 assert_eq!(doc.content[0].node_type, "blockCard");
10603 assert_eq!(
10604 doc.content[0].attrs.as_ref().unwrap()["url"],
10605 "https://new.example.com"
10606 );
10607 }
10608
10609 #[test]
10610 fn block_card_attr_form_with_layout_and_width() {
10611 let doc =
10614 markdown_to_adf(r#"::card[]{url="https://example.com/a]b" layout=wide width=80}"#)
10615 .unwrap();
10616 let attrs = doc.content[0].attrs.as_ref().unwrap();
10617 assert_eq!(attrs["url"], "https://example.com/a]b");
10618 assert_eq!(attrs["layout"], "wide");
10619 assert_eq!(attrs["width"], 80);
10620 }
10621
10622 #[test]
10623 fn inline_card_issue_553_reproducer() {
10624 let adf_json = r#"{
10628 "version": 1,
10629 "type": "doc",
10630 "content": [{
10631 "type": "paragraph",
10632 "content": [
10633 {"type": "text", "text": "See the related page: "},
10634 {"type": "inlineCard", "attrs": {
10635 "url": "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10636 }}
10637 ]
10638 }]
10639 }"#;
10640 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10641 let jfm = adf_to_markdown(&adf).unwrap();
10642 let rt = markdown_to_adf(&jfm).unwrap();
10643 let content = rt.content[0].content.as_ref().unwrap();
10644 assert_eq!(content.len(), 2);
10645 assert_eq!(content[0].node_type, "text");
10646 assert_eq!(content[1].node_type, "inlineCard");
10647 assert_eq!(
10648 content[1].attrs.as_ref().unwrap()["url"],
10649 "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10650 );
10651 }
10652
10653 #[test]
10654 fn inline_card_attr_form_parses_even_without_renderer() {
10655 let doc = markdown_to_adf(r#":card[]{url="https://example.com/a"}"#).unwrap();
10657 let node = &doc.content[0].content.as_ref().unwrap()[0];
10658 assert_eq!(node.node_type, "inlineCard");
10659 assert_eq!(node.attrs.as_ref().unwrap()["url"], "https://example.com/a");
10660 }
10661
10662 #[test]
10663 fn inline_card_attr_form_url_overrides_content() {
10664 let doc =
10668 markdown_to_adf(r#":card[https://old.example.com]{url="https://new.example.com"}"#)
10669 .unwrap();
10670 let node = &doc.content[0].content.as_ref().unwrap()[0];
10671 assert_eq!(node.node_type, "inlineCard");
10672 assert_eq!(
10673 node.attrs.as_ref().unwrap()["url"],
10674 "https://new.example.com"
10675 );
10676 }
10677
10678 #[test]
10681 fn url_with_link_and_underline_marks_round_trip() {
10682 let adf_json = r#"{
10686 "version": 1,
10687 "type": "doc",
10688 "content": [{
10689 "type": "paragraph",
10690 "content": [
10691 {"type": "text", "text": "See results at: "},
10692 {"type": "text",
10693 "text": "https://example.com/projects/abc123/analytics",
10694 "marks": [
10695 {"type": "link", "attrs": {"href": "https://example.com/projects/abc123/analytics"}},
10696 {"type": "underline"}
10697 ]},
10698 {"type": "text", "text": " for details."}
10699 ]
10700 }]
10701 }"#;
10702 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10703 let jfm = adf_to_markdown(&adf).unwrap();
10704 let rt = markdown_to_adf(&jfm).unwrap();
10705 let content = rt.content[0].content.as_ref().unwrap();
10706 assert_eq!(
10707 content.len(),
10708 3,
10709 "expected 3 inline nodes, got: {content:?}"
10710 );
10711 assert_eq!(content[0].node_type, "text");
10712 assert_eq!(
10713 content[1].node_type, "text",
10714 "must stay text, not inlineCard"
10715 );
10716 assert_eq!(
10717 content[1].text.as_deref(),
10718 Some("https://example.com/projects/abc123/analytics")
10719 );
10720 let mark_types: Vec<&str> = content[1]
10721 .marks
10722 .as_deref()
10723 .unwrap_or(&[])
10724 .iter()
10725 .map(|m| m.mark_type.as_str())
10726 .collect();
10727 assert_eq!(mark_types, vec!["link", "underline"], "marks lost");
10728 assert_eq!(content[2].node_type, "text");
10729 }
10730
10731 #[test]
10732 fn url_inside_bracketed_span_stays_text() {
10733 let doc = markdown_to_adf("[https://example.com]{underline}").unwrap();
10737 let node = &doc.content[0].content.as_ref().unwrap()[0];
10738 assert_eq!(node.node_type, "text");
10739 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10740 let mark_types: Vec<&str> = node
10741 .marks
10742 .as_deref()
10743 .unwrap_or(&[])
10744 .iter()
10745 .map(|m| m.mark_type.as_str())
10746 .collect();
10747 assert_eq!(mark_types, vec!["underline"]);
10748 }
10749
10750 #[test]
10751 fn url_inside_emphasis_stays_text() {
10752 for (md, mark) in [
10755 ("**https://example.com**", "strong"),
10756 ("*https://example.com*", "em"),
10757 ("~~https://example.com~~", "strike"),
10758 ] {
10759 let doc = markdown_to_adf(md).unwrap();
10760 let node = &doc.content[0].content.as_ref().unwrap()[0];
10761 assert_eq!(node.node_type, "text", "md={md}: must be text");
10762 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10763 let mark_types: Vec<&str> = node
10764 .marks
10765 .as_deref()
10766 .unwrap_or(&[])
10767 .iter()
10768 .map(|m| m.mark_type.as_str())
10769 .collect();
10770 assert_eq!(mark_types, vec![mark], "md={md}: wrong marks");
10771 }
10772 }
10773
10774 #[test]
10775 fn url_inside_span_directive_stays_text() {
10776 let doc = markdown_to_adf(":span[https://example.com]{color=red}").unwrap();
10778 let node = &doc.content[0].content.as_ref().unwrap()[0];
10779 assert_eq!(node.node_type, "text");
10780 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10781 let mark = &node.marks.as_ref().unwrap()[0];
10782 assert_eq!(mark.mark_type, "textColor");
10783 }
10784
10785 #[test]
10786 fn url_as_link_text_with_underline_after_link_mark_order() {
10787 let adf_json = r#"{
10791 "version": 1,
10792 "type": "doc",
10793 "content": [{
10794 "type": "paragraph",
10795 "content": [
10796 {"type": "text",
10797 "text": "https://example.com",
10798 "marks": [
10799 {"type": "underline"},
10800 {"type": "link", "attrs": {"href": "https://example.com"}}
10801 ]}
10802 ]
10803 }]
10804 }"#;
10805 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10806 let jfm = adf_to_markdown(&adf).unwrap();
10807 let rt = markdown_to_adf(&jfm).unwrap();
10808 let node = &rt.content[0].content.as_ref().unwrap()[0];
10809 assert_eq!(node.node_type, "text", "must stay text, got: {node:?}");
10810 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10811 let mark_types: Vec<&str> = node
10812 .marks
10813 .as_deref()
10814 .unwrap_or(&[])
10815 .iter()
10816 .map(|m| m.mark_type.as_str())
10817 .collect();
10818 assert_eq!(mark_types, vec!["underline", "link"]);
10819 }
10820
10821 #[test]
10822 fn bare_url_at_top_level_still_becomes_inline_card() {
10823 let doc = markdown_to_adf("Visit https://example.com today").unwrap();
10827 let content = doc.content[0].content.as_ref().unwrap();
10828 assert_eq!(content.len(), 3);
10829 assert_eq!(content[0].node_type, "text");
10830 assert_eq!(content[1].node_type, "inlineCard");
10831 assert_eq!(
10832 content[1].attrs.as_ref().unwrap()["url"],
10833 "https://example.com"
10834 );
10835 assert_eq!(content[2].node_type, "text");
10836 }
10837
10838 #[test]
10841 fn paragraph_align_center() {
10842 let md = "Centered text.\n{align=center}";
10843 let doc = markdown_to_adf(md).unwrap();
10844 let marks = doc.content[0].marks.as_ref().unwrap();
10845 assert_eq!(marks[0].mark_type, "alignment");
10846 assert_eq!(marks[0].attrs.as_ref().unwrap()["align"], "center");
10847 }
10848
10849 #[test]
10850 fn adf_alignment_to_markdown() {
10851 let mut node = AdfNode::paragraph(vec![AdfNode::text("Centered.")]);
10852 node.marks = Some(vec![AdfMark::alignment("center")]);
10853 let doc = AdfDocument {
10854 version: 1,
10855 doc_type: "doc".to_string(),
10856 content: vec![node],
10857 };
10858 let md = adf_to_markdown(&doc).unwrap();
10859 assert!(md.contains("Centered."));
10860 assert!(md.contains("{align=center}"));
10861 }
10862
10863 #[test]
10864 fn round_trip_alignment() {
10865 let md = "Centered.\n{align=center}\n";
10866 let doc = markdown_to_adf(md).unwrap();
10867 let result = adf_to_markdown(&doc).unwrap();
10868 assert!(result.contains("{align=center}"));
10869 }
10870
10871 #[test]
10872 fn paragraph_indent() {
10873 let md = "Indented.\n{indent=2}";
10874 let doc = markdown_to_adf(md).unwrap();
10875 let marks = doc.content[0].marks.as_ref().unwrap();
10876 assert_eq!(marks[0].mark_type, "indentation");
10877 assert_eq!(marks[0].attrs.as_ref().unwrap()["level"], 2);
10878 }
10879
10880 #[test]
10881 fn code_block_breakout() {
10882 let md = "```python\ndef f(): pass\n```\n{breakout=wide}";
10883 let doc = markdown_to_adf(md).unwrap();
10884 let marks = doc.content[0].marks.as_ref().unwrap();
10885 assert_eq!(marks[0].mark_type, "breakout");
10886 assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
10887 assert!(marks[0].attrs.as_ref().unwrap().get("width").is_none());
10888 }
10889
10890 #[test]
10891 fn code_block_breakout_with_width() {
10892 let md = "```python\ndef f(): pass\n```\n{breakout=wide breakoutWidth=1200}";
10893 let doc = markdown_to_adf(md).unwrap();
10894 let marks = doc.content[0].marks.as_ref().unwrap();
10895 assert_eq!(marks[0].mark_type, "breakout");
10896 assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
10897 assert_eq!(marks[0].attrs.as_ref().unwrap()["width"], 1200);
10898 }
10899
10900 #[test]
10901 fn adf_breakout_to_markdown() {
10902 let mut node = AdfNode::code_block(Some("python"), "pass");
10903 node.marks = Some(vec![AdfMark::breakout("wide", None)]);
10904 let doc = AdfDocument {
10905 version: 1,
10906 doc_type: "doc".to_string(),
10907 content: vec![node],
10908 };
10909 let md = adf_to_markdown(&doc).unwrap();
10910 assert!(md.contains("{breakout=wide}"));
10911 assert!(!md.contains("breakoutWidth"));
10912 }
10913
10914 #[test]
10915 fn adf_breakout_with_width_to_markdown() {
10916 let mut node = AdfNode::code_block(Some("python"), "pass");
10917 node.marks = Some(vec![AdfMark::breakout("wide", Some(1200))]);
10918 let doc = AdfDocument {
10919 version: 1,
10920 doc_type: "doc".to_string(),
10921 content: vec![node],
10922 };
10923 let md = adf_to_markdown(&doc).unwrap();
10924 assert!(md.contains("breakout=wide"));
10925 assert!(md.contains("breakoutWidth=1200"));
10926 }
10927
10928 #[test]
10929 fn breakout_width_round_trip() {
10930 let adf_json = r#"{"version":1,"type":"doc","content":[{
10931 "type":"codeBlock",
10932 "attrs":{"language":"text"},
10933 "marks":[{"type":"breakout","attrs":{"mode":"wide","width":1200}}],
10934 "content":[{"type":"text","text":"some code"}]
10935 }]}"#;
10936 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10937 let md = adf_to_markdown(&doc).unwrap();
10938 assert!(md.contains("breakout=wide"));
10939 assert!(md.contains("breakoutWidth=1200"));
10940 let round_tripped = markdown_to_adf(&md).unwrap();
10941 let marks = round_tripped.content[0].marks.as_ref().unwrap();
10942 let breakout = marks.iter().find(|m| m.mark_type == "breakout").unwrap();
10943 assert_eq!(breakout.attrs.as_ref().unwrap()["mode"], "wide");
10944 assert_eq!(breakout.attrs.as_ref().unwrap()["width"], 1200);
10945 }
10946
10947 #[test]
10950 fn image_with_layout_attrs() {
10951 let doc = markdown_to_adf("{layout=wide width=80}").unwrap();
10952 let node = &doc.content[0];
10953 assert_eq!(node.node_type, "mediaSingle");
10954 let attrs = node.attrs.as_ref().unwrap();
10955 assert_eq!(attrs["layout"], "wide");
10956 assert_eq!(attrs["width"], 80);
10957 }
10958
10959 #[test]
10960 fn adf_image_with_layout_to_markdown() {
10961 let mut node = AdfNode::media_single("url", Some("alt"));
10962 node.attrs.as_mut().unwrap()["layout"] = serde_json::json!("wide");
10963 node.attrs.as_mut().unwrap()["width"] = serde_json::json!(80);
10964 let doc = AdfDocument {
10965 version: 1,
10966 doc_type: "doc".to_string(),
10967 content: vec![node],
10968 };
10969 let md = adf_to_markdown(&doc).unwrap();
10970 assert!(md.contains("{layout=wide width=80}"));
10971 }
10972
10973 #[test]
10974 fn table_with_layout_attrs() {
10975 let md = "| H |\n| --- |\n| C |\n{layout=wide numbered}";
10976 let doc = markdown_to_adf(md).unwrap();
10977 let table = &doc.content[0];
10978 assert_eq!(table.node_type, "table");
10979 let attrs = table.attrs.as_ref().unwrap();
10980 assert_eq!(attrs["layout"], "wide");
10981 assert_eq!(attrs["isNumberColumnEnabled"], true);
10982 }
10983
10984 #[test]
10985 fn adf_table_with_attrs_to_markdown() {
10986 let mut table = AdfNode::table(vec![
10987 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
10988 AdfNode::text("H"),
10989 ])])]),
10990 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
10991 AdfNode::text("C"),
10992 ])])]),
10993 ]);
10994 table.attrs = Some(serde_json::json!({"layout": "wide", "isNumberColumnEnabled": true}));
10995 let doc = AdfDocument {
10996 version: 1,
10997 doc_type: "doc".to_string(),
10998 content: vec![table],
10999 };
11000 let md = adf_to_markdown(&doc).unwrap();
11001 assert!(md.contains("{layout=wide numbered}"));
11002 }
11003
11004 #[test]
11007 fn underline_bracketed_span() {
11008 let doc = markdown_to_adf("This is [underlined text]{underline} here.").unwrap();
11009 let content = doc.content[0].content.as_ref().unwrap();
11010 assert_eq!(content[1].text.as_deref(), Some("underlined text"));
11011 let marks = content[1].marks.as_ref().unwrap();
11012 assert_eq!(marks[0].mark_type, "underline");
11013 }
11014
11015 #[test]
11016 fn adf_underline_to_markdown() {
11017 let doc = AdfDocument {
11018 version: 1,
11019 doc_type: "doc".to_string(),
11020 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11021 "underlined",
11022 vec![AdfMark::underline()],
11023 )])],
11024 };
11025 let md = adf_to_markdown(&doc).unwrap();
11026 assert!(md.contains("[underlined]{underline}"));
11027 }
11028
11029 #[test]
11030 fn round_trip_underline() {
11031 let md = "This is [underlined text]{underline} here.\n";
11032 let doc = markdown_to_adf(md).unwrap();
11033 let result = adf_to_markdown(&doc).unwrap();
11034 assert!(result.contains("[underlined text]{underline}"));
11035 }
11036
11037 #[test]
11038 fn mark_ordering_underline_strong_preserved() {
11039 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11041 {"type":"text","text":"bold and underlined","marks":[{"type":"underline"},{"type":"strong"}]}
11042 ]}]}"#;
11043 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11044 let md = adf_to_markdown(&doc).unwrap();
11045 let round_tripped = markdown_to_adf(&md).unwrap();
11046 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11047 let mark_types: Vec<&str> = node
11048 .marks
11049 .as_ref()
11050 .unwrap()
11051 .iter()
11052 .map(|m| m.mark_type.as_str())
11053 .collect();
11054 assert_eq!(
11055 mark_types,
11056 vec!["underline", "strong"],
11057 "mark order should be preserved, got: {mark_types:?}"
11058 );
11059 }
11060
11061 #[test]
11062 fn mark_ordering_link_strong_preserved() {
11063 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11065 {"type":"text","text":"bold link","marks":[
11066 {"type":"link","attrs":{"href":"https://example.com"}},
11067 {"type":"strong"}
11068 ]}
11069 ]}]}"#;
11070 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11071 let md = adf_to_markdown(&doc).unwrap();
11072 let round_tripped = markdown_to_adf(&md).unwrap();
11073 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11074 let mark_types: Vec<&str> = node
11075 .marks
11076 .as_ref()
11077 .unwrap()
11078 .iter()
11079 .map(|m| m.mark_type.as_str())
11080 .collect();
11081 assert_eq!(
11082 mark_types,
11083 vec!["link", "strong"],
11084 "mark order should be preserved, got: {mark_types:?}"
11085 );
11086 }
11087
11088 #[test]
11089 fn mark_ordering_link_textcolor_preserved() {
11090 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11092 {"type":"text","text":"red link","marks":[
11093 {"type":"link","attrs":{"href":"https://example.com"}},
11094 {"type":"textColor","attrs":{"color":"#ff0000"}}
11095 ]}
11096 ]}]}"##;
11097 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11098 let md = adf_to_markdown(&doc).unwrap();
11099 let round_tripped = markdown_to_adf(&md).unwrap();
11100 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11101 let mark_types: Vec<&str> = node
11102 .marks
11103 .as_ref()
11104 .unwrap()
11105 .iter()
11106 .map(|m| m.mark_type.as_str())
11107 .collect();
11108 assert_eq!(
11109 mark_types,
11110 vec!["link", "textColor"],
11111 "mark order should be preserved, got: {mark_types:?}"
11112 );
11113 }
11114
11115 #[test]
11116 fn mark_ordering_link_em_preserved() {
11117 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11119 {"type":"text","text":"italic link","marks":[
11120 {"type":"link","attrs":{"href":"https://example.com"}},
11121 {"type":"em"}
11122 ]}
11123 ]}]}"#;
11124 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11125 let md = adf_to_markdown(&doc).unwrap();
11126 let round_tripped = markdown_to_adf(&md).unwrap();
11127 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11128 let mark_types: Vec<&str> = node
11129 .marks
11130 .as_ref()
11131 .unwrap()
11132 .iter()
11133 .map(|m| m.mark_type.as_str())
11134 .collect();
11135 assert_eq!(
11136 mark_types,
11137 vec!["link", "em"],
11138 "mark order should be preserved, got: {mark_types:?}"
11139 );
11140 }
11141
11142 #[test]
11143 fn mark_ordering_link_strike_preserved() {
11144 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11146 {"type":"text","text":"struck link","marks":[
11147 {"type":"link","attrs":{"href":"https://example.com"}},
11148 {"type":"strike"}
11149 ]}
11150 ]}]}"#;
11151 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11152 let md = adf_to_markdown(&doc).unwrap();
11153 let round_tripped = markdown_to_adf(&md).unwrap();
11154 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11155 let mark_types: Vec<&str> = node
11156 .marks
11157 .as_ref()
11158 .unwrap()
11159 .iter()
11160 .map(|m| m.mark_type.as_str())
11161 .collect();
11162 assert_eq!(
11163 mark_types,
11164 vec!["link", "strike"],
11165 "mark order should be preserved, got: {mark_types:?}"
11166 );
11167 }
11168
11169 #[test]
11170 fn mark_ordering_strong_link_preserved() {
11171 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11173 {"type":"text","text":"bold link","marks":[
11174 {"type":"strong"},
11175 {"type":"link","attrs":{"href":"https://example.com"}}
11176 ]}
11177 ]}]}"#;
11178 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11179 let md = adf_to_markdown(&doc).unwrap();
11180 let round_tripped = markdown_to_adf(&md).unwrap();
11181 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11182 let mark_types: Vec<&str> = node
11183 .marks
11184 .as_ref()
11185 .unwrap()
11186 .iter()
11187 .map(|m| m.mark_type.as_str())
11188 .collect();
11189 assert_eq!(
11190 mark_types,
11191 vec!["strong", "link"],
11192 "mark order should be preserved, got: {mark_types:?}"
11193 );
11194 }
11195
11196 #[test]
11197 fn mark_ordering_em_link_preserved() {
11198 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11200 {"type":"text","text":"italic link","marks":[
11201 {"type":"em"},
11202 {"type":"link","attrs":{"href":"https://example.com"}}
11203 ]}
11204 ]}]}"#;
11205 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11206 let md = adf_to_markdown(&doc).unwrap();
11207 let round_tripped = markdown_to_adf(&md).unwrap();
11208 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11209 let mark_types: Vec<&str> = node
11210 .marks
11211 .as_ref()
11212 .unwrap()
11213 .iter()
11214 .map(|m| m.mark_type.as_str())
11215 .collect();
11216 assert_eq!(
11217 mark_types,
11218 vec!["em", "link"],
11219 "mark order should be preserved, got: {mark_types:?}"
11220 );
11221 }
11222
11223 #[test]
11224 fn mark_ordering_strike_link_preserved() {
11225 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11227 {"type":"text","text":"struck link","marks":[
11228 {"type":"strike"},
11229 {"type":"link","attrs":{"href":"https://example.com"}}
11230 ]}
11231 ]}]}"#;
11232 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11233 let md = adf_to_markdown(&doc).unwrap();
11234 let round_tripped = markdown_to_adf(&md).unwrap();
11235 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11236 let mark_types: Vec<&str> = node
11237 .marks
11238 .as_ref()
11239 .unwrap()
11240 .iter()
11241 .map(|m| m.mark_type.as_str())
11242 .collect();
11243 assert_eq!(
11244 mark_types,
11245 vec!["strike", "link"],
11246 "mark order should be preserved, got: {mark_types:?}"
11247 );
11248 }
11249
11250 #[test]
11251 fn mark_ordering_underline_link_preserved() {
11252 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11254 {"type":"text","text":"click here","marks":[
11255 {"type":"underline"},
11256 {"type":"link","attrs":{"href":"https://example.com"}}
11257 ]}
11258 ]}]}"#;
11259 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11260 let md = adf_to_markdown(&doc).unwrap();
11261 let round_tripped = markdown_to_adf(&md).unwrap();
11262 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11263 let mark_types: Vec<&str> = node
11264 .marks
11265 .as_ref()
11266 .unwrap()
11267 .iter()
11268 .map(|m| m.mark_type.as_str())
11269 .collect();
11270 assert_eq!(
11271 mark_types,
11272 vec!["underline", "link"],
11273 "mark order should be preserved, got: {mark_types:?}"
11274 );
11275 }
11276
11277 #[test]
11278 fn mark_ordering_textcolor_link_preserved() {
11279 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11281 {"type":"text","text":"red link","marks":[
11282 {"type":"textColor","attrs":{"color":"#ff0000"}},
11283 {"type":"link","attrs":{"href":"https://example.com"}}
11284 ]}
11285 ]}]}"##;
11286 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11287 let md = adf_to_markdown(&doc).unwrap();
11288 let round_tripped = markdown_to_adf(&md).unwrap();
11289 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11290 let mark_types: Vec<&str> = node
11291 .marks
11292 .as_ref()
11293 .unwrap()
11294 .iter()
11295 .map(|m| m.mark_type.as_str())
11296 .collect();
11297 assert_eq!(
11298 mark_types,
11299 vec!["textColor", "link"],
11300 "mark order should be preserved, got: {mark_types:?}"
11301 );
11302 }
11303
11304 #[test]
11305 fn mark_ordering_link_underline_preserved() {
11306 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11308 {"type":"text","text":"click here","marks":[
11309 {"type":"link","attrs":{"href":"https://example.com"}},
11310 {"type":"underline"}
11311 ]}
11312 ]}]}"#;
11313 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11314 let md = adf_to_markdown(&doc).unwrap();
11315 assert!(
11317 md.contains("](https://example.com)"),
11318 "should have link: {md}"
11319 );
11320 assert!(md.contains("underline"), "should have underline: {md}");
11321 let round_tripped = markdown_to_adf(&md).unwrap();
11322 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11323 let mark_types: Vec<&str> = node
11324 .marks
11325 .as_ref()
11326 .unwrap()
11327 .iter()
11328 .map(|m| m.mark_type.as_str())
11329 .collect();
11330 assert_eq!(
11331 mark_types,
11332 vec!["link", "underline"],
11333 "mark order should be preserved, got: {mark_types:?}"
11334 );
11335 }
11336
11337 #[test]
11338 fn mark_ordering_underline_strong_link_preserved() {
11339 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11341 {"type":"text","text":"bold underlined link","marks":[
11342 {"type":"underline"},
11343 {"type":"strong"},
11344 {"type":"link","attrs":{"href":"https://example.com/page"}}
11345 ]}
11346 ]}]}"#;
11347 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11348 let md = adf_to_markdown(&doc).unwrap();
11349 let round_tripped = markdown_to_adf(&md).unwrap();
11350 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11351 let mark_types: Vec<&str> = node
11352 .marks
11353 .as_ref()
11354 .unwrap()
11355 .iter()
11356 .map(|m| m.mark_type.as_str())
11357 .collect();
11358 assert_eq!(
11359 mark_types,
11360 vec!["underline", "strong", "link"],
11361 "mark order should be preserved, got: {mark_types:?}"
11362 );
11363 }
11364
11365 #[test]
11366 fn mark_ordering_strong_underline_link_preserved() {
11367 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11369 {"type":"text","text":"bold underlined link","marks":[
11370 {"type":"strong"},
11371 {"type":"underline"},
11372 {"type":"link","attrs":{"href":"https://example.com/page"}}
11373 ]}
11374 ]}]}"#;
11375 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11376 let md = adf_to_markdown(&doc).unwrap();
11377 let round_tripped = markdown_to_adf(&md).unwrap();
11378 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11379 let mark_types: Vec<&str> = node
11380 .marks
11381 .as_ref()
11382 .unwrap()
11383 .iter()
11384 .map(|m| m.mark_type.as_str())
11385 .collect();
11386 assert_eq!(
11387 mark_types,
11388 vec!["strong", "underline", "link"],
11389 "mark order should be preserved, got: {mark_types:?}"
11390 );
11391 }
11392
11393 #[test]
11394 fn mark_ordering_underline_em_link_preserved() {
11395 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11397 {"type":"text","text":"italic underlined link","marks":[
11398 {"type":"underline"},
11399 {"type":"em"},
11400 {"type":"link","attrs":{"href":"https://example.com/page"}}
11401 ]}
11402 ]}]}"#;
11403 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11404 let md = adf_to_markdown(&doc).unwrap();
11405 let round_tripped = markdown_to_adf(&md).unwrap();
11406 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11407 let mark_types: Vec<&str> = node
11408 .marks
11409 .as_ref()
11410 .unwrap()
11411 .iter()
11412 .map(|m| m.mark_type.as_str())
11413 .collect();
11414 assert_eq!(
11415 mark_types,
11416 vec!["underline", "em", "link"],
11417 "mark order should be preserved, got: {mark_types:?}"
11418 );
11419 }
11420
11421 #[test]
11422 fn mark_ordering_underline_strike_link_preserved() {
11423 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11425 {"type":"text","text":"struck underlined link","marks":[
11426 {"type":"underline"},
11427 {"type":"strike"},
11428 {"type":"link","attrs":{"href":"https://example.com/page"}}
11429 ]}
11430 ]}]}"#;
11431 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11432 let md = adf_to_markdown(&doc).unwrap();
11433 let round_tripped = markdown_to_adf(&md).unwrap();
11434 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11435 let mark_types: Vec<&str> = node
11436 .marks
11437 .as_ref()
11438 .unwrap()
11439 .iter()
11440 .map(|m| m.mark_type.as_str())
11441 .collect();
11442 assert_eq!(
11443 mark_types,
11444 vec!["underline", "strike", "link"],
11445 "mark order should be preserved, got: {mark_types:?}"
11446 );
11447 }
11448
11449 #[test]
11450 fn mark_ordering_underline_strong_em_link_preserved() {
11451 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11453 {"type":"text","text":"all the marks","marks":[
11454 {"type":"underline"},
11455 {"type":"strong"},
11456 {"type":"em"},
11457 {"type":"link","attrs":{"href":"https://example.com/page"}}
11458 ]}
11459 ]}]}"#;
11460 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11461 let md = adf_to_markdown(&doc).unwrap();
11462 let round_tripped = markdown_to_adf(&md).unwrap();
11463 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11464 let mark_types: Vec<&str> = node
11465 .marks
11466 .as_ref()
11467 .unwrap()
11468 .iter()
11469 .map(|m| m.mark_type.as_str())
11470 .collect();
11471 assert_eq!(
11472 mark_types,
11473 vec!["underline", "strong", "em", "link"],
11474 "mark order should be preserved, got: {mark_types:?}"
11475 );
11476 }
11477
11478 #[test]
11479 fn em_strong_round_trip() {
11480 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11482 {"type":"text","text":"bold and italic","marks":[{"type":"strong"},{"type":"em"}]}
11483 ]}]}"#;
11484 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11485 let md = adf_to_markdown(&doc).unwrap();
11486 assert_eq!(md.trim(), "***bold and italic***");
11487 let round_tripped = markdown_to_adf(&md).unwrap();
11488 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11489 assert_eq!(node.text.as_deref(), Some("bold and italic"));
11490 let mark_types: Vec<&str> = node
11491 .marks
11492 .as_ref()
11493 .unwrap()
11494 .iter()
11495 .map(|m| m.mark_type.as_str())
11496 .collect();
11497 assert_eq!(
11498 mark_types,
11499 vec!["strong", "em"],
11500 "both strong and em marks should be preserved, got: {mark_types:?}"
11501 );
11502 }
11503
11504 #[test]
11505 fn em_strong_round_trip_em_first() {
11506 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11508 {"type":"text","text":"italic and bold","marks":[{"type":"em"},{"type":"strong"}]}
11509 ]}]}"#;
11510 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11511 let md = adf_to_markdown(&doc).unwrap();
11512 let round_tripped = markdown_to_adf(&md).unwrap();
11513 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11514 assert_eq!(node.text.as_deref(), Some("italic and bold"));
11515 let mark_types: Vec<&str> = node
11516 .marks
11517 .as_ref()
11518 .unwrap()
11519 .iter()
11520 .map(|m| m.mark_type.as_str())
11521 .collect();
11522 assert_eq!(
11523 mark_types,
11524 vec!["em", "strong"],
11525 "mark order [em, strong] should be preserved, got: {mark_types:?}"
11526 );
11527 }
11528
11529 fn assert_mark_order_round_trip(marks: Vec<AdfMark>, expected: &[&str]) {
11532 let doc = AdfDocument {
11533 version: 1,
11534 doc_type: "doc".to_string(),
11535 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11536 "text", marks,
11537 )])],
11538 };
11539 let md = adf_to_markdown(&doc).unwrap();
11540 let round_tripped = markdown_to_adf(&md).unwrap();
11541 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11542 let mark_types: Vec<&str> = node
11543 .marks
11544 .as_ref()
11545 .expect("round-tripped node should have marks")
11546 .iter()
11547 .map(|m| m.mark_type.as_str())
11548 .collect();
11549 assert_eq!(
11550 mark_types, expected,
11551 "marks should round-trip in order via {md:?}"
11552 );
11553 }
11554
11555 #[test]
11556 fn round_trip_em_strong_mark_order() {
11557 assert_mark_order_round_trip(vec![AdfMark::em(), AdfMark::strong()], &["em", "strong"]);
11559 assert_mark_order_round_trip(vec![AdfMark::strong(), AdfMark::em()], &["strong", "em"]);
11560 }
11561
11562 #[test]
11563 fn round_trip_strong_underline_mark_order() {
11564 assert_mark_order_round_trip(
11566 vec![AdfMark::strong(), AdfMark::underline()],
11567 &["strong", "underline"],
11568 );
11569 assert_mark_order_round_trip(
11570 vec![AdfMark::underline(), AdfMark::strong()],
11571 &["underline", "strong"],
11572 );
11573 }
11574
11575 #[test]
11576 fn round_trip_em_underline_mark_order() {
11577 assert_mark_order_round_trip(
11578 vec![AdfMark::em(), AdfMark::underline()],
11579 &["em", "underline"],
11580 );
11581 assert_mark_order_round_trip(
11582 vec![AdfMark::underline(), AdfMark::em()],
11583 &["underline", "em"],
11584 );
11585 }
11586
11587 #[test]
11588 fn round_trip_strike_strong_em_permutations() {
11589 assert_mark_order_round_trip(
11593 vec![AdfMark::strike(), AdfMark::strong(), AdfMark::em()],
11594 &["strike", "strong", "em"],
11595 );
11596 assert_mark_order_round_trip(
11597 vec![AdfMark::strike(), AdfMark::em(), AdfMark::strong()],
11598 &["strike", "em", "strong"],
11599 );
11600 assert_mark_order_round_trip(
11601 vec![AdfMark::strong(), AdfMark::strike(), AdfMark::em()],
11602 &["strong", "strike", "em"],
11603 );
11604 assert_mark_order_round_trip(
11605 vec![AdfMark::strong(), AdfMark::em(), AdfMark::strike()],
11606 &["strong", "em", "strike"],
11607 );
11608 assert_mark_order_round_trip(
11609 vec![AdfMark::em(), AdfMark::strike(), AdfMark::strong()],
11610 &["em", "strike", "strong"],
11611 );
11612 assert_mark_order_round_trip(
11613 vec![AdfMark::em(), AdfMark::strong(), AdfMark::strike()],
11614 &["em", "strong", "strike"],
11615 );
11616 }
11617
11618 #[test]
11619 fn round_trip_underline_nested_with_strong_em() {
11620 assert_mark_order_round_trip(
11623 vec![AdfMark::underline(), AdfMark::strong(), AdfMark::em()],
11624 &["underline", "strong", "em"],
11625 );
11626 assert_mark_order_round_trip(
11627 vec![AdfMark::strong(), AdfMark::underline(), AdfMark::em()],
11628 &["strong", "underline", "em"],
11629 );
11630 assert_mark_order_round_trip(
11631 vec![AdfMark::strong(), AdfMark::em(), AdfMark::underline()],
11632 &["strong", "em", "underline"],
11633 );
11634 }
11635
11636 #[test]
11637 fn round_trip_span_attr_order_preserved() {
11638 assert_mark_order_round_trip(
11642 vec![
11643 AdfMark::background_color("#ffff00"),
11644 AdfMark::text_color("#ff0000"),
11645 ],
11646 &["backgroundColor", "textColor"],
11647 );
11648 assert_mark_order_round_trip(
11649 vec![AdfMark::subsup("sub"), AdfMark::text_color("#ff0000")],
11650 &["subsup", "textColor"],
11651 );
11652 assert_mark_order_round_trip(
11653 vec![
11654 AdfMark::text_color("#ff0000"),
11655 AdfMark::background_color("#ffff00"),
11656 ],
11657 &["textColor", "backgroundColor"],
11658 );
11659 }
11660
11661 #[test]
11662 fn round_trip_annotation_before_underline() {
11663 assert_mark_order_round_trip(
11667 vec![
11668 AdfMark::annotation("ann-1", "inlineComment"),
11669 AdfMark::underline(),
11670 ],
11671 &["annotation", "underline"],
11672 );
11673 assert_mark_order_round_trip(
11674 vec![
11675 AdfMark::annotation("ann-1", "inlineComment"),
11676 AdfMark::underline(),
11677 AdfMark::annotation("ann-2", "inlineComment"),
11678 ],
11679 &["annotation", "underline", "annotation"],
11680 );
11681 }
11682
11683 #[test]
11684 fn round_trip_em_content_with_underscores() {
11685 let doc = AdfDocument {
11690 version: 1,
11691 doc_type: "doc".to_string(),
11692 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11693 "foo _bar_ baz",
11694 vec![AdfMark::em(), AdfMark::strong()],
11695 )])],
11696 };
11697 let md = adf_to_markdown(&doc).unwrap();
11698 let round_tripped = markdown_to_adf(&md).unwrap();
11699 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11700 assert_eq!(node.text.as_deref(), Some("foo _bar_ baz"));
11701 let mark_types: Vec<&str> = node
11702 .marks
11703 .as_ref()
11704 .unwrap()
11705 .iter()
11706 .map(|m| m.mark_type.as_str())
11707 .collect();
11708 assert_eq!(mark_types, vec!["em", "strong"]);
11709 }
11710
11711 #[test]
11712 fn round_trip_link_nested_with_formatting_marks() {
11713 assert_mark_order_round_trip(
11716 vec![
11717 AdfMark::link("https://example.com"),
11718 AdfMark::strong(),
11719 AdfMark::em(),
11720 ],
11721 &["link", "strong", "em"],
11722 );
11723 assert_mark_order_round_trip(
11724 vec![
11725 AdfMark::em(),
11726 AdfMark::strong(),
11727 AdfMark::link("https://example.com"),
11728 ],
11729 &["em", "strong", "link"],
11730 );
11731 assert_mark_order_round_trip(
11732 vec![
11733 AdfMark::underline(),
11734 AdfMark::link("https://example.com"),
11735 AdfMark::strong(),
11736 ],
11737 &["underline", "link", "strong"],
11738 );
11739 }
11740
11741 fn bare_mark(mark_type: &str) -> AdfMark {
11745 AdfMark {
11746 mark_type: mark_type.to_string(),
11747 attrs: None,
11748 }
11749 }
11750
11751 #[test]
11752 fn collect_span_attr_handles_missing_attrs() {
11753 let mut attrs = Vec::new();
11758 collect_span_attr(&bare_mark("textColor"), &mut attrs);
11759 collect_span_attr(&bare_mark("backgroundColor"), &mut attrs);
11760 collect_span_attr(&bare_mark("subsup"), &mut attrs);
11761 collect_span_attr(&bare_mark("link"), &mut attrs);
11762 assert!(attrs.is_empty(), "got: {attrs:?}");
11763 }
11764
11765 #[test]
11766 fn collect_bracketed_attr_handles_missing_attrs() {
11767 let mut attrs = Vec::new();
11770 collect_bracketed_attr(&bare_mark("annotation"), &mut attrs);
11771 collect_bracketed_attr(&bare_mark("strong"), &mut attrs);
11772 assert!(attrs.is_empty(), "got: {attrs:?}");
11773 }
11774
11775 #[test]
11776 fn collect_bracketed_attr_handles_annotation_without_id() {
11777 let mark = AdfMark {
11781 mark_type: "annotation".to_string(),
11782 attrs: Some(serde_json::json!({})),
11783 };
11784 let mut attrs = Vec::new();
11785 collect_bracketed_attr(&mark, &mut attrs);
11786 assert!(attrs.is_empty(), "got: {attrs:?}");
11787 }
11788
11789 #[test]
11790 fn span_attr_order_rejects_unknown_types() {
11791 assert_eq!(span_attr_order("textColor"), 0);
11795 assert_eq!(span_attr_order("backgroundColor"), 1);
11796 assert_eq!(span_attr_order("subsup"), 2);
11797 assert_eq!(span_attr_order("strong"), u8::MAX);
11798 assert!(!span_run_is_canonical(&[bare_mark("strong")]));
11799 }
11800
11801 #[test]
11802 fn bracketed_run_rejects_unknown_types() {
11803 assert!(bracketed_run_is_canonical(&[
11807 AdfMark::underline(),
11808 AdfMark::annotation("x", "inlineComment")
11809 ]));
11810 assert!(!bracketed_run_is_canonical(&[
11811 AdfMark::annotation("x", "inlineComment"),
11812 AdfMark::underline()
11813 ]));
11814 assert!(!bracketed_run_is_canonical(&[bare_mark("strong")]));
11815 }
11816
11817 #[test]
11818 fn render_marked_text_ignores_unknown_mark_types() {
11819 let doc = AdfDocument {
11823 version: 1,
11824 doc_type: "doc".to_string(),
11825 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11826 "hello",
11827 vec![bare_mark("futureMark"), AdfMark::strong()],
11828 )])],
11829 };
11830 let md = adf_to_markdown(&doc).unwrap();
11831 assert_eq!(md.trim(), "**hello**");
11832 let rt = markdown_to_adf(&md).unwrap();
11833 let node = &rt.content[0].content.as_ref().unwrap()[0];
11834 assert_eq!(node.text.as_deref(), Some("hello"));
11835 let mark_types: Vec<&str> = node
11836 .marks
11837 .as_ref()
11838 .unwrap()
11839 .iter()
11840 .map(|m| m.mark_type.as_str())
11841 .collect();
11842 assert_eq!(mark_types, vec!["strong"]);
11843 }
11844
11845 #[test]
11846 fn triple_asterisk_parse_to_adf() {
11847 let md = "***bold and italic***\n";
11849 let doc = markdown_to_adf(md).unwrap();
11850 let node = &doc.content[0].content.as_ref().unwrap()[0];
11851 assert_eq!(node.text.as_deref(), Some("bold and italic"));
11852 let mark_types: Vec<&str> = node
11853 .marks
11854 .as_ref()
11855 .unwrap()
11856 .iter()
11857 .map(|m| m.mark_type.as_str())
11858 .collect();
11859 assert!(
11860 mark_types.contains(&"strong") && mark_types.contains(&"em"),
11861 "***text*** should produce both strong and em marks, got: {mark_types:?}"
11862 );
11863 }
11864
11865 #[test]
11866 fn triple_asterisk_with_surrounding_text() {
11867 let md = "before ***bold italic*** after\n";
11869 let doc = markdown_to_adf(md).unwrap();
11870 let nodes = doc.content[0].content.as_ref().unwrap();
11871 assert!(
11873 nodes.len() >= 3,
11874 "expected at least 3 nodes, got {}",
11875 nodes.len()
11876 );
11877 assert_eq!(nodes[0].text.as_deref(), Some("before "));
11878 assert_eq!(nodes[1].text.as_deref(), Some("bold italic"));
11879 let mark_types: Vec<&str> = nodes[1]
11880 .marks
11881 .as_ref()
11882 .unwrap()
11883 .iter()
11884 .map(|m| m.mark_type.as_str())
11885 .collect();
11886 assert!(
11887 mark_types.contains(&"strong") && mark_types.contains(&"em"),
11888 "middle node should have strong+em, got: {mark_types:?}"
11889 );
11890 assert_eq!(nodes[2].text.as_deref(), Some(" after"));
11891 }
11892
11893 #[test]
11894 fn annotation_mark_round_trip() {
11895 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11897 {"type":"text","text":"highlighted text","marks":[
11898 {"type":"annotation","attrs":{"id":"abc123","annotationType":"inlineComment"}}
11899 ]}
11900 ]}]}"#;
11901 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11902
11903 let md = adf_to_markdown(&doc).unwrap();
11904 assert!(
11905 md.contains("annotation-id="),
11906 "JFM should contain annotation-id, got: {md}"
11907 );
11908
11909 let round_tripped = markdown_to_adf(&md).unwrap();
11910 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11911 assert_eq!(text_node.text.as_deref(), Some("highlighted text"));
11912 let marks = text_node.marks.as_ref().expect("should have marks");
11913 let ann = marks
11914 .iter()
11915 .find(|m| m.mark_type == "annotation")
11916 .expect("should have annotation mark");
11917 let attrs = ann.attrs.as_ref().unwrap();
11918 assert_eq!(attrs["id"], "abc123");
11919 assert_eq!(attrs["annotationType"], "inlineComment");
11920 }
11921
11922 #[test]
11923 fn annotation_mark_with_bold() {
11924 let doc = AdfDocument {
11926 version: 1,
11927 doc_type: "doc".to_string(),
11928 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11929 "bold comment",
11930 vec![
11931 AdfMark::strong(),
11932 AdfMark::annotation("def456", "inlineComment"),
11933 ],
11934 )])],
11935 };
11936 let md = adf_to_markdown(&doc).unwrap();
11937 let round_tripped = markdown_to_adf(&md).unwrap();
11938 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11939 let marks = text_node.marks.as_ref().expect("should have marks");
11940 assert!(
11941 marks.iter().any(|m| m.mark_type == "strong"),
11942 "should have strong mark"
11943 );
11944 assert!(
11945 marks.iter().any(|m| m.mark_type == "annotation"),
11946 "should have annotation mark"
11947 );
11948 }
11949
11950 #[test]
11951 fn annotation_and_link_marks_both_preserved() {
11952 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11954 {"type":"text","text":"HANGUL-8","marks":[
11955 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"5ca7425e-34cd-48d3-b4eb-9873ac8b20e0"}},
11956 {"type":"link","attrs":{"href":"https://zd.atlassian.net/browse/HANG-8"}}
11957 ]}
11958 ]}]}"#;
11959 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11960 let md = adf_to_markdown(&doc).unwrap();
11961 assert!(
11963 md.contains("annotation-id="),
11964 "JFM should contain annotation-id, got: {md}"
11965 );
11966 assert!(
11967 md.contains("](https://"),
11968 "JFM should contain link href, got: {md}"
11969 );
11970 let round_tripped = markdown_to_adf(&md).unwrap();
11971 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11972 let marks = text_node.marks.as_ref().expect("should have marks");
11973 assert!(
11974 marks.iter().any(|m| m.mark_type == "annotation"),
11975 "should have annotation mark, got: {:?}",
11976 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11977 );
11978 assert!(
11979 marks.iter().any(|m| m.mark_type == "link"),
11980 "should have link mark, got: {:?}",
11981 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11982 );
11983 }
11984
11985 #[test]
11986 fn annotation_and_code_marks_both_preserved() {
11987 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11989 {"type":"text","text":"some text with "},
11990 {"type":"text","text":"annotated code","marks":[
11991 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"aabbccdd-1234-5678-abcd-000000000001"}},
11992 {"type":"code"}
11993 ]},
11994 {"type":"text","text":" remaining text"}
11995 ]}]}"#;
11996 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11997 let md = adf_to_markdown(&doc).unwrap();
11998 assert!(
11999 md.contains("annotation-id="),
12000 "JFM should contain annotation-id, got: {md}"
12001 );
12002 assert!(
12003 md.contains('`'),
12004 "JFM should contain backticks for code, got: {md}"
12005 );
12006
12007 let round_tripped = markdown_to_adf(&md).unwrap();
12008 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12009 let code_node = nodes
12011 .iter()
12012 .find(|n| n.text.as_deref() == Some("annotated code"))
12013 .expect("should have 'annotated code' text node");
12014 let marks = code_node.marks.as_ref().expect("should have marks");
12015 assert!(
12016 marks.iter().any(|m| m.mark_type == "annotation"),
12017 "should have annotation mark, got: {:?}",
12018 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12019 );
12020 assert!(
12021 marks.iter().any(|m| m.mark_type == "code"),
12022 "should have code mark, got: {:?}",
12023 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12024 );
12025 let ann = marks.iter().find(|m| m.mark_type == "annotation").unwrap();
12026 let attrs = ann.attrs.as_ref().unwrap();
12027 assert_eq!(attrs["id"], "aabbccdd-1234-5678-abcd-000000000001");
12028 assert_eq!(attrs["annotationType"], "inlineComment");
12029 }
12030
12031 #[test]
12032 fn annotation_and_code_and_link_marks_all_preserved() {
12033 let doc = AdfDocument {
12035 version: 1,
12036 doc_type: "doc".to_string(),
12037 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12038 "linked code",
12039 vec![
12040 AdfMark::annotation("ann-001", "inlineComment"),
12041 AdfMark::code(),
12042 AdfMark::link("https://example.com"),
12043 ],
12044 )])],
12045 };
12046 let md = adf_to_markdown(&doc).unwrap();
12047 assert!(
12048 md.contains("annotation-id="),
12049 "JFM should contain annotation-id, got: {md}"
12050 );
12051 assert!(md.contains('`'), "JFM should contain backticks, got: {md}");
12052 assert!(
12053 md.contains("](https://example.com)"),
12054 "JFM should contain link, got: {md}"
12055 );
12056
12057 let round_tripped = markdown_to_adf(&md).unwrap();
12058 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12059 let marks = text_node.marks.as_ref().expect("should have marks");
12060 assert!(
12061 marks.iter().any(|m| m.mark_type == "annotation"),
12062 "should have annotation mark, got: {:?}",
12063 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12064 );
12065 assert!(
12066 marks.iter().any(|m| m.mark_type == "code"),
12067 "should have code mark, got: {:?}",
12068 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12069 );
12070 assert!(
12071 marks.iter().any(|m| m.mark_type == "link"),
12072 "should have link mark, got: {:?}",
12073 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12074 );
12075 }
12076
12077 #[test]
12078 fn multiple_annotations_and_code_mark_preserved() {
12079 let doc = AdfDocument {
12081 version: 1,
12082 doc_type: "doc".to_string(),
12083 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12084 "doubly annotated",
12085 vec![
12086 AdfMark::annotation("ann-aaa", "inlineComment"),
12087 AdfMark::annotation("ann-bbb", "inlineComment"),
12088 AdfMark::code(),
12089 ],
12090 )])],
12091 };
12092 let md = adf_to_markdown(&doc).unwrap();
12093 assert!(
12094 md.contains("ann-aaa"),
12095 "JFM should contain first annotation id, got: {md}"
12096 );
12097 assert!(
12098 md.contains("ann-bbb"),
12099 "JFM should contain second annotation id, got: {md}"
12100 );
12101
12102 let round_tripped = markdown_to_adf(&md).unwrap();
12103 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12104 let marks = text_node.marks.as_ref().expect("should have marks");
12105 let ann_marks: Vec<_> = marks
12106 .iter()
12107 .filter(|m| m.mark_type == "annotation")
12108 .collect();
12109 assert_eq!(
12110 ann_marks.len(),
12111 2,
12112 "should have 2 annotation marks, got: {}",
12113 ann_marks.len()
12114 );
12115 assert!(
12116 marks.iter().any(|m| m.mark_type == "code"),
12117 "should have code mark"
12118 );
12119 }
12120
12121 #[test]
12122 fn underline_and_link_marks_both_preserved() {
12123 let doc = AdfDocument {
12125 version: 1,
12126 doc_type: "doc".to_string(),
12127 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12128 "click here",
12129 vec![AdfMark::underline(), AdfMark::link("https://example.com")],
12130 )])],
12131 };
12132 let md = adf_to_markdown(&doc).unwrap();
12133 assert!(md.contains("underline"), "should have underline attr: {md}");
12134 assert!(
12135 md.contains("](https://example.com)"),
12136 "should have link: {md}"
12137 );
12138 let round_tripped = markdown_to_adf(&md).unwrap();
12139 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12140 let marks = text_node.marks.as_ref().expect("should have marks");
12141 assert!(marks.iter().any(|m| m.mark_type == "underline"));
12142 assert!(marks.iter().any(|m| m.mark_type == "link"));
12143 }
12144
12145 #[test]
12146 fn annotation_link_and_bold_all_preserved() {
12147 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12149 {"type":"text","text":"important","marks":[
12150 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"abc"}},
12151 {"type":"link","attrs":{"href":"https://example.com"}},
12152 {"type":"strong"}
12153 ]}
12154 ]}]}"#;
12155 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12156 let md = adf_to_markdown(&doc).unwrap();
12157 let round_tripped = markdown_to_adf(&md).unwrap();
12158 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12159 let marks = text_node.marks.as_ref().expect("should have marks");
12160 assert!(
12161 marks.iter().any(|m| m.mark_type == "annotation"),
12162 "should have annotation"
12163 );
12164 assert!(
12165 marks.iter().any(|m| m.mark_type == "link"),
12166 "should have link"
12167 );
12168 assert!(
12169 marks.iter().any(|m| m.mark_type == "strong"),
12170 "should have strong"
12171 );
12172 }
12173
12174 #[test]
12175 fn multiple_annotation_marks_round_trip() {
12176 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12178 {"type":"text","text":"some annotated text","marks":[
12179 {"type":"annotation","attrs":{"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","annotationType":"inlineComment"}},
12180 {"type":"annotation","attrs":{"id":"ffffffff-1111-2222-3333-444444444444","annotationType":"inlineComment"}}
12181 ]}
12182 ]}]}"#;
12183 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12184
12185 let md = adf_to_markdown(&doc).unwrap();
12186 assert!(
12187 md.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
12188 "JFM should contain first annotation id, got: {md}"
12189 );
12190 assert!(
12191 md.contains("ffffffff-1111-2222-3333-444444444444"),
12192 "JFM should contain second annotation id, got: {md}"
12193 );
12194
12195 let round_tripped = markdown_to_adf(&md).unwrap();
12196 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12197 assert_eq!(text_node.text.as_deref(), Some("some annotated text"));
12198 let marks = text_node.marks.as_ref().expect("should have marks");
12199 let annotations: Vec<_> = marks
12200 .iter()
12201 .filter(|m| m.mark_type == "annotation")
12202 .collect();
12203 assert_eq!(
12204 annotations.len(),
12205 2,
12206 "should have 2 annotation marks, got: {annotations:?}"
12207 );
12208 let ids: Vec<_> = annotations
12209 .iter()
12210 .map(|a| a.attrs.as_ref().unwrap()["id"].as_str().unwrap())
12211 .collect();
12212 assert!(ids.contains(&"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
12213 assert!(ids.contains(&"ffffffff-1111-2222-3333-444444444444"));
12214 }
12215
12216 #[test]
12217 fn three_annotation_marks_round_trip() {
12218 let doc = AdfDocument {
12220 version: 1,
12221 doc_type: "doc".to_string(),
12222 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12223 "triple annotated",
12224 vec![
12225 AdfMark::annotation("id-1", "inlineComment"),
12226 AdfMark::annotation("id-2", "inlineComment"),
12227 AdfMark::annotation("id-3", "inlineComment"),
12228 ],
12229 )])],
12230 };
12231 let md = adf_to_markdown(&doc).unwrap();
12232 let round_tripped = markdown_to_adf(&md).unwrap();
12233 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12234 let marks = text_node.marks.as_ref().expect("should have marks");
12235 let annotations: Vec<_> = marks
12236 .iter()
12237 .filter(|m| m.mark_type == "annotation")
12238 .collect();
12239 assert_eq!(
12240 annotations.len(),
12241 3,
12242 "should have 3 annotation marks, got: {annotations:?}"
12243 );
12244 }
12245
12246 #[test]
12247 fn multiple_annotations_with_bold_round_trip() {
12248 let doc = AdfDocument {
12250 version: 1,
12251 doc_type: "doc".to_string(),
12252 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12253 "bold double annotated",
12254 vec![
12255 AdfMark::strong(),
12256 AdfMark::annotation("ann-a", "inlineComment"),
12257 AdfMark::annotation("ann-b", "inlineComment"),
12258 ],
12259 )])],
12260 };
12261 let md = adf_to_markdown(&doc).unwrap();
12262 let round_tripped = markdown_to_adf(&md).unwrap();
12263 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12264 let marks = text_node.marks.as_ref().expect("should have marks");
12265 assert!(
12266 marks.iter().any(|m| m.mark_type == "strong"),
12267 "should have strong mark"
12268 );
12269 let annotations: Vec<_> = marks
12270 .iter()
12271 .filter(|m| m.mark_type == "annotation")
12272 .collect();
12273 assert_eq!(
12274 annotations.len(),
12275 2,
12276 "should have 2 annotation marks, got: {annotations:?}"
12277 );
12278 }
12279
12280 #[test]
12281 fn multiple_annotations_with_link_round_trip() {
12282 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12284 {"type":"text","text":"linked text","marks":[
12285 {"type":"annotation","attrs":{"id":"ann-x","annotationType":"inlineComment"}},
12286 {"type":"annotation","attrs":{"id":"ann-y","annotationType":"inlineComment"}},
12287 {"type":"link","attrs":{"href":"https://example.com"}}
12288 ]}
12289 ]}]}"#;
12290 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12291 let md = adf_to_markdown(&doc).unwrap();
12292 let round_tripped = markdown_to_adf(&md).unwrap();
12293 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12294 let marks = text_node.marks.as_ref().expect("should have marks");
12295 assert!(
12296 marks.iter().any(|m| m.mark_type == "link"),
12297 "should have link mark"
12298 );
12299 let annotations: Vec<_> = marks
12300 .iter()
12301 .filter(|m| m.mark_type == "annotation")
12302 .collect();
12303 assert_eq!(
12304 annotations.len(),
12305 2,
12306 "should have 2 annotation marks, got: {annotations:?}"
12307 );
12308 }
12309
12310 #[test]
12313 fn annotation_on_emoji_round_trip() {
12314 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12316 {"type":"emoji","attrs":{"id":"1f4dd","shortName":":memo:","text":"📝"},"marks":[
12317 {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12318 ]},
12319 {"type":"text","text":" annotated text","marks":[
12320 {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12321 ]}
12322 ]}]}"#;
12323 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12324 let md = adf_to_markdown(&doc).unwrap();
12325 assert!(
12326 md.contains("annotation-id="),
12327 "JFM should contain annotation-id for emoji, got: {md}"
12328 );
12329
12330 let round_tripped = markdown_to_adf(&md).unwrap();
12331 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12332
12333 let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12335 let emoji_marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12336 assert!(
12337 emoji_marks.iter().any(|m| m.mark_type == "annotation"),
12338 "emoji should have annotation mark, got: {emoji_marks:?}"
12339 );
12340 let ann = emoji_marks
12341 .iter()
12342 .find(|m| m.mark_type == "annotation")
12343 .unwrap();
12344 assert_eq!(
12345 ann.attrs.as_ref().unwrap()["id"],
12346 "ccddee11-2233-4455-aabb-ccddee112233"
12347 );
12348
12349 let text_node = nodes.iter().find(|n| n.node_type == "text").unwrap();
12351 let text_marks = text_node.marks.as_ref().expect("text should have marks");
12352 assert!(
12353 text_marks.iter().any(|m| m.mark_type == "annotation"),
12354 "text should have annotation mark"
12355 );
12356 }
12357
12358 #[test]
12359 fn annotation_on_status_round_trip() {
12360 let mut status = AdfNode::status("In Progress", "blue");
12361 status.marks = Some(vec![AdfMark::annotation("ann-status-1", "inlineComment")]);
12362
12363 let doc = AdfDocument {
12364 version: 1,
12365 doc_type: "doc".to_string(),
12366 content: vec![AdfNode::paragraph(vec![status])],
12367 };
12368 let md = adf_to_markdown(&doc).unwrap();
12369 assert!(
12370 md.contains("annotation-id="),
12371 "JFM should contain annotation-id for status, got: {md}"
12372 );
12373
12374 let round_tripped = markdown_to_adf(&md).unwrap();
12375 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12376 let status_node = nodes.iter().find(|n| n.node_type == "status").unwrap();
12377 let marks = status_node
12378 .marks
12379 .as_ref()
12380 .expect("status should have marks");
12381 assert!(
12382 marks.iter().any(|m| m.mark_type == "annotation"),
12383 "status should have annotation mark, got: {marks:?}"
12384 );
12385 }
12386
12387 #[test]
12388 fn annotation_on_date_round_trip() {
12389 let mut date = AdfNode::date("1704067200000");
12390 date.marks = Some(vec![AdfMark::annotation("ann-date-1", "inlineComment")]);
12391
12392 let doc = AdfDocument {
12393 version: 1,
12394 doc_type: "doc".to_string(),
12395 content: vec![AdfNode::paragraph(vec![date])],
12396 };
12397 let md = adf_to_markdown(&doc).unwrap();
12398 assert!(
12399 md.contains("annotation-id="),
12400 "JFM should contain annotation-id for date, got: {md}"
12401 );
12402
12403 let round_tripped = markdown_to_adf(&md).unwrap();
12404 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12405 let date_node = nodes.iter().find(|n| n.node_type == "date").unwrap();
12406 let marks = date_node.marks.as_ref().expect("date should have marks");
12407 assert!(
12408 marks.iter().any(|m| m.mark_type == "annotation"),
12409 "date should have annotation mark, got: {marks:?}"
12410 );
12411 }
12412
12413 #[test]
12414 fn annotation_on_mention_round_trip() {
12415 let mut mention = AdfNode::mention("user-123", "@Alice");
12416 mention.marks = Some(vec![AdfMark::annotation("ann-mention-1", "inlineComment")]);
12417
12418 let doc = AdfDocument {
12419 version: 1,
12420 doc_type: "doc".to_string(),
12421 content: vec![AdfNode::paragraph(vec![mention])],
12422 };
12423 let md = adf_to_markdown(&doc).unwrap();
12424 assert!(
12425 md.contains("annotation-id="),
12426 "JFM should contain annotation-id for mention, got: {md}"
12427 );
12428
12429 let round_tripped = markdown_to_adf(&md).unwrap();
12430 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12431 let mention_node = nodes.iter().find(|n| n.node_type == "mention").unwrap();
12432 let marks = mention_node
12433 .marks
12434 .as_ref()
12435 .expect("mention should have marks");
12436 assert!(
12437 marks.iter().any(|m| m.mark_type == "annotation"),
12438 "mention should have annotation mark, got: {marks:?}"
12439 );
12440 }
12441
12442 #[test]
12443 fn annotation_on_inline_card_round_trip() {
12444 let mut card = AdfNode::inline_card("https://example.com");
12445 card.marks = Some(vec![AdfMark::annotation("ann-card-1", "inlineComment")]);
12446
12447 let doc = AdfDocument {
12448 version: 1,
12449 doc_type: "doc".to_string(),
12450 content: vec![AdfNode::paragraph(vec![card])],
12451 };
12452 let md = adf_to_markdown(&doc).unwrap();
12453 assert!(
12454 md.contains("annotation-id="),
12455 "JFM should contain annotation-id for inlineCard, got: {md}"
12456 );
12457
12458 let round_tripped = markdown_to_adf(&md).unwrap();
12459 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12460 let card_node = nodes.iter().find(|n| n.node_type == "inlineCard").unwrap();
12461 let marks = card_node
12462 .marks
12463 .as_ref()
12464 .expect("inlineCard should have marks");
12465 assert!(
12466 marks.iter().any(|m| m.mark_type == "annotation"),
12467 "inlineCard should have annotation mark, got: {marks:?}"
12468 );
12469 }
12470
12471 #[test]
12472 fn annotation_on_placeholder_round_trip() {
12473 let mut placeholder = AdfNode::placeholder("Enter text here");
12474 placeholder.marks = Some(vec![AdfMark::annotation("ann-ph-1", "inlineComment")]);
12475
12476 let doc = AdfDocument {
12477 version: 1,
12478 doc_type: "doc".to_string(),
12479 content: vec![AdfNode::paragraph(vec![placeholder])],
12480 };
12481 let md = adf_to_markdown(&doc).unwrap();
12482 assert!(
12483 md.contains("annotation-id="),
12484 "JFM should contain annotation-id for placeholder, got: {md}"
12485 );
12486
12487 let round_tripped = markdown_to_adf(&md).unwrap();
12488 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12489 let ph_node = nodes.iter().find(|n| n.node_type == "placeholder").unwrap();
12490 let marks = ph_node
12491 .marks
12492 .as_ref()
12493 .expect("placeholder should have marks");
12494 assert!(
12495 marks.iter().any(|m| m.mark_type == "annotation"),
12496 "placeholder should have annotation mark, got: {marks:?}"
12497 );
12498 }
12499
12500 #[test]
12501 fn multiple_annotations_on_emoji_round_trip() {
12502 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12504 {"type":"emoji","attrs":{"shortName":":fire:","text":"🔥"},"marks":[
12505 {"type":"annotation","attrs":{"id":"ann-1","annotationType":"inlineComment"}},
12506 {"type":"annotation","attrs":{"id":"ann-2","annotationType":"inlineComment"}}
12507 ]}
12508 ]}]}"#;
12509 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12510 let md = adf_to_markdown(&doc).unwrap();
12511
12512 let round_tripped = markdown_to_adf(&md).unwrap();
12513 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12514 let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12515 let marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12516 let annotations: Vec<_> = marks
12517 .iter()
12518 .filter(|m| m.mark_type == "annotation")
12519 .collect();
12520 assert_eq!(
12521 annotations.len(),
12522 2,
12523 "emoji should have 2 annotation marks, got: {annotations:?}"
12524 );
12525 }
12526
12527 #[test]
12528 fn emoji_without_annotation_unchanged() {
12529 let doc = AdfDocument {
12531 version: 1,
12532 doc_type: "doc".to_string(),
12533 content: vec![AdfNode::paragraph(vec![AdfNode::emoji(":fire:")])],
12534 };
12535 let md = adf_to_markdown(&doc).unwrap();
12536 assert!(
12538 !md.contains('['),
12539 "emoji without annotation should not be wrapped in brackets, got: {md}"
12540 );
12541 assert!(md.contains(":fire:"));
12542 }
12543
12544 #[test]
12547 fn status_directive() {
12548 let doc = markdown_to_adf("The ticket is :status[In Progress]{color=blue}.").unwrap();
12549 let content = doc.content[0].content.as_ref().unwrap();
12550 assert_eq!(content[1].node_type, "status");
12551 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "In Progress");
12552 assert_eq!(content[1].attrs.as_ref().unwrap()["color"], "blue");
12553 }
12554
12555 #[test]
12556 fn adf_status_to_markdown() {
12557 let doc = AdfDocument {
12558 version: 1,
12559 doc_type: "doc".to_string(),
12560 content: vec![AdfNode::paragraph(vec![AdfNode::status("Done", "green")])],
12561 };
12562 let md = adf_to_markdown(&doc).unwrap();
12563 assert!(md.contains(":status[Done]{color=green}"));
12564 }
12565
12566 #[test]
12567 fn round_trip_status() {
12568 let md = "The ticket is :status[In Progress]{color=blue}.\n";
12569 let doc = markdown_to_adf(md).unwrap();
12570 let result = adf_to_markdown(&doc).unwrap();
12571 assert!(result.contains(":status[In Progress]{color=blue}"));
12572 }
12573
12574 #[test]
12575 fn status_with_style_and_localid_roundtrips() {
12576 let adf = AdfDocument {
12577 version: 1,
12578 doc_type: "doc".to_string(),
12579 content: vec![AdfNode::paragraph(vec![{
12580 let mut node = AdfNode::status("open", "green");
12581 node.attrs.as_mut().unwrap()["style"] =
12582 serde_json::Value::String("bold".to_string());
12583 node.attrs.as_mut().unwrap()["localId"] =
12584 serde_json::Value::String("d2205ca5-84b9-4950-a730-bfe550fc146b".to_string());
12585 node
12586 }])],
12587 };
12588
12589 let md = adf_to_markdown(&adf).unwrap();
12590 assert!(
12591 md.contains("style=bold"),
12592 "Markdown should contain style attr: {md}"
12593 );
12594 assert!(
12595 md.contains("localId=d2205ca5"),
12596 "Markdown should contain localId attr: {md}"
12597 );
12598
12599 let rt = markdown_to_adf(&md).unwrap();
12600 let status = &rt.content[0].content.as_ref().unwrap()[0];
12601 let attrs = status.attrs.as_ref().unwrap();
12602 assert_eq!(attrs["text"], "open");
12603 assert_eq!(attrs["color"], "green");
12604 assert_eq!(attrs["style"], "bold");
12605 assert_eq!(
12606 attrs["localId"], "d2205ca5-84b9-4950-a730-bfe550fc146b",
12607 "localId should be preserved, got: {}",
12608 attrs["localId"]
12609 );
12610 }
12611
12612 #[test]
12613 fn status_without_style_still_works() {
12614 let md = ":status[Done]{color=green}\n";
12615 let doc = markdown_to_adf(md).unwrap();
12616 let status = &doc.content[0].content.as_ref().unwrap()[0];
12617 let attrs = status.attrs.as_ref().unwrap();
12618 assert_eq!(attrs["text"], "Done");
12619 assert_eq!(attrs["color"], "green");
12620 assert!(
12622 attrs.get("style").is_none() || attrs["style"].is_null(),
12623 "style should not be set when not provided"
12624 );
12625 }
12626
12627 #[test]
12628 fn strip_local_ids_removes_localid_from_status() {
12629 let adf = AdfDocument {
12630 version: 1,
12631 doc_type: "doc".to_string(),
12632 content: vec![AdfNode::paragraph(vec![{
12633 let mut node = AdfNode::status("open", "green");
12634 node.attrs.as_mut().unwrap()["localId"] =
12635 serde_json::Value::String("real-uuid-here".to_string());
12636 node
12637 }])],
12638 };
12639 let opts = RenderOptions {
12640 strip_local_ids: true,
12641 };
12642 let md = adf_to_markdown_with_options(&adf, &opts).unwrap();
12643 assert!(
12644 !md.contains("localId"),
12645 "localId should be stripped, got: {md}"
12646 );
12647 assert!(md.contains("color=green"), "color should be preserved");
12648 }
12649
12650 #[test]
12651 fn strip_local_ids_removes_localid_from_table() {
12652 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"}]}]}]}]}]}"#;
12653 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12654 let opts = RenderOptions {
12655 strip_local_ids: true,
12656 };
12657 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12658 assert!(
12659 !md.contains("localId"),
12660 "localId should be stripped from table, got: {md}"
12661 );
12662 assert!(md.contains("layout=default"), "layout should be preserved");
12663 }
12664
12665 #[test]
12666 fn default_options_preserve_localid() {
12667 let adf = AdfDocument {
12668 version: 1,
12669 doc_type: "doc".to_string(),
12670 content: vec![AdfNode::paragraph(vec![{
12671 let mut node = AdfNode::status("open", "green");
12672 node.attrs.as_mut().unwrap()["localId"] =
12673 serde_json::Value::String("real-uuid-here".to_string());
12674 node
12675 }])],
12676 };
12677 let md = adf_to_markdown(&adf).unwrap();
12678 assert!(
12679 md.contains("localId=real-uuid-here"),
12680 "Default should preserve localId, got: {md}"
12681 );
12682 }
12683
12684 #[test]
12685 fn mention_localid_roundtrip() {
12686 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12687 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12688 let md = adf_to_markdown(&doc).unwrap();
12689 assert!(
12690 md.contains("localId=m-001"),
12691 "mention should have localId in md: {md}"
12692 );
12693 let rt = markdown_to_adf(&md).unwrap();
12694 let mention = &rt.content[0].content.as_ref().unwrap()[0];
12695 assert_eq!(mention.attrs.as_ref().unwrap()["localId"], "m-001");
12696 }
12697
12698 #[test]
12699 fn date_localid_roundtrip() {
12700 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
12701 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12702 let md = adf_to_markdown(&doc).unwrap();
12703 assert!(
12704 md.contains("localId=d-001"),
12705 "date should have localId in md: {md}"
12706 );
12707 let rt = markdown_to_adf(&md).unwrap();
12708 let date = &rt.content[0].content.as_ref().unwrap()[0];
12709 assert_eq!(date.attrs.as_ref().unwrap()["localId"], "d-001");
12710 }
12711
12712 #[test]
12713 fn emoji_localid_roundtrip() {
12714 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"emoji","attrs":{"shortName":":smile:","localId":"e-001"}}]}]}"#;
12715 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12716 let md = adf_to_markdown(&doc).unwrap();
12717 assert!(
12718 md.contains("localId=e-001"),
12719 "emoji should have localId in md: {md}"
12720 );
12721 let rt = markdown_to_adf(&md).unwrap();
12722 let emoji = &rt.content[0].content.as_ref().unwrap()[0];
12723 assert_eq!(emoji.attrs.as_ref().unwrap()["localId"], "e-001");
12724 }
12725
12726 #[test]
12727 fn inline_card_localid_roundtrip() {
12728 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"inlineCard","attrs":{"url":"https://example.com","localId":"c-001"}}]}]}"#;
12729 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12730 let md = adf_to_markdown(&doc).unwrap();
12731 assert!(
12732 md.contains("localId=c-001"),
12733 "inlineCard should have localId in md: {md}"
12734 );
12735 let rt = markdown_to_adf(&md).unwrap();
12736 let card = &rt.content[0].content.as_ref().unwrap()[0];
12737 assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-001");
12738 }
12739
12740 #[test]
12741 fn strip_local_ids_removes_from_mention() {
12742 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12743 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12744 let opts = RenderOptions {
12745 strip_local_ids: true,
12746 };
12747 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12748 assert!(
12749 !md.contains("localId"),
12750 "localId should be stripped from mention: {md}"
12751 );
12752 assert!(md.contains("id=user123"), "other attrs should be preserved");
12753 }
12754
12755 #[test]
12756 fn strip_local_ids_removes_from_date() {
12757 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
12758 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12759 let opts = RenderOptions {
12760 strip_local_ids: true,
12761 };
12762 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12763 assert!(
12764 !md.contains("localId"),
12765 "localId should be stripped from date: {md}"
12766 );
12767 }
12768
12769 #[test]
12770 fn strip_local_ids_removes_from_block_attrs() {
12771 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"hello"}]}]}"#;
12772 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12773 let opts = RenderOptions {
12774 strip_local_ids: true,
12775 };
12776 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12777 assert!(
12778 !md.contains("localId"),
12779 "localId should be stripped from block attrs: {md}"
12780 );
12781 }
12782
12783 #[test]
12784 fn table_cell_localid_roundtrip() {
12785 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"}]}]}]}]}]}"#;
12786 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12787 let md = adf_to_markdown(&doc).unwrap();
12788 assert!(
12789 md.contains("localId=tc-001"),
12790 "tableCell should have localId in md: {md}"
12791 );
12792 let rt = markdown_to_adf(&md).unwrap();
12793 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12794 .content
12795 .as_ref()
12796 .unwrap()[0];
12797 assert_eq!(
12798 cell.attrs.as_ref().unwrap()["localId"],
12799 "tc-001",
12800 "tableCell localId should round-trip"
12801 );
12802 }
12803
12804 #[test]
12805 fn table_cell_border_mark_roundtrip() {
12806 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"}]}]}]}]}]}"##;
12807 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12808 let md = adf_to_markdown(&doc).unwrap();
12809 assert!(
12810 md.contains("border-color=#ff000033"),
12811 "tableCell should have border-color in md: {md}"
12812 );
12813 assert!(
12814 md.contains("border-size=2"),
12815 "tableCell should have border-size in md: {md}"
12816 );
12817 let rt = markdown_to_adf(&md).unwrap();
12818 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12819 .content
12820 .as_ref()
12821 .unwrap()[0];
12822 let marks = cell.marks.as_ref().expect("tableCell should have marks");
12823 assert_eq!(marks.len(), 1);
12824 assert_eq!(marks[0].mark_type, "border");
12825 let attrs = marks[0].attrs.as_ref().unwrap();
12826 assert_eq!(attrs["color"], "#ff000033");
12827 assert_eq!(attrs["size"], 2);
12828 }
12829
12830 #[test]
12831 fn table_header_border_mark_roundtrip() {
12832 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"}]}]}]}]}]}"##;
12833 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12834 let md = adf_to_markdown(&doc).unwrap();
12835 assert!(md.contains("border-color=#0000ff"), "md: {md}");
12836 assert!(md.contains("border-size=3"), "md: {md}");
12837 let rt = markdown_to_adf(&md).unwrap();
12838 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12839 .content
12840 .as_ref()
12841 .unwrap()[0];
12842 assert_eq!(cell.node_type, "tableHeader");
12843 let marks = cell.marks.as_ref().expect("tableHeader should have marks");
12844 assert_eq!(marks[0].mark_type, "border");
12845 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#0000ff");
12846 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12847 }
12848
12849 #[test]
12850 fn table_cell_border_mark_with_attrs_roundtrip() {
12851 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"}]}]}]}]}]}"##;
12852 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12853 let md = adf_to_markdown(&doc).unwrap();
12854 assert!(md.contains("bg=#e6fcff"), "md: {md}");
12855 assert!(md.contains("colspan=2"), "md: {md}");
12856 assert!(md.contains("border-color=#ff000033"), "md: {md}");
12857 let rt = markdown_to_adf(&md).unwrap();
12858 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12859 .content
12860 .as_ref()
12861 .unwrap()[0];
12862 assert_eq!(cell.attrs.as_ref().unwrap()["background"], "#e6fcff");
12863 assert_eq!(cell.attrs.as_ref().unwrap()["colspan"], 2);
12864 let marks = cell.marks.as_ref().expect("should have marks");
12865 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff000033");
12866 }
12867
12868 #[test]
12869 fn table_cell_no_border_mark_unchanged() {
12870 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"}]}]}]}]}]}"#;
12871 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12872 let md = adf_to_markdown(&doc).unwrap();
12873 assert!(
12874 !md.contains("border-color"),
12875 "no border attrs expected: {md}"
12876 );
12877 let rt = markdown_to_adf(&md).unwrap();
12878 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12879 .content
12880 .as_ref()
12881 .unwrap()[0];
12882 assert!(cell.marks.is_none(), "no marks expected on plain cell");
12883 }
12884
12885 #[test]
12886 fn table_cell_border_size_only_defaults_color() {
12887 let md = "::::table\n:::tr\n:::td{border-size=3}\ncell\n:::\n:::\n::::\n";
12890 let doc = markdown_to_adf(md).unwrap();
12891 let cell = &doc.content[0].content.as_ref().unwrap()[0]
12892 .content
12893 .as_ref()
12894 .unwrap()[0];
12895 let marks = cell.marks.as_ref().expect("should have border mark");
12896 assert_eq!(marks[0].mark_type, "border");
12897 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
12898 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12899 }
12900
12901 #[test]
12902 fn table_cell_border_color_only_defaults_size() {
12903 let md = "::::table\n:::tr\n:::td{border-color=#ff0000}\ncell\n:::\n:::\n::::\n";
12905 let doc = markdown_to_adf(md).unwrap();
12906 let cell = &doc.content[0].content.as_ref().unwrap()[0]
12907 .content
12908 .as_ref()
12909 .unwrap()[0];
12910 let marks = cell.marks.as_ref().expect("should have border mark");
12911 assert_eq!(marks[0].mark_type, "border");
12912 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
12913 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
12914 }
12915
12916 #[test]
12917 fn media_file_border_mark_roundtrip() {
12918 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}}]}]}]}"##;
12919 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12920 let md = adf_to_markdown(&doc).unwrap();
12921 assert!(
12922 md.contains("border-color=#091e4224"),
12923 "media should have border-color in md: {md}"
12924 );
12925 assert!(
12926 md.contains("border-size=2"),
12927 "media should have border-size in md: {md}"
12928 );
12929 let rt = markdown_to_adf(&md).unwrap();
12930 let media_single = &rt.content[0];
12931 let media = &media_single.content.as_ref().unwrap()[0];
12932 assert_eq!(media.node_type, "media");
12933 let marks = media.marks.as_ref().expect("media should have marks");
12934 assert_eq!(marks.len(), 1);
12935 assert_eq!(marks[0].mark_type, "border");
12936 let attrs = marks[0].attrs.as_ref().unwrap();
12937 assert_eq!(attrs["color"], "#091e4224");
12938 assert_eq!(attrs["size"], 2);
12939 }
12940
12941 #[test]
12942 fn media_external_border_mark_roundtrip() {
12943 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}}]}]}]}"##;
12944 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12945 let md = adf_to_markdown(&doc).unwrap();
12946 assert!(
12947 md.contains("border-color=#ff0000"),
12948 "external media should have border-color in md: {md}"
12949 );
12950 assert!(
12951 md.contains("border-size=3"),
12952 "external media should have border-size in md: {md}"
12953 );
12954 let rt = markdown_to_adf(&md).unwrap();
12955 let media = &rt.content[0].content.as_ref().unwrap()[0];
12956 let marks = media.marks.as_ref().expect("media should have marks");
12957 assert_eq!(marks[0].mark_type, "border");
12958 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
12959 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12960 }
12961
12962 #[test]
12963 fn media_file_no_border_mark_unchanged() {
12964 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}}]}]}"#;
12965 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12966 let md = adf_to_markdown(&doc).unwrap();
12967 assert!(
12968 !md.contains("border-color"),
12969 "no border attrs expected: {md}"
12970 );
12971 let rt = markdown_to_adf(&md).unwrap();
12972 let media = &rt.content[0].content.as_ref().unwrap()[0];
12973 assert!(media.marks.is_none(), "no marks expected on plain media");
12974 }
12975
12976 #[test]
12977 fn media_border_size_only_defaults_color() {
12978 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}}]}]}]}"#;
12979 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12980 let md = adf_to_markdown(&doc).unwrap();
12981 assert!(md.contains("border-size=4"), "md: {md}");
12982 let rt = markdown_to_adf(&md).unwrap();
12983 let media = &rt.content[0].content.as_ref().unwrap()[0];
12984 let marks = media.marks.as_ref().expect("should have border mark");
12985 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
12986 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 4);
12987 }
12988
12989 #[test]
12990 fn media_border_color_only_defaults_size() {
12991 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"}}]}]}]}"##;
12992 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12993 let md = adf_to_markdown(&doc).unwrap();
12994 assert!(md.contains("border-color=#00ff00"), "md: {md}");
12995 let rt = markdown_to_adf(&md).unwrap();
12996 let media = &rt.content[0].content.as_ref().unwrap()[0];
12997 let marks = media.marks.as_ref().expect("should have border mark");
12998 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#00ff00");
12999 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
13000 }
13001
13002 #[test]
13003 fn media_border_with_other_attrs_roundtrip() {
13004 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}}]}]}]}"##;
13005 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13006 let md = adf_to_markdown(&doc).unwrap();
13007 assert!(md.contains("layout=wide"), "md: {md}");
13008 assert!(md.contains("mediaWidth=600"), "md: {md}");
13009 assert!(md.contains("border-color=#091e4224"), "md: {md}");
13010 assert!(md.contains("border-size=2"), "md: {md}");
13011 let rt = markdown_to_adf(&md).unwrap();
13012 let ms = &rt.content[0];
13013 assert_eq!(ms.attrs.as_ref().unwrap()["layout"], "wide");
13014 let media = &ms.content.as_ref().unwrap()[0];
13015 let marks = media.marks.as_ref().expect("should have marks");
13016 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#091e4224");
13017 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 2);
13018 }
13019
13020 #[test]
13021 fn table_row_localid_roundtrip() {
13022 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"}]}]}]}]}]}"#;
13023 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13024 let md = adf_to_markdown(&doc).unwrap();
13025 assert!(
13026 md.contains("localId=tr-001"),
13027 "tableRow should have localId in md: {md}"
13028 );
13029 let rt = markdown_to_adf(&md).unwrap();
13030 let row = &rt.content[0].content.as_ref().unwrap()[0];
13031 assert_eq!(
13032 row.attrs.as_ref().unwrap()["localId"],
13033 "tr-001",
13034 "tableRow localId should round-trip"
13035 );
13036 }
13037
13038 #[test]
13039 fn list_item_localid_roundtrip() {
13040 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"}]}]}]}]}"#;
13042 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13043 let md = adf_to_markdown(&doc).unwrap();
13044 assert!(
13045 md.contains("localId=li-001"),
13046 "listItem should have localId in md: {md}"
13047 );
13048 let rt = markdown_to_adf(&md).unwrap();
13050 let list = &rt.content[0];
13051 assert!(
13052 list.attrs.is_none() || list.attrs.as_ref().unwrap().get("localId").is_none(),
13053 "bulletList should NOT have localId: {:?}",
13054 list.attrs
13055 );
13056 let item = &list.content.as_ref().unwrap()[0];
13057 assert_eq!(
13058 item.attrs.as_ref().unwrap()["localId"],
13059 "li-001",
13060 "listItem should have localId=li-001"
13061 );
13062 }
13063
13064 #[test]
13065 fn list_item_localid_not_promoted_to_parent() {
13066 let md = "- item {localId=li-002}\n";
13068 let doc = markdown_to_adf(md).unwrap();
13069 let list = &doc.content[0];
13070 assert!(
13071 list.attrs.is_none(),
13072 "bulletList should have no attrs: {:?}",
13073 list.attrs
13074 );
13075 let item = &list.content.as_ref().unwrap()[0];
13076 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "li-002");
13077 }
13078
13079 #[test]
13080 fn ordered_list_item_localid_roundtrip() {
13081 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"}]}]}]}]}"#;
13082 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13083 let md = adf_to_markdown(&doc).unwrap();
13084 assert!(md.contains("localId=oli-001"), "md: {md}");
13085 let rt = markdown_to_adf(&md).unwrap();
13086 let item = &rt.content[0].content.as_ref().unwrap()[0];
13087 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
13088 }
13089
13090 #[test]
13091 fn task_item_localid_roundtrip() {
13092 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"}]}]}]}]}"#;
13093 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13094 let md = adf_to_markdown(&doc).unwrap();
13095 assert!(md.contains("localId=ti-001"), "md: {md}");
13096 let rt = markdown_to_adf(&md).unwrap();
13097 let item = &rt.content[0].content.as_ref().unwrap()[0];
13098 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "ti-001");
13099 }
13100
13101 #[test]
13104 fn task_list_short_localid_roundtrip() {
13105 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"}]}]}]}"#;
13106 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13107 let md = adf_to_markdown(&doc).unwrap();
13108 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13110 assert!(md.contains("localId=99"), "localId=99 missing: {md}");
13111 assert!(
13113 !md.contains("localId=}"),
13114 "empty localId should not be emitted: {md}"
13115 );
13116 let rt = markdown_to_adf(&md).unwrap();
13117 let task_list = &rt.content[0];
13118 assert_eq!(task_list.node_type, "taskList");
13119 assert_eq!(rt.content.len(), 1, "should be exactly one top-level node");
13121 let items = task_list.content.as_ref().unwrap();
13122 assert_eq!(items.len(), 2);
13123 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13125 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
13126 assert!(
13127 items[0].content.is_none(),
13128 "empty taskItem should have no content: {:?}",
13129 items[0].content
13130 );
13131 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "99");
13133 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
13134 let content = items[1].content.as_ref().unwrap();
13135 assert_eq!(content.len(), 1);
13136 assert_eq!(content[0].text.as_deref(), Some("done task"));
13137 }
13138
13139 #[test]
13143 fn task_item_numeric_localid_with_hardbreak_roundtrip() {
13144 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!!)"}]}]}]}]}"#;
13145 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13146 let md = adf_to_markdown(&doc).unwrap();
13147 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13149 let rt = markdown_to_adf(&md).unwrap();
13151 assert_eq!(rt.content.len(), 1, "exactly one top-level node");
13152 let task_list = &rt.content[0];
13153 assert_eq!(task_list.node_type, "taskList");
13154 let items = task_list.content.as_ref().unwrap();
13155 assert_eq!(items.len(), 1);
13156 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13158 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
13159 let para = &items[0].content.as_ref().unwrap()[0];
13161 assert_eq!(para.node_type, "paragraph");
13162 let inlines = para.content.as_ref().unwrap();
13163 assert_eq!(inlines[0].node_type, "text");
13164 assert_eq!(
13165 inlines[0].text.as_deref(),
13166 Some("Engineering Onboarding Link")
13167 );
13168 assert_eq!(inlines[1].node_type, "hardBreak");
13169 assert_eq!(inlines[2].node_type, "text");
13170 assert_eq!(
13171 inlines[2].text.as_deref(),
13172 Some("(This has links to all the various useful tools!!)")
13173 );
13174 let rt_json = serde_json::to_string(&rt).unwrap();
13176 assert!(
13177 !rt_json.contains("{localId="),
13178 "localId attr syntax should not leak into ADF text: {rt_json}"
13179 );
13180 }
13181
13182 #[test]
13184 fn task_item_multiple_hardbreak_localids_roundtrip() {
13185 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"}]}]}]}]}"#;
13186 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13187 let md = adf_to_markdown(&doc).unwrap();
13188 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13189 assert!(md.contains("localId=67"), "localId=67 missing: {md}");
13190 let rt = markdown_to_adf(&md).unwrap();
13191 let items = rt.content[0].content.as_ref().unwrap();
13192 assert_eq!(items.len(), 2);
13193 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13194 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "67");
13195 for item in items {
13197 let para = &item.content.as_ref().unwrap()[0];
13198 assert_eq!(para.node_type, "paragraph");
13199 let inlines = para.content.as_ref().unwrap();
13200 assert_eq!(inlines[1].node_type, "hardBreak");
13201 }
13202 }
13203
13204 #[test]
13209 fn task_item_sibling_localid_hardbreak_unwrapped_roundtrip() {
13210 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"}]}]}]}"#;
13211 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13212 let md = adf_to_markdown(&doc).unwrap();
13213 assert!(
13215 md.contains(" (parenthetical"),
13216 "continuation line should be 2-space indented: {md}"
13217 );
13218 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13219 assert!(md.contains("localId=69"), "localId=69 missing: {md}");
13220 let rt = markdown_to_adf(&md).unwrap();
13221 assert_eq!(
13223 rt.content.len(),
13224 1,
13225 "should be one taskList: {:#?}",
13226 rt.content
13227 );
13228 assert_eq!(rt.content[0].node_type, "taskList");
13229 let items = rt.content[0].content.as_ref().unwrap();
13230 assert_eq!(items.len(), 2, "should have 2 taskItems");
13231 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13232 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13233 let first_content = items[0].content.as_ref().unwrap();
13235 assert!(
13236 first_content.iter().any(|n| n.node_type == "hardBreak"),
13237 "first item should contain hardBreak"
13238 );
13239 let second_content = items[1].content.as_ref().unwrap();
13241 assert_eq!(second_content[0].node_type, "text");
13242 assert_eq!(
13243 second_content[0].text.as_deref().unwrap(),
13244 "second task item"
13245 );
13246 }
13247
13248 #[test]
13251 fn task_item_sibling_localid_hardbreak_paragraph_roundtrip() {
13252 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"}]}]}]}]}"#;
13253 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13254 let md = adf_to_markdown(&doc).unwrap();
13255 let rt = markdown_to_adf(&md).unwrap();
13256 assert_eq!(
13257 rt.content.len(),
13258 1,
13259 "should be one taskList: {:#?}",
13260 rt.content
13261 );
13262 let items = rt.content[0].content.as_ref().unwrap();
13263 assert_eq!(items.len(), 2);
13264 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13265 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13266 }
13267
13268 #[test]
13271 fn task_item_three_siblings_middle_hardbreak_roundtrip() {
13272 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"}]}]}]}"#;
13273 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13274 let md = adf_to_markdown(&doc).unwrap();
13275 let rt = markdown_to_adf(&md).unwrap();
13276 assert_eq!(rt.content.len(), 1);
13277 let items = rt.content[0].content.as_ref().unwrap();
13278 assert_eq!(items.len(), 3);
13279 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "10");
13280 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "20");
13281 assert_eq!(items[2].attrs.as_ref().unwrap()["localId"], "30");
13282 let mid_content = items[1].content.as_ref().unwrap();
13284 assert!(mid_content.iter().any(|n| n.node_type == "hardBreak"));
13285 }
13286
13287 #[test]
13290 fn task_list_empty_localid_no_spurious_paragraph() {
13291 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"}]}]}]}"#;
13292 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13293 let md = adf_to_markdown(&doc).unwrap();
13294 assert!(
13295 !md.contains("{localId=}"),
13296 "empty localId should not be emitted: {md}"
13297 );
13298 let rt = markdown_to_adf(&md).unwrap();
13299 assert_eq!(
13300 rt.content.len(),
13301 1,
13302 "no spurious paragraph: {:#?}",
13303 rt.content
13304 );
13305 assert_eq!(rt.content[0].node_type, "taskList");
13306 }
13307
13308 #[test]
13310 fn task_list_localid_stripped() {
13311 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"}]}]}]}]}"#;
13312 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13313 let opts = RenderOptions {
13314 strip_local_ids: true,
13315 };
13316 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13317 assert!(!md.contains("localId"), "localId should be stripped: {md}");
13318 }
13319
13320 #[test]
13322 fn task_item_no_content_emits_localid() {
13323 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"}}]}]}"#;
13324 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13325 let md = adf_to_markdown(&doc).unwrap();
13326 assert!(
13327 md.contains("localId=abc"),
13328 "localId should be emitted even without content: {md}"
13329 );
13330 let rt = markdown_to_adf(&md).unwrap();
13331 let item = &rt.content[0].content.as_ref().unwrap()[0];
13332 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "abc");
13333 assert!(item.content.is_none(), "should have no content");
13334 }
13335
13336 #[test]
13338 fn task_list_localid_roundtrip() {
13339 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"}]}]}]}]}"#;
13340 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13341 let md = adf_to_markdown(&doc).unwrap();
13342 assert!(
13343 md.contains("localId=tl-xyz"),
13344 "taskList localId missing: {md}"
13345 );
13346 let rt = markdown_to_adf(&md).unwrap();
13347 assert_eq!(
13348 rt.content[0].attrs.as_ref().unwrap()["localId"],
13349 "tl-xyz",
13350 "taskList localId should survive round-trip"
13351 );
13352 }
13353
13354 #[test]
13356 fn task_item_paragraph_wrapper_roundtrip_no_localid() {
13357 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"}]}]}]}]}"#;
13358 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13359 let md = adf_to_markdown(&doc).unwrap();
13360 assert!(
13361 md.contains("paraLocalId=_"),
13362 "should emit paraLocalId=_ sentinel: {md}"
13363 );
13364 let rt = markdown_to_adf(&md).unwrap();
13365 let item = &rt.content[0].content.as_ref().unwrap()[0];
13366 let content = item.content.as_ref().unwrap();
13367 assert_eq!(content.len(), 1, "should have one child: {content:#?}");
13368 assert_eq!(
13369 content[0].node_type, "paragraph",
13370 "child should be a paragraph: {content:#?}"
13371 );
13372 let para_content = content[0].content.as_ref().unwrap();
13373 assert_eq!(
13374 para_content[0].text.as_deref(),
13375 Some("A task with paragraph wrapper")
13376 );
13377 assert!(
13379 content[0].attrs.is_none(),
13380 "paragraph should have no attrs: {:?}",
13381 content[0].attrs
13382 );
13383 }
13384
13385 #[test]
13387 fn task_item_paragraph_wrapper_roundtrip_with_localid() {
13388 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"}]}]}]}]}"#;
13389 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13390 let md = adf_to_markdown(&doc).unwrap();
13391 assert!(
13392 md.contains("paraLocalId=p-001"),
13393 "should emit paraLocalId=p-001: {md}"
13394 );
13395 let rt = markdown_to_adf(&md).unwrap();
13396 let item = &rt.content[0].content.as_ref().unwrap()[0];
13397 let content = item.content.as_ref().unwrap();
13398 assert_eq!(content[0].node_type, "paragraph");
13399 assert_eq!(
13400 content[0].attrs.as_ref().unwrap()["localId"],
13401 "p-001",
13402 "paragraph localId should be preserved"
13403 );
13404 }
13405
13406 #[test]
13408 fn task_item_unwrapped_inline_no_paragraph_on_roundtrip() {
13409 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"}]}]}]}"#;
13410 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13411 let md = adf_to_markdown(&doc).unwrap();
13412 assert!(
13413 !md.contains("paraLocalId"),
13414 "should NOT emit paraLocalId for unwrapped inline: {md}"
13415 );
13416 let rt = markdown_to_adf(&md).unwrap();
13417 let item = &rt.content[0].content.as_ref().unwrap()[0];
13418 let content = item.content.as_ref().unwrap();
13419 assert_eq!(
13420 content[0].node_type, "text",
13421 "should remain unwrapped: {content:#?}"
13422 );
13423 }
13424
13425 #[test]
13427 fn task_item_done_paragraph_wrapper_roundtrip() {
13428 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"}]}]}]}]}"#;
13429 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13430 let md = adf_to_markdown(&doc).unwrap();
13431 assert!(md.contains("- [x]"), "should render as done: {md}");
13432 let rt = markdown_to_adf(&md).unwrap();
13433 let item = &rt.content[0].content.as_ref().unwrap()[0];
13434 assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
13435 let content = item.content.as_ref().unwrap();
13436 assert_eq!(content[0].node_type, "paragraph");
13437 }
13438
13439 #[test]
13441 fn task_item_mixed_paragraph_and_unwrapped_roundtrip() {
13442 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"}]}]}]}"#;
13443 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13444 let md = adf_to_markdown(&doc).unwrap();
13445 let rt = markdown_to_adf(&md).unwrap();
13446 let items = rt.content[0].content.as_ref().unwrap();
13447 assert_eq!(items.len(), 2);
13448 let c1 = items[0].content.as_ref().unwrap();
13450 assert_eq!(
13451 c1[0].node_type, "paragraph",
13452 "first item should have paragraph wrapper"
13453 );
13454 let c2 = items[1].content.as_ref().unwrap();
13456 assert_eq!(
13457 c2[0].node_type, "text",
13458 "second item should remain unwrapped"
13459 );
13460 }
13461
13462 #[test]
13464 fn task_item_paragraph_wrapper_with_marks_roundtrip() {
13465 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"}]}]}]}]}]}"#;
13466 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13467 let md = adf_to_markdown(&doc).unwrap();
13468 let rt = markdown_to_adf(&md).unwrap();
13469 let item = &rt.content[0].content.as_ref().unwrap()[0];
13470 let content = item.content.as_ref().unwrap();
13471 assert_eq!(content[0].node_type, "paragraph");
13472 let para_children = content[0].content.as_ref().unwrap();
13473 assert!(
13474 para_children.len() >= 2,
13475 "paragraph should contain multiple inline nodes"
13476 );
13477 }
13478
13479 #[test]
13481 fn task_item_paragraph_wrapper_stripped_with_option() {
13482 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"}]}]}]}]}"#;
13483 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13484 let opts = RenderOptions {
13485 strip_local_ids: true,
13486 };
13487 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13488 assert!(
13489 !md.contains("paraLocalId"),
13490 "paraLocalId should be stripped: {md}"
13491 );
13492 assert!(
13493 !md.contains("localId"),
13494 "all localIds should be stripped: {md}"
13495 );
13496 }
13497
13498 #[test]
13499 fn trailing_space_preserved_with_hex_localid() {
13500 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 "}]}]}]}]}"#;
13503 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13504 let md = adf_to_markdown(&doc).unwrap();
13505 let rt = markdown_to_adf(&md).unwrap();
13506 let item = &rt.content[0].content.as_ref().unwrap()[0];
13507 assert_eq!(
13508 item.attrs.as_ref().unwrap()["localId"],
13509 "aabb112233cc",
13510 "localId should round-trip"
13511 );
13512 let para = &item.content.as_ref().unwrap()[0];
13513 let inlines = para.content.as_ref().unwrap();
13514 let last = inlines.last().unwrap();
13515 assert!(
13516 last.text.as_deref().unwrap_or("").ends_with(' '),
13517 "trailing space should be preserved, got nodes: {:?}",
13518 inlines
13519 .iter()
13520 .map(|n| (&n.node_type, &n.text))
13521 .collect::<Vec<_>>()
13522 );
13523 }
13524
13525 #[test]
13526 fn extract_trailing_local_id_preserves_trailing_space() {
13527 let (before, lid, _) = extract_trailing_local_id("trailing space {localId=aabb112233cc}");
13529 assert_eq!(before, "trailing space ");
13530 assert_eq!(lid.as_deref(), Some("aabb112233cc"));
13531 }
13532
13533 #[test]
13534 fn extract_trailing_local_id_no_trailing_space() {
13535 let (before, lid, _) = extract_trailing_local_id("text {localId=abc123}");
13536 assert_eq!(before, "text");
13537 assert_eq!(lid.as_deref(), Some("abc123"));
13538 }
13539
13540 #[test]
13541 fn extract_trailing_local_id_no_attrs() {
13542 let (before, lid, pid) = extract_trailing_local_id("plain text");
13543 assert_eq!(before, "plain text");
13544 assert!(lid.is_none());
13545 assert!(pid.is_none());
13546 }
13547
13548 #[test]
13549 fn list_item_localid_stripped() {
13550 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"}]}]}]}]}"#;
13551 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13552 let opts = RenderOptions {
13553 strip_local_ids: true,
13554 };
13555 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13556 assert!(!md.contains("localId"), "localId should be stripped: {md}");
13557 }
13558
13559 #[test]
13560 fn paragraph_localid_in_list_item_roundtrip() {
13561 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"}]}]}]}]}"#;
13563 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13564 let md = adf_to_markdown(&doc).unwrap();
13565 assert!(
13566 md.contains("paraLocalId=para-001"),
13567 "paragraph localId should be in md: {md}"
13568 );
13569 let rt = markdown_to_adf(&md).unwrap();
13570 let item = &rt.content[0].content.as_ref().unwrap()[0];
13571 assert_eq!(
13572 item.attrs.as_ref().unwrap()["localId"],
13573 "item-001",
13574 "listItem localId should survive"
13575 );
13576 let para = &item.content.as_ref().unwrap()[0];
13577 assert_eq!(
13578 para.attrs.as_ref().unwrap()["localId"],
13579 "para-001",
13580 "paragraph localId should survive round-trip"
13581 );
13582 }
13583
13584 #[test]
13585 fn paragraph_localid_in_ordered_list_item_roundtrip() {
13586 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"}]}]}]}]}"#;
13588 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13589 let md = adf_to_markdown(&doc).unwrap();
13590 assert!(md.contains("paraLocalId=op-001"), "md: {md}");
13591 let rt = markdown_to_adf(&md).unwrap();
13592 let item = &rt.content[0].content.as_ref().unwrap()[0];
13593 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
13594 let para = &item.content.as_ref().unwrap()[0];
13595 assert_eq!(para.attrs.as_ref().unwrap()["localId"], "op-001");
13596 }
13597
13598 #[test]
13599 fn paragraph_localid_only_in_list_item() {
13600 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"}]}]}]}]}"#;
13602 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13603 let md = adf_to_markdown(&doc).unwrap();
13604 assert!(
13605 md.contains("paraLocalId=para-only"),
13606 "paragraph localId should be emitted: {md}"
13607 );
13608 let rt = markdown_to_adf(&md).unwrap();
13609 let item = &rt.content[0].content.as_ref().unwrap()[0];
13610 assert!(item.attrs.is_none(), "listItem should have no attrs");
13611 let para = &item.content.as_ref().unwrap()[0];
13612 assert_eq!(para.attrs.as_ref().unwrap()["localId"], "para-only");
13613 }
13614
13615 #[test]
13616 fn paragraph_localid_in_table_header_roundtrip() {
13617 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"}]}]}]}]}]}"#;
13619 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13620 let md = adf_to_markdown(&doc).unwrap();
13621 assert!(
13623 md.contains("localId=aaaa-aaaa"),
13624 "paragraph localId should be in md: {md}"
13625 );
13626 let rt = markdown_to_adf(&md).unwrap();
13627 let cell = &rt.content[0].content.as_ref().unwrap()[0]
13628 .content
13629 .as_ref()
13630 .unwrap()[0];
13631 let para = &cell.content.as_ref().unwrap()[0];
13632 assert_eq!(
13633 para.attrs.as_ref().unwrap()["localId"],
13634 "aaaa-aaaa",
13635 "paragraph localId should survive round-trip in tableHeader"
13636 );
13637 }
13638
13639 #[test]
13640 fn paragraph_localid_in_table_cell_roundtrip() {
13641 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"}]}]}]}]}]}"#;
13643 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13644 let md = adf_to_markdown(&doc).unwrap();
13645 assert!(
13646 md.contains("localId=cell-para"),
13647 "paragraph localId should be in md: {md}"
13648 );
13649 let rt = markdown_to_adf(&md).unwrap();
13650 let cell = &rt.content[0].content.as_ref().unwrap()[1]
13652 .content
13653 .as_ref()
13654 .unwrap()[0];
13655 let para = &cell.content.as_ref().unwrap()[0];
13656 assert_eq!(
13657 para.attrs.as_ref().unwrap()["localId"],
13658 "cell-para",
13659 "paragraph localId should survive round-trip in tableCell"
13660 );
13661 }
13662
13663 #[test]
13664 fn nbsp_paragraph_with_localid_roundtrip() {
13665 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"nbsp-para"},"content":[{"type":"text","text":"\u00a0"}]}]}"#;
13667 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13668 let md = adf_to_markdown(&doc).unwrap();
13669 assert!(
13670 md.contains("::paragraph["),
13671 "nbsp should use directive form: {md}"
13672 );
13673 assert!(
13674 md.contains("localId=nbsp-para"),
13675 "localId should be in directive: {md}"
13676 );
13677 let rt = markdown_to_adf(&md).unwrap();
13678 let para = &rt.content[0];
13679 assert_eq!(
13680 para.attrs.as_ref().unwrap()["localId"],
13681 "nbsp-para",
13682 "localId should survive round-trip"
13683 );
13684 let text = para.content.as_ref().unwrap()[0].text.as_ref().unwrap();
13685 assert_eq!(text, "\u{00a0}", "nbsp should survive");
13686 }
13687
13688 #[test]
13689 fn empty_paragraph_with_localid_roundtrip() {
13690 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"empty-para"}}]}"#;
13692 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13693 let md = adf_to_markdown(&doc).unwrap();
13694 assert!(
13695 md.contains("::paragraph{localId=empty-para}"),
13696 "empty paragraph should include localId in directive: {md}"
13697 );
13698 let rt = markdown_to_adf(&md).unwrap();
13699 assert_eq!(
13700 rt.content[0].attrs.as_ref().unwrap()["localId"],
13701 "empty-para"
13702 );
13703 }
13704
13705 #[test]
13706 fn paragraph_localid_stripped_from_list_item() {
13707 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"}]}]}]}]}"#;
13709 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13710 let opts = RenderOptions {
13711 strip_local_ids: true,
13712 };
13713 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13714 assert!(!md.contains("localId"), "localId should be stripped: {md}");
13715 assert!(
13716 !md.contains("paraLocalId"),
13717 "paraLocalId should be stripped: {md}"
13718 );
13719 }
13720
13721 #[test]
13722 fn date_directive() {
13723 let doc = markdown_to_adf("Due by :date[2026-04-15].").unwrap();
13724 let content = doc.content[0].content.as_ref().unwrap();
13725 assert_eq!(content[1].node_type, "date");
13726 assert_eq!(
13728 content[1].attrs.as_ref().unwrap()["timestamp"],
13729 "1776211200000"
13730 );
13731 }
13732
13733 #[test]
13734 fn adf_date_to_markdown() {
13735 let doc = AdfDocument {
13737 version: 1,
13738 doc_type: "doc".to_string(),
13739 content: vec![AdfNode::paragraph(vec![AdfNode::date("1776211200000")])],
13740 };
13741 let md = adf_to_markdown(&doc).unwrap();
13742 assert!(md.contains(":date[2026-04-15]{timestamp=1776211200000}"));
13743 }
13744
13745 #[test]
13746 fn adf_date_iso_passthrough() {
13747 let doc = AdfDocument {
13749 version: 1,
13750 doc_type: "doc".to_string(),
13751 content: vec![AdfNode::paragraph(vec![AdfNode::date("2026-04-15")])],
13752 };
13753 let md = adf_to_markdown(&doc).unwrap();
13754 assert!(md.contains(":date[2026-04-15]{timestamp=2026-04-15}"));
13755 }
13756
13757 #[test]
13758 fn round_trip_date() {
13759 let md = "Due by :date[2026-04-15].\n";
13760 let doc = markdown_to_adf(md).unwrap();
13761 let result = adf_to_markdown(&doc).unwrap();
13762 assert!(result.contains(":date[2026-04-15]"));
13763 }
13764
13765 #[test]
13766 fn round_trip_date_non_midnight_timestamp() {
13767 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000"}}]}]}"#;
13769 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13770 let md = adf_to_markdown(&doc).unwrap();
13771 assert!(
13773 md.contains("timestamp=1700000000000"),
13774 "JFM should preserve original timestamp: {md}"
13775 );
13776 let doc2 = markdown_to_adf(&md).unwrap();
13778 let content = doc2.content[0].content.as_ref().unwrap();
13779 assert_eq!(
13780 content[0].attrs.as_ref().unwrap()["timestamp"],
13781 "1700000000000",
13782 "Round-trip must preserve original non-midnight timestamp"
13783 );
13784 }
13785
13786 #[test]
13787 fn date_epoch_ms_passthrough() {
13788 let doc = markdown_to_adf("Due by :date[1776211200000].").unwrap();
13790 let content = doc.content[0].content.as_ref().unwrap();
13791 assert_eq!(
13792 content[1].attrs.as_ref().unwrap()["timestamp"],
13793 "1776211200000"
13794 );
13795 }
13796
13797 #[test]
13798 fn date_timestamp_attr_preferred_over_content() {
13799 let md = ":date[2023-11-14]{timestamp=1700000000000}\n";
13801 let doc = markdown_to_adf(md).unwrap();
13802 let content = doc.content[0].content.as_ref().unwrap();
13803 assert_eq!(
13804 content[0].attrs.as_ref().unwrap()["timestamp"],
13805 "1700000000000",
13806 "timestamp attr should be used directly"
13807 );
13808 }
13809
13810 #[test]
13811 fn date_without_timestamp_attr_backward_compat() {
13812 let md = ":date[2026-04-15]\n";
13814 let doc = markdown_to_adf(md).unwrap();
13815 let content = doc.content[0].content.as_ref().unwrap();
13816 assert_eq!(
13817 content[0].attrs.as_ref().unwrap()["timestamp"],
13818 "1776211200000",
13819 "Should fall back to computing timestamp from date string"
13820 );
13821 }
13822
13823 #[test]
13824 fn date_with_local_id_and_timestamp() {
13825 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
13827 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13828 let md = adf_to_markdown(&doc).unwrap();
13829 assert!(
13830 md.contains("timestamp=1700000000000"),
13831 "Should contain timestamp: {md}"
13832 );
13833 assert!(md.contains("localId=d-001"), "Should contain localId: {md}");
13834 let doc2 = markdown_to_adf(&md).unwrap();
13836 let content = doc2.content[0].content.as_ref().unwrap();
13837 let attrs = content[0].attrs.as_ref().unwrap();
13838 assert_eq!(attrs["timestamp"], "1700000000000");
13839 assert_eq!(attrs["localId"], "d-001");
13840 }
13841
13842 #[test]
13843 fn mention_directive() {
13844 let doc = markdown_to_adf("Assigned to :mention[Alice]{id=abc123}.").unwrap();
13845 let content = doc.content[0].content.as_ref().unwrap();
13846 assert_eq!(content[1].node_type, "mention");
13847 assert_eq!(content[1].attrs.as_ref().unwrap()["id"], "abc123");
13848 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "Alice");
13849 }
13850
13851 #[test]
13852 fn adf_mention_to_markdown() {
13853 let doc = AdfDocument {
13854 version: 1,
13855 doc_type: "doc".to_string(),
13856 content: vec![AdfNode::paragraph(vec![AdfNode::mention(
13857 "abc123", "Alice",
13858 )])],
13859 };
13860 let md = adf_to_markdown(&doc).unwrap();
13861 assert!(md.contains(":mention[Alice]{id=abc123}"));
13862 }
13863
13864 #[test]
13865 fn round_trip_mention() {
13866 let md = "Assigned to :mention[Alice]{id=abc123}.\n";
13867 let doc = markdown_to_adf(md).unwrap();
13868 let result = adf_to_markdown(&doc).unwrap();
13869 assert!(result.contains(":mention[Alice]{id=abc123}"));
13870 }
13871
13872 #[test]
13873 fn mention_with_empty_access_level_round_trips() {
13874 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13876 {"type":"mention","attrs":{"id":"61921b41c15977006af2b1d1","text":"@Javier Inchausti","accessLevel":""}}
13877 ]}]}"#;
13878 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13879
13880 let md = adf_to_markdown(&doc).unwrap();
13881 let round_tripped = markdown_to_adf(&md).unwrap();
13882 let mention = &round_tripped.content[0].content.as_ref().unwrap()[0];
13883 assert_eq!(
13884 mention.node_type, "mention",
13885 "mention with empty accessLevel was not parsed as mention, got: {}",
13886 mention.node_type
13887 );
13888 }
13889
13890 #[test]
13891 fn span_with_color() {
13892 let doc = markdown_to_adf("This is :span[red text]{color=#ff5630}.").unwrap();
13893 let content = doc.content[0].content.as_ref().unwrap();
13894 assert_eq!(content[1].node_type, "text");
13895 assert_eq!(content[1].text.as_deref(), Some("red text"));
13896 let marks = content[1].marks.as_ref().unwrap();
13897 assert_eq!(marks[0].mark_type, "textColor");
13898 }
13899
13900 #[test]
13901 fn adf_text_color_to_markdown() {
13902 let doc = AdfDocument {
13903 version: 1,
13904 doc_type: "doc".to_string(),
13905 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
13906 "red text",
13907 vec![AdfMark::text_color("#ff5630")],
13908 )])],
13909 };
13910 let md = adf_to_markdown(&doc).unwrap();
13911 assert!(md.contains(":span[red text]{color=#ff5630}"));
13912 }
13913
13914 #[test]
13915 fn round_trip_span_color() {
13916 let md = "This is :span[red text]{color=#ff5630}.\n";
13917 let doc = markdown_to_adf(md).unwrap();
13918 let result = adf_to_markdown(&doc).unwrap();
13919 assert!(result.contains(":span[red text]{color=#ff5630}"));
13920 }
13921
13922 #[test]
13923 fn text_color_and_link_marks_both_preserved() {
13924 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13926 {"type":"text","text":"red link","marks":[
13927 {"type":"link","attrs":{"href":"https://example.com"}},
13928 {"type":"textColor","attrs":{"color":"#ff0000"}}
13929 ]}
13930 ]}]}"##;
13931 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13932 let md = adf_to_markdown(&doc).unwrap();
13933 assert!(
13934 md.contains(":span[red link]{color=#ff0000}"),
13935 "JFM should contain span with color, got: {md}"
13936 );
13937 assert!(
13938 md.contains("](https://example.com)"),
13939 "JFM should contain link href, got: {md}"
13940 );
13941 let rt = markdown_to_adf(&md).unwrap();
13943 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
13944 let marks = text_node.marks.as_ref().expect("should have marks");
13945 assert!(
13946 marks.iter().any(|m| m.mark_type == "textColor"),
13947 "should have textColor mark, got: {:?}",
13948 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13949 );
13950 assert!(
13951 marks.iter().any(|m| m.mark_type == "link"),
13952 "should have link mark, got: {:?}",
13953 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13954 );
13955 let link_mark = marks.iter().find(|m| m.mark_type == "link").unwrap();
13957 assert_eq!(
13958 link_mark.attrs.as_ref().unwrap()["href"],
13959 "https://example.com"
13960 );
13961 let color_mark = marks.iter().find(|m| m.mark_type == "textColor").unwrap();
13962 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#ff0000");
13963 }
13964
13965 #[test]
13966 fn bg_color_and_link_marks_both_preserved() {
13967 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13968 {"type":"text","text":"highlighted link","marks":[
13969 {"type":"link","attrs":{"href":"https://example.com"}},
13970 {"type":"backgroundColor","attrs":{"color":"#ffff00"}}
13971 ]}
13972 ]}]}"##;
13973 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13974 let md = adf_to_markdown(&doc).unwrap();
13975 assert!(md.contains("bg=#ffff00"), "should have bg color: {md}");
13976 assert!(
13977 md.contains("](https://example.com)"),
13978 "should have link: {md}"
13979 );
13980 let rt = markdown_to_adf(&md).unwrap();
13981 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
13982 let marks = text_node.marks.as_ref().expect("should have marks");
13983 assert!(marks.iter().any(|m| m.mark_type == "backgroundColor"));
13984 assert!(marks.iter().any(|m| m.mark_type == "link"));
13985 }
13986
13987 #[test]
13988 fn text_color_link_and_strong_rendering() {
13989 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13991 {"type":"text","text":"bold red link","marks":[
13992 {"type":"strong"},
13993 {"type":"link","attrs":{"href":"https://example.com"}},
13994 {"type":"textColor","attrs":{"color":"#ff0000"}}
13995 ]}
13996 ]}]}"##;
13997 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13998 let md = adf_to_markdown(&doc).unwrap();
13999 assert!(
14000 md.starts_with("**") && md.trim().ends_with("**"),
14001 "should have bold wrapping: {md}"
14002 );
14003 assert!(md.contains("color=#ff0000"), "should have color: {md}");
14004 assert!(
14005 md.contains("](https://example.com)"),
14006 "should have link: {md}"
14007 );
14008 }
14009
14010 #[test]
14011 fn subsup_and_link_marks_both_preserved() {
14012 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14013 {"type":"text","text":"note","marks":[
14014 {"type":"link","attrs":{"href":"https://example.com"}},
14015 {"type":"subsup","attrs":{"type":"sup"}}
14016 ]}
14017 ]}]}"#;
14018 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14019 let md = adf_to_markdown(&doc).unwrap();
14020 assert!(md.contains("sup"), "should have sup: {md}");
14021 assert!(
14022 md.contains("](https://example.com)"),
14023 "should have link: {md}"
14024 );
14025 let rt = markdown_to_adf(&md).unwrap();
14026 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
14027 let marks = text_node.marks.as_ref().expect("should have marks");
14028 assert!(marks.iter().any(|m| m.mark_type == "subsup"));
14029 assert!(marks.iter().any(|m| m.mark_type == "link"));
14030 }
14031
14032 #[test]
14033 fn text_color_without_link_unchanged() {
14034 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14036 {"type":"text","text":"just red","marks":[
14037 {"type":"textColor","attrs":{"color":"#ff0000"}}
14038 ]}
14039 ]}]}"##;
14040 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14041 let md = adf_to_markdown(&doc).unwrap();
14042 assert!(md.contains(":span[just red]{color=#ff0000}"), "md: {md}");
14043 assert!(!md.contains("](http"), "should NOT have link syntax: {md}");
14044 }
14045
14046 #[test]
14047 fn inline_extension_directive() {
14048 let doc =
14049 markdown_to_adf("See :extension[fallback]{type=com.app key=widget} here.").unwrap();
14050 let content = doc.content[0].content.as_ref().unwrap();
14051 assert_eq!(content[1].node_type, "inlineExtension");
14052 assert_eq!(
14053 content[1].attrs.as_ref().unwrap()["extensionType"],
14054 "com.app"
14055 );
14056 assert_eq!(content[1].attrs.as_ref().unwrap()["extensionKey"], "widget");
14057 }
14058
14059 #[test]
14060 fn adf_inline_extension_to_markdown() {
14061 let doc = AdfDocument {
14062 version: 1,
14063 doc_type: "doc".to_string(),
14064 content: vec![AdfNode::paragraph(vec![AdfNode::inline_extension(
14065 "com.app",
14066 "widget",
14067 Some("fallback"),
14068 )])],
14069 };
14070 let md = adf_to_markdown(&doc).unwrap();
14071 assert!(md.contains(":extension[fallback]{type=com.app key=widget}"));
14072 }
14073
14074 #[test]
14077 fn parse_ordered_list_marker_valid() {
14078 let result = parse_ordered_list_marker("1. Hello");
14079 assert_eq!(result, Some((1, "Hello")));
14080 }
14081
14082 #[test]
14083 fn parse_ordered_list_marker_high_number() {
14084 let result = parse_ordered_list_marker("42. Item");
14085 assert_eq!(result, Some((42, "Item")));
14086 }
14087
14088 #[test]
14089 fn parse_ordered_list_marker_not_a_list() {
14090 assert!(parse_ordered_list_marker("not a list").is_none());
14091 assert!(parse_ordered_list_marker("1.no space").is_none());
14092 }
14093
14094 #[test]
14095 fn is_list_start_various() {
14096 assert!(is_list_start("- item"));
14097 assert!(is_list_start("* item"));
14098 assert!(is_list_start("+ item"));
14099 assert!(is_list_start("1. item"));
14100 assert!(!is_list_start("not a list"));
14101 }
14102
14103 #[test]
14104 fn is_horizontal_rule_various() {
14105 assert!(is_horizontal_rule("---"));
14106 assert!(is_horizontal_rule("***"));
14107 assert!(is_horizontal_rule("___"));
14108 assert!(is_horizontal_rule("------"));
14109 assert!(!is_horizontal_rule("--"));
14110 assert!(!is_horizontal_rule("abc"));
14111 }
14112
14113 #[test]
14114 fn is_table_separator_valid() {
14115 assert!(is_table_separator("| --- | --- |"));
14116 assert!(is_table_separator("|:---:|:---|"));
14117 assert!(!is_table_separator("no pipes here"));
14118 }
14119
14120 #[test]
14121 fn parse_table_row_cells() {
14122 let cells = parse_table_row("| A | B | C |");
14123 assert_eq!(cells, vec!["A", "B", "C"]);
14124 }
14125
14126 #[test]
14127 fn parse_table_row_escaped_pipe_in_cell() {
14128 let cells = parse_table_row(r"| a\|b | c |");
14130 assert_eq!(cells, vec!["a|b", "c"]);
14131 }
14132
14133 #[test]
14134 fn parse_table_row_escaped_pipe_in_code_span() {
14135 let cells = parse_table_row(r"| `parser.decode[T\|json]` | other |");
14137 assert_eq!(cells, vec!["`parser.decode[T|json]`", "other"]);
14138 }
14139
14140 #[test]
14141 fn parse_table_row_preserves_other_backslashes() {
14142 let cells = parse_table_row(r"| a\\b | c\*d |");
14144 assert_eq!(cells, vec![r"a\\b", r"c\*d"]);
14145 }
14146
14147 #[test]
14148 fn parse_image_syntax_valid() {
14149 let result = parse_image_syntax("");
14150 assert_eq!(result, Some(("alt", "url")));
14151 }
14152
14153 #[test]
14154 fn parse_image_syntax_not_image() {
14155 assert!(parse_image_syntax("not an image").is_none());
14156 }
14157
14158 #[test]
14161 fn find_closing_paren_simple() {
14162 assert_eq!(find_closing_paren("(hello)", 0), Some(6));
14163 }
14164
14165 #[test]
14166 fn find_closing_paren_nested() {
14167 assert_eq!(find_closing_paren("(a(b)c)", 0), Some(6));
14168 }
14169
14170 #[test]
14171 fn find_closing_paren_unmatched() {
14172 assert_eq!(find_closing_paren("(no close", 0), None);
14173 }
14174
14175 #[test]
14176 fn find_closing_paren_offset() {
14177 assert_eq!(find_closing_paren("xx(inner)", 2), Some(8));
14179 }
14180
14181 #[test]
14184 fn try_parse_link_url_with_parens() {
14185 let input = "[here](https://example.com/faq#access-(permissions)-rest)";
14186 let result = try_parse_link(input, 0);
14187 assert_eq!(
14188 result,
14189 Some((
14190 input.len(),
14191 "here",
14192 "https://example.com/faq#access-(permissions)-rest"
14193 ))
14194 );
14195 }
14196
14197 #[test]
14198 fn try_parse_link_url_no_parens() {
14199 let input = "[text](https://example.com)";
14200 let result = try_parse_link(input, 0);
14201 assert_eq!(result, Some((input.len(), "text", "https://example.com")));
14202 }
14203
14204 #[test]
14205 fn try_parse_link_url_with_multiple_nested_parens() {
14206 let input = "[x](http://en.wikipedia.org/wiki/Foo_(bar_(baz)))";
14207 let result = try_parse_link(input, 0);
14208 assert_eq!(
14209 result,
14210 Some((
14211 input.len(),
14212 "x",
14213 "http://en.wikipedia.org/wiki/Foo_(bar_(baz))"
14214 ))
14215 );
14216 }
14217
14218 #[test]
14219 fn parse_image_syntax_url_with_parens() {
14220 let result = parse_image_syntax(")");
14221 assert_eq!(result, Some(("alt", "https://example.com/page_(1)")));
14222 }
14223
14224 #[test]
14225 fn parse_image_syntax_url_no_parens() {
14226 let result = parse_image_syntax("");
14227 assert_eq!(result, Some(("alt", "https://example.com")));
14228 }
14229
14230 #[test]
14231 fn link_with_parens_round_trip() {
14232 let href = "https://example.com/faq#I-need-access-(permissions)-added-in-Monitor";
14233 let mut text_node = AdfNode::text("here");
14234 text_node.marks = Some(vec![AdfMark::link(href)]);
14235 let adf_input = AdfDocument {
14236 version: 1,
14237 doc_type: "doc".to_string(),
14238 content: vec![AdfNode::paragraph(vec![text_node])],
14239 };
14240
14241 let jfm = adf_to_markdown(&adf_input).unwrap();
14242 let adf_output = markdown_to_adf(&jfm).unwrap();
14243
14244 let para = &adf_output.content[0];
14246 let text_node = ¶.content.as_ref().unwrap()[0];
14247 let mark = &text_node.marks.as_ref().unwrap()[0];
14248 let result_href = mark.attrs.as_ref().unwrap()["href"].as_str().unwrap();
14249
14250 assert_eq!(result_href, href);
14251 }
14252
14253 #[test]
14254 fn flush_plain_empty_range() {
14255 let mut nodes = Vec::new();
14256 flush_plain("hello", 3, 3, &mut nodes);
14257 assert!(nodes.is_empty());
14258 }
14259
14260 #[test]
14261 fn add_mark_to_unmarked_node() {
14262 let mut node = AdfNode::text("test");
14263 add_mark(&mut node, AdfMark::strong());
14264 assert_eq!(node.marks.as_ref().unwrap().len(), 1);
14265 }
14266
14267 #[test]
14268 fn add_mark_to_marked_node() {
14269 let mut node = AdfNode::text_with_marks("test", vec![AdfMark::strong()]);
14270 add_mark(&mut node, AdfMark::em());
14271 assert_eq!(node.marks.as_ref().unwrap().len(), 2);
14272 }
14273
14274 #[test]
14277 fn directive_table_basic() {
14278 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";
14279 let doc = markdown_to_adf(md).unwrap();
14280 assert_eq!(doc.content[0].node_type, "table");
14281 let rows = doc.content[0].content.as_ref().unwrap();
14282 assert_eq!(rows.len(), 2);
14283 assert_eq!(
14284 rows[0].content.as_ref().unwrap()[0].node_type,
14285 "tableHeader"
14286 );
14287 assert_eq!(rows[1].content.as_ref().unwrap()[0].node_type, "tableCell");
14288 }
14289
14290 #[test]
14291 fn directive_table_with_block_content() {
14292 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";
14293 let doc = markdown_to_adf(md).unwrap();
14294 let rows = doc.content[0].content.as_ref().unwrap();
14295 let cell = &rows[0].content.as_ref().unwrap()[0];
14296 let content = cell.content.as_ref().unwrap();
14298 assert!(content.len() >= 2);
14299 assert_eq!(content[1].node_type, "bulletList");
14300 }
14301
14302 #[test]
14303 fn directive_table_with_cell_attrs() {
14304 let md = "::::table\n:::tr\n:::td{colspan=2 bg=#DEEBFF}\nSpanning cell\n:::\n:::\n::::\n";
14305 let doc = markdown_to_adf(md).unwrap();
14306 let cell = &doc.content[0].content.as_ref().unwrap()[0]
14307 .content
14308 .as_ref()
14309 .unwrap()[0];
14310 let attrs = cell.attrs.as_ref().unwrap();
14311 assert_eq!(attrs["colspan"], 2);
14312 assert_eq!(attrs["background"], "#DEEBFF");
14313 }
14314
14315 #[test]
14316 fn directive_table_with_css_var_background() {
14317 let bg = "var(--ds-background-accent-gray-subtlest, var(--ds-background-accent-gray-subtlest, #F1F2F4))";
14318 let md = format!("::::table\n:::tr\n:::th{{bg=\"{bg}\"}}\nHeader\n:::\n:::\n::::\n");
14319 let doc = markdown_to_adf(&md).unwrap();
14320 let row = &doc.content[0].content.as_ref().unwrap()[0];
14321 let cells = row.content.as_ref().unwrap();
14322 assert_eq!(cells.len(), 1, "row must have at least one cell");
14323 let attrs = cells[0].attrs.as_ref().unwrap();
14324 assert_eq!(attrs["background"], bg);
14325 }
14326
14327 #[test]
14328 fn css_var_background_round_trips() {
14329 let bg = "var(--ds-background-accent-gray-subtlest, #F1F2F4)";
14330 let adf = AdfDocument {
14331 version: 1,
14332 doc_type: "doc".to_string(),
14333 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14334 AdfNode::table_header_with_attrs(
14335 vec![AdfNode::paragraph(vec![AdfNode::text("Header")])],
14336 serde_json::json!({"background": bg}),
14337 ),
14338 ])])],
14339 };
14340 let md = adf_to_markdown(&adf).unwrap();
14341 assert!(
14342 md.contains(&format!("bg=\"{bg}\"")),
14343 "bg value must be quoted in markdown: {md}"
14344 );
14345
14346 let round_tripped = markdown_to_adf(&md).unwrap();
14347 let row = &round_tripped.content[0].content.as_ref().unwrap()[0];
14348 let cells = row.content.as_ref().unwrap();
14349 assert_eq!(cells.len(), 1, "round-tripped row must have one cell");
14350 let rt_attrs = cells[0].attrs.as_ref().unwrap();
14351 assert_eq!(rt_attrs["background"], bg);
14352 }
14353
14354 #[test]
14355 fn directive_table_with_table_attrs() {
14356 let md = "::::table{layout=wide numbered}\n:::tr\n:::td\nCell\n:::\n:::\n::::\n";
14357 let doc = markdown_to_adf(md).unwrap();
14358 let attrs = doc.content[0].attrs.as_ref().unwrap();
14359 assert_eq!(attrs["layout"], "wide");
14360 assert_eq!(attrs["isNumberColumnEnabled"], true);
14361 }
14362
14363 #[test]
14364 fn adf_table_with_block_content_renders_directive_form() {
14365 let doc = AdfDocument {
14367 version: 1,
14368 doc_type: "doc".to_string(),
14369 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14370 AdfNode::table_cell(vec![
14371 AdfNode::paragraph(vec![AdfNode::text("Cell with list:")]),
14372 AdfNode::bullet_list(vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
14373 AdfNode::text("Item 1"),
14374 ])])]),
14375 ]),
14376 ])])],
14377 };
14378 let md = adf_to_markdown(&doc).unwrap();
14379 assert!(md.contains("::::table"));
14380 assert!(md.contains(":::td"));
14381 assert!(md.contains("- Item 1"));
14382 }
14383
14384 #[test]
14385 fn adf_table_inline_only_renders_pipe_form() {
14386 let doc = AdfDocument {
14388 version: 1,
14389 doc_type: "doc".to_string(),
14390 content: vec![AdfNode::table(vec![
14391 AdfNode::table_row(vec![
14392 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
14393 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14394 ]),
14395 AdfNode::table_row(vec![
14396 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
14397 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14398 ]),
14399 ])],
14400 };
14401 let md = adf_to_markdown(&doc).unwrap();
14402 assert!(md.contains("| H1 | H2 |"));
14403 assert!(!md.contains("::::table"));
14404 }
14405
14406 #[test]
14407 fn adf_table_header_outside_first_row_renders_directive() {
14408 let doc = AdfDocument {
14409 version: 1,
14410 doc_type: "doc".to_string(),
14411 content: vec![AdfNode::table(vec![
14412 AdfNode::table_row(vec![
14413 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H")])]),
14414 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C")])]),
14415 ]),
14416 AdfNode::table_row(vec![
14417 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14418 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14419 ]),
14420 ])],
14421 };
14422 let md = adf_to_markdown(&doc).unwrap();
14423 assert!(md.contains("::::table"));
14424 assert!(md.contains(":::th"));
14425 }
14426
14427 #[test]
14428 fn adf_table_cell_attrs_rendered() {
14429 let doc = AdfDocument {
14430 version: 1,
14431 doc_type: "doc".to_string(),
14432 content: vec![AdfNode::table(vec![
14433 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
14434 AdfNode::text("H"),
14435 ])])]),
14436 AdfNode::table_row(vec![AdfNode::table_cell_with_attrs(
14437 vec![AdfNode::paragraph(vec![AdfNode::text("C")])],
14438 serde_json::json!({"background": "#DEEBFF", "colspan": 2}),
14439 )]),
14440 ])],
14441 };
14442 let md = adf_to_markdown(&doc).unwrap();
14443 assert!(md.contains("{colspan=2 bg=#DEEBFF}"));
14444 }
14445
14446 #[test]
14449 fn pipe_table_cell_attrs() {
14450 let md = "| H1 | H2 |\n|---|---|\n| {bg=#DEEBFF} highlighted | normal |\n";
14451 let doc = markdown_to_adf(md).unwrap();
14452 let rows = doc.content[0].content.as_ref().unwrap();
14453 let cell = &rows[1].content.as_ref().unwrap()[0];
14454 let attrs = cell.attrs.as_ref().unwrap();
14455 assert_eq!(attrs["background"], "#DEEBFF");
14456 }
14457
14458 #[test]
14459 fn pipe_table_cell_colspan() {
14460 let md = "| H1 | H2 |\n|---|---|\n| {colspan=2} spanning |\n";
14461 let doc = markdown_to_adf(md).unwrap();
14462 let rows = doc.content[0].content.as_ref().unwrap();
14463 let cell = &rows[1].content.as_ref().unwrap()[0];
14464 let attrs = cell.attrs.as_ref().unwrap();
14465 assert_eq!(attrs["colspan"], 2);
14466 }
14467
14468 #[test]
14469 fn trailing_space_after_mention_in_table_cell_preserved() {
14470 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":[
14472 {"type":"mention","attrs":{"id":"aaa","text":"@Rob"}},
14473 {"type":"text","text":" "}
14474 ]}]}]}]}]}"#;
14475 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14476 let md = adf_to_markdown(&doc).unwrap();
14477 let round_tripped = markdown_to_adf(&md).unwrap();
14478 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14479 .content
14480 .as_ref()
14481 .unwrap()[0];
14482 let para = &cell.content.as_ref().unwrap()[0];
14483 let inlines = para.content.as_ref().unwrap();
14484 assert!(
14485 inlines.len() >= 2,
14486 "expected mention + text(' ') nodes, got {} nodes: {:?}",
14487 inlines.len(),
14488 inlines.iter().map(|n| &n.node_type).collect::<Vec<_>>()
14489 );
14490 assert_eq!(inlines[0].node_type, "mention");
14491 assert_eq!(inlines[1].node_type, "text");
14492 assert_eq!(inlines[1].text.as_deref(), Some(" "));
14493 }
14494
14495 #[test]
14498 fn pipe_table_column_alignment() {
14499 let md = "| Left | Center | Right |\n|:---|:---:|---:|\n| L | C | R |\n";
14500 let doc = markdown_to_adf(md).unwrap();
14501 let rows = doc.content[0].content.as_ref().unwrap();
14502 let h_cells = rows[0].content.as_ref().unwrap();
14504 assert!(h_cells[0].content.as_ref().unwrap()[0].marks.is_none());
14506 let center_marks = h_cells[1].content.as_ref().unwrap()[0]
14508 .marks
14509 .as_ref()
14510 .unwrap();
14511 assert_eq!(center_marks[0].attrs.as_ref().unwrap()["align"], "center");
14512 let right_marks = h_cells[2].content.as_ref().unwrap()[0]
14514 .marks
14515 .as_ref()
14516 .unwrap();
14517 assert_eq!(right_marks[0].attrs.as_ref().unwrap()["align"], "end");
14518 }
14519
14520 #[test]
14521 fn adf_table_alignment_roundtrip() {
14522 let doc = AdfDocument {
14523 version: 1,
14524 doc_type: "doc".to_string(),
14525 content: vec![AdfNode::table(vec![
14526 AdfNode::table_row(vec![
14527 AdfNode::table_header(vec![{
14528 let mut p = AdfNode::paragraph(vec![AdfNode::text("Center")]);
14529 p.marks = Some(vec![AdfMark::alignment("center")]);
14530 p
14531 }]),
14532 AdfNode::table_header(vec![{
14533 let mut p = AdfNode::paragraph(vec![AdfNode::text("Right")]);
14534 p.marks = Some(vec![AdfMark::alignment("end")]);
14535 p
14536 }]),
14537 ]),
14538 AdfNode::table_row(vec![
14539 AdfNode::table_cell(vec![{
14540 let mut p = AdfNode::paragraph(vec![AdfNode::text("C")]);
14541 p.marks = Some(vec![AdfMark::alignment("center")]);
14542 p
14543 }]),
14544 AdfNode::table_cell(vec![{
14545 let mut p = AdfNode::paragraph(vec![AdfNode::text("R")]);
14546 p.marks = Some(vec![AdfMark::alignment("end")]);
14547 p
14548 }]),
14549 ]),
14550 ])],
14551 };
14552 let md = adf_to_markdown(&doc).unwrap();
14553 assert!(md.contains(":---:"));
14554 assert!(md.contains("---:"));
14555 }
14556
14557 #[test]
14560 fn panel_custom_attrs_round_trip() {
14561 let md = ":::panel{type=custom icon=\":star:\" color=\"#DEEBFF\"}\nContent\n:::\n";
14562 let doc = markdown_to_adf(md).unwrap();
14563 let panel = &doc.content[0];
14564 let attrs = panel.attrs.as_ref().unwrap();
14565 assert_eq!(attrs["panelType"], "custom");
14566 assert_eq!(attrs["panelIcon"], ":star:");
14567 assert_eq!(attrs["panelColor"], "#DEEBFF");
14568
14569 let result = adf_to_markdown(&doc).unwrap();
14570 assert!(result.contains("type=custom"));
14571 assert!(result.contains("icon="));
14572 assert!(result.contains("color="));
14573 }
14574
14575 #[test]
14578 fn block_card_with_layout() {
14579 let md = "::card[https://example.com]{layout=wide}\n";
14580 let doc = markdown_to_adf(md).unwrap();
14581 let attrs = doc.content[0].attrs.as_ref().unwrap();
14582 assert_eq!(attrs["layout"], "wide");
14583
14584 let result = adf_to_markdown(&doc).unwrap();
14585 assert!(result.contains("::card[https://example.com]{layout=wide}"));
14586 }
14587
14588 #[test]
14591 fn extension_with_params() {
14592 let md = r#"::extension{type=com.atlassian.macro key=jira-chart params='{"jql":"project=PROJ"}'}"#;
14593 let doc = markdown_to_adf(&format!("{md}\n")).unwrap();
14594 let attrs = doc.content[0].attrs.as_ref().unwrap();
14595 assert_eq!(attrs["parameters"]["jql"], "project=PROJ");
14596 }
14597
14598 #[test]
14599 fn leaf_extension_layout_preserved_in_roundtrip() {
14600 let adf_json = r#"{"version":1,"type":"doc","content":[
14602 {"type":"extension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"toc","layout":"default","parameters":{}}}
14603 ]}"#;
14604 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14605 let md = adf_to_markdown(&doc).unwrap();
14606 assert!(
14607 md.contains("layout=default"),
14608 "JFM should contain layout=default, got: {md}"
14609 );
14610 let round_tripped = markdown_to_adf(&md).unwrap();
14611 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14612 assert_eq!(attrs["layout"], "default", "layout should be preserved");
14613 assert_eq!(attrs["extensionKey"], "toc");
14614 }
14615
14616 #[test]
14617 fn bodied_extension_layout_preserved_in_roundtrip() {
14618 let adf_json = r#"{"version":1,"type":"doc","content":[
14620 {"type":"bodiedExtension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"expand","layout":"wide"},
14621 "content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}
14622 ]}"#;
14623 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14624 let md = adf_to_markdown(&doc).unwrap();
14625 assert!(
14626 md.contains("layout=wide"),
14627 "JFM should contain layout=wide, got: {md}"
14628 );
14629 let round_tripped = markdown_to_adf(&md).unwrap();
14630 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14631 assert_eq!(attrs["layout"], "wide", "layout should be preserved");
14632 }
14633
14634 #[test]
14635 fn bodied_extension_parameters_preserved_in_roundtrip() {
14636 let adf_json = r#"{"version":1,"type":"doc","content":[
14638 {"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":{}}},
14639 "content":[{"type":"paragraph","content":[{"type":"text","text":"Content inside bodied extension"}]}]}
14640 ]}"#;
14641 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14642 let md = adf_to_markdown(&doc).unwrap();
14643 assert!(
14644 md.contains("params="),
14645 "JFM should contain params attribute, got: {md}"
14646 );
14647 let round_tripped = markdown_to_adf(&md).unwrap();
14648 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14649 assert_eq!(
14650 attrs["parameters"]["macroMetadata"]["title"], "Page Properties",
14651 "parameters should be preserved in round-trip"
14652 );
14653 assert_eq!(attrs["extensionKey"], "details");
14654 assert_eq!(attrs["layout"], "default");
14655 assert_eq!(attrs["localId"], "aabbccdd-1234");
14656 }
14657
14658 #[test]
14659 fn bodied_extension_malformed_params_ignored() {
14660 let md = ":::extension{type=com.atlassian.macro key=details params='not-valid-json'}\nContent\n:::\n";
14662 let doc = markdown_to_adf(md).unwrap();
14663 let attrs = doc.content[0].attrs.as_ref().unwrap();
14664 assert_eq!(attrs["extensionKey"], "details");
14665 assert!(attrs.get("parameters").is_none());
14667 }
14668
14669 #[test]
14670 fn leaf_extension_localid_preserved_in_roundtrip() {
14671 let adf_json = r#"{"version":1,"type":"doc","content":[
14673 {"type":"extension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"toc","layout":"default","localId":"abc-123"}}
14674 ]}"#;
14675 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14676 let md = adf_to_markdown(&doc).unwrap();
14677 let round_tripped = markdown_to_adf(&md).unwrap();
14678 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14679 assert_eq!(attrs["layout"], "default");
14680 assert_eq!(attrs["localId"], "abc-123");
14681 }
14682
14683 #[test]
14686 fn mention_with_user_type() {
14687 let md = "Hi :mention[Alice]{id=abc123 userType=DEFAULT}.\n";
14688 let doc = markdown_to_adf(md).unwrap();
14689 let mention = &doc.content[0].content.as_ref().unwrap()[1];
14690 assert_eq!(mention.attrs.as_ref().unwrap()["userType"], "DEFAULT");
14691
14692 let result = adf_to_markdown(&doc).unwrap();
14693 assert!(result.contains("userType=DEFAULT"));
14694 }
14695
14696 #[test]
14699 fn directive_table_colwidth() {
14700 let md = "::::table\n:::tr\n:::td{colwidth=100,200}\nCell\n:::\n:::\n::::\n";
14701 let doc = markdown_to_adf(md).unwrap();
14702 let cell = &doc.content[0].content.as_ref().unwrap()[0]
14703 .content
14704 .as_ref()
14705 .unwrap()[0];
14706 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14707 assert_eq!(colwidth, &[serde_json::json!(100), serde_json::json!(200)]);
14708 }
14709
14710 #[test]
14711 fn directive_table_colwidth_float_roundtrip() {
14712 let adf_doc = serde_json::json!({
14715 "type": "doc",
14716 "version": 1,
14717 "content": [{
14718 "type": "table",
14719 "content": [{
14720 "type": "tableRow",
14721 "content": [
14722 {
14723 "type": "tableHeader",
14724 "attrs": { "colwidth": [157.0] },
14725 "content": [{ "type": "paragraph" }]
14726 },
14727 {
14728 "type": "tableHeader",
14729 "attrs": { "colwidth": [863.0] },
14730 "content": [{ "type": "paragraph" }]
14731 }
14732 ]
14733 }]
14734 }]
14735 });
14736 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
14737 let md = adf_to_markdown(&doc).unwrap();
14738 assert!(
14739 md.contains("colwidth=157.0"),
14740 "expected colwidth=157.0 in markdown, got: {md}"
14741 );
14742 assert!(
14743 md.contains("colwidth=863.0"),
14744 "expected colwidth=863.0 in markdown, got: {md}"
14745 );
14746 let doc2 = markdown_to_adf(&md).unwrap();
14748 let row = &doc2.content[0].content.as_ref().unwrap()[0];
14749 let header1 = &row.content.as_ref().unwrap()[0];
14750 let header2 = &row.content.as_ref().unwrap()[1];
14751 assert_eq!(
14752 header1.attrs.as_ref().unwrap()["colwidth"]
14753 .as_array()
14754 .unwrap(),
14755 &[serde_json::json!(157.0)]
14756 );
14757 assert_eq!(
14758 header2.attrs.as_ref().unwrap()["colwidth"]
14759 .as_array()
14760 .unwrap(),
14761 &[serde_json::json!(863.0)]
14762 );
14763 }
14764
14765 #[test]
14766 fn colwidth_float_preserved_in_roundtrip() {
14767 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":[]}]}]}]}]}"#;
14769 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14770 let md = adf_to_markdown(&doc).unwrap();
14771 let round_tripped = markdown_to_adf(&md).unwrap();
14772 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14773 .content
14774 .as_ref()
14775 .unwrap()[0];
14776 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14777 assert_eq!(
14778 colwidth,
14779 &[serde_json::json!(254.0), serde_json::json!(416.0)],
14780 "colwidth should preserve float values"
14781 );
14782 }
14783
14784 #[test]
14785 fn colwidth_integer_preserved_in_roundtrip() {
14786 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"}]}]}]}]}]}"#;
14788 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14789 let md = adf_to_markdown(&doc).unwrap();
14790 assert!(
14791 md.contains("colwidth=150"),
14792 "expected colwidth=150 (no decimal) in markdown, got: {md}"
14793 );
14794 assert!(
14795 !md.contains("colwidth=150.0"),
14796 "colwidth should not have .0 suffix for integers, got: {md}"
14797 );
14798 let round_tripped = markdown_to_adf(&md).unwrap();
14800 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14801 .content
14802 .as_ref()
14803 .unwrap()[0];
14804 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14805 assert_eq!(
14806 colwidth,
14807 &[serde_json::json!(150)],
14808 "colwidth should preserve integer values"
14809 );
14810 let json_output = serde_json::to_string(&round_tripped).unwrap();
14812 assert!(
14813 json_output.contains(r#""colwidth":[150]"#),
14814 "JSON should contain integer colwidth, got: {json_output}"
14815 );
14816 }
14817
14818 #[test]
14819 fn colwidth_mixed_int_and_float_roundtrip() {
14820 let int_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100,200]}}]}]}]}"#;
14823 let float_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100.0,200.0]}}]}]}]}"#;
14824
14825 let int_doc: AdfDocument = serde_json::from_str(int_json).unwrap();
14827 let int_md = adf_to_markdown(&int_doc).unwrap();
14828 assert!(
14829 int_md.contains("colwidth=100,200"),
14830 "integer colwidth in md: {int_md}"
14831 );
14832 let int_rt = markdown_to_adf(&int_md).unwrap();
14833 let int_serial = serde_json::to_string(&int_rt).unwrap();
14834 assert!(
14835 int_serial.contains(r#""colwidth":[100,200]"#),
14836 "integer colwidth in JSON: {int_serial}"
14837 );
14838
14839 let float_doc: AdfDocument = serde_json::from_str(float_json).unwrap();
14841 let float_md = adf_to_markdown(&float_doc).unwrap();
14842 assert!(
14843 float_md.contains("colwidth=100.0,200.0"),
14844 "float colwidth in md: {float_md}"
14845 );
14846 let float_rt = markdown_to_adf(&float_md).unwrap();
14847 let float_serial = serde_json::to_string(&float_rt).unwrap();
14848 assert!(
14849 float_serial.contains(r#""colwidth":[100.0,200.0]"#),
14850 "float colwidth in JSON: {float_serial}"
14851 );
14852 }
14853
14854 #[test]
14855 fn colwidth_fractional_float_preserved() {
14856 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"}]}]}]}]}]}"#;
14858 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14859 let md = adf_to_markdown(&doc).unwrap();
14860 assert!(
14861 md.contains("colwidth=100.5"),
14862 "expected colwidth=100.5 in markdown, got: {md}"
14863 );
14864 }
14865
14866 #[test]
14867 fn colwidth_non_numeric_values_skipped() {
14868 let adf_doc = serde_json::json!({
14870 "type": "doc",
14871 "version": 1,
14872 "content": [{
14873 "type": "table",
14874 "content": [{
14875 "type": "tableRow",
14876 "content": [{
14877 "type": "tableCell",
14878 "attrs": { "colwidth": ["invalid"] },
14879 "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "cell" }] }]
14880 }]
14881 }]
14882 }]
14883 });
14884 let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
14885 let md = adf_to_markdown(&doc).unwrap();
14886 assert!(
14888 !md.contains("colwidth"),
14889 "non-numeric colwidth should be filtered out, got: {md}"
14890 );
14891 }
14892
14893 #[test]
14894 fn default_rowspan_colspan_preserved_in_roundtrip() {
14895 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"}]}]}]}]}]}"#;
14897 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14898 let md = adf_to_markdown(&doc).unwrap();
14899 let round_tripped = markdown_to_adf(&md).unwrap();
14900 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14901 .content
14902 .as_ref()
14903 .unwrap()[0];
14904 let attrs = cell.attrs.as_ref().unwrap();
14905 assert_eq!(attrs["rowspan"], 1, "rowspan=1 should be preserved");
14906 assert_eq!(attrs["colspan"], 1, "colspan=1 should be preserved");
14907 }
14908
14909 #[test]
14912 fn table_localid_preserved_in_roundtrip() {
14913 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"}]}]}]}]}]}"#;
14915 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14916 let md = adf_to_markdown(&doc).unwrap();
14917 assert!(
14918 md.contains("localId="),
14919 "JFM should contain localId, got: {md}"
14920 );
14921 let round_tripped = markdown_to_adf(&md).unwrap();
14922 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14923 assert_eq!(
14924 attrs["localId"], "7afd4550-e66c-4b12-875f-a91c6c7b62c7",
14925 "localId should be preserved"
14926 );
14927 }
14928
14929 #[test]
14930 fn paragraph_localid_preserved_in_roundtrip() {
14931 let adf_json = r#"{"version":1,"type":"doc","content":[
14933 {"type":"paragraph","attrs":{"localId":"abc-123"},"content":[{"type":"text","text":"hello"}]}
14934 ]}"#;
14935 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14936 let md = adf_to_markdown(&doc).unwrap();
14937 assert!(
14938 md.contains("localId=abc-123"),
14939 "JFM should contain localId, got: {md}"
14940 );
14941 let round_tripped = markdown_to_adf(&md).unwrap();
14942 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14943 assert_eq!(attrs["localId"], "abc-123", "localId should be preserved");
14944 }
14945
14946 #[test]
14947 fn heading_localid_preserved_in_roundtrip() {
14948 let adf_json = r#"{"version":1,"type":"doc","content":[
14949 {"type":"heading","attrs":{"level":2,"localId":"h-456"},"content":[{"type":"text","text":"Title"}]}
14950 ]}"#;
14951 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14952 let md = adf_to_markdown(&doc).unwrap();
14953 let round_tripped = markdown_to_adf(&md).unwrap();
14954 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14955 assert_eq!(attrs["localId"], "h-456");
14956 }
14957
14958 #[test]
14959 fn localid_with_alignment_preserved() {
14960 let adf_json = r#"{"version":1,"type":"doc","content":[
14962 {"type":"paragraph","attrs":{"localId":"p-789"},"marks":[{"type":"alignment","attrs":{"align":"center"}}],
14963 "content":[{"type":"text","text":"centered"}]}
14964 ]}"#;
14965 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14966 let md = adf_to_markdown(&doc).unwrap();
14967 assert!(md.contains("localId=p-789"), "should have localId: {md}");
14968 assert!(md.contains("align=center"), "should have align: {md}");
14969 let round_tripped = markdown_to_adf(&md).unwrap();
14970 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14971 assert_eq!(attrs["localId"], "p-789");
14972 let marks = round_tripped.content[0].marks.as_ref().unwrap();
14973 assert!(marks.iter().any(|m| m.mark_type == "alignment"));
14974 }
14975
14976 #[test]
14977 fn table_layout_default_preserved_in_roundtrip() {
14978 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"}]}]}]}]}]}"#;
14980 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14981 let md = adf_to_markdown(&doc).unwrap();
14982 let round_tripped = markdown_to_adf(&md).unwrap();
14983 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14984 assert_eq!(
14985 attrs["layout"], "default",
14986 "layout='default' should be preserved"
14987 );
14988 }
14989
14990 #[test]
14991 fn table_is_number_column_enabled_false_preserved() {
14992 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"}]}]}]}]}]}"#;
14994 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14995 let md = adf_to_markdown(&doc).unwrap();
14996 let round_tripped = markdown_to_adf(&md).unwrap();
14997 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14998 assert_eq!(
14999 attrs["isNumberColumnEnabled"], false,
15000 "isNumberColumnEnabled=false should be preserved"
15001 );
15002 }
15003
15004 #[test]
15005 fn table_is_number_column_enabled_true_preserved() {
15006 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"}]}]}]}]}]}"#;
15008 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15009 let md = adf_to_markdown(&doc).unwrap();
15010 let round_tripped = markdown_to_adf(&md).unwrap();
15011 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15012 assert_eq!(
15013 attrs["isNumberColumnEnabled"], true,
15014 "isNumberColumnEnabled=true should be preserved"
15015 );
15016 }
15017
15018 #[test]
15019 fn directive_table_is_number_column_enabled_false_preserved() {
15020 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
15023 {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
15024 {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
15025 ]}]}]}]}"#;
15026 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15027 let md = adf_to_markdown(&doc).unwrap();
15028 assert!(md.contains("::::table"), "should use directive table form");
15029 assert!(
15030 md.contains("numbered=false"),
15031 "should contain numbered=false, got: {md}"
15032 );
15033 let round_tripped = markdown_to_adf(&md).unwrap();
15034 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15035 assert_eq!(attrs["isNumberColumnEnabled"], false);
15036 assert_eq!(attrs["layout"], "default");
15037 }
15038
15039 #[test]
15040 fn directive_table_is_number_column_enabled_true_preserved() {
15041 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":true,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
15043 {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
15044 {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
15045 ]}]}]}]}"#;
15046 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15047 let md = adf_to_markdown(&doc).unwrap();
15048 assert!(md.contains("::::table"), "should use directive table form");
15049 assert!(
15050 md.contains("numbered}") || md.contains("numbered "),
15051 "should contain numbered flag, got: {md}"
15052 );
15053 let round_tripped = markdown_to_adf(&md).unwrap();
15054 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15055 assert_eq!(attrs["isNumberColumnEnabled"], true);
15056 }
15057
15058 #[test]
15059 fn trailing_space_in_bullet_list_item_preserved() {
15060 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15062 {"type":"listItem","content":[{"type":"paragraph","content":[
15063 {"type":"text","text":"Before link "},
15064 {"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
15065 {"type":"text","text":" "}
15066 ]}]}
15067 ]}]}"#;
15068 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15069 let md = adf_to_markdown(&doc).unwrap();
15070 let round_tripped = markdown_to_adf(&md).unwrap();
15071 let list = &round_tripped.content[0];
15072 let item = &list.content.as_ref().unwrap()[0];
15073 let para = &item.content.as_ref().unwrap()[0];
15074 let inlines = para.content.as_ref().unwrap();
15075 let last = inlines.last().unwrap();
15076 assert_eq!(
15077 last.text.as_deref(),
15078 Some(" "),
15079 "trailing space text node should be preserved, got nodes: {:?}",
15080 inlines
15081 .iter()
15082 .map(|n| (&n.node_type, &n.text))
15083 .collect::<Vec<_>>()
15084 );
15085 }
15086
15087 #[test]
15088 fn trailing_space_after_mention_in_bullet_list_preserved() {
15089 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15091 {"type":"listItem","content":[{"type":"paragraph","content":[
15092 {"type":"mention","attrs":{"id":"abc","text":"@Alice"}},
15093 {"type":"text","text":" "}
15094 ]}]}
15095 ]}]}"#;
15096 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15097 let md = adf_to_markdown(&doc).unwrap();
15098 let round_tripped = markdown_to_adf(&md).unwrap();
15099 let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
15100 .content
15101 .as_ref()
15102 .unwrap()[0];
15103 let inlines = para.content.as_ref().unwrap();
15104 assert!(
15105 inlines.len() >= 2,
15106 "should have mention + trailing space, got {} nodes",
15107 inlines.len()
15108 );
15109 assert_eq!(inlines.last().unwrap().text.as_deref(), Some(" "));
15110 }
15111
15112 #[test]
15113 fn trailing_space_in_ordered_list_item_preserved() {
15114 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15116 {"type":"listItem","content":[{"type":"paragraph","content":[
15117 {"type":"text","text":"item "},
15118 {"type":"text","text":"link","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
15119 {"type":"text","text":" "}
15120 ]}]}
15121 ]}]}"#;
15122 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15123 let md = adf_to_markdown(&doc).unwrap();
15124 let round_tripped = markdown_to_adf(&md).unwrap();
15125 let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
15126 .content
15127 .as_ref()
15128 .unwrap()[0];
15129 let inlines = para.content.as_ref().unwrap();
15130 let last = inlines.last().unwrap();
15131 assert_eq!(
15132 last.text.as_deref(),
15133 Some(" "),
15134 "trailing space should be preserved in ordered list item"
15135 );
15136 }
15137
15138 #[test]
15139 fn trailing_space_in_heading_text_preserved() {
15140 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[
15142 {"type":"text","text":"Firefighting Engineers "}
15143 ]}]}"#;
15144 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15145 let md = adf_to_markdown(&doc).unwrap();
15146 let round_tripped = markdown_to_adf(&md).unwrap();
15147 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15148 assert_eq!(
15149 inlines[0].text.as_deref(),
15150 Some("Firefighting Engineers "),
15151 "trailing space in heading should be preserved"
15152 );
15153 }
15154
15155 #[test]
15156 fn trailing_space_in_heading_before_bold_preserved() {
15157 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
15159 {"type":"text","text":"Classic "},
15160 {"type":"text","text":"bold","marks":[{"type":"strong"}]}
15161 ]}]}"#;
15162 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15163 let md = adf_to_markdown(&doc).unwrap();
15164 let round_tripped = markdown_to_adf(&md).unwrap();
15165 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15166 assert_eq!(
15167 inlines[0].text.as_deref(),
15168 Some("Classic "),
15169 "trailing space in heading text before bold should be preserved"
15170 );
15171 }
15172
15173 #[test]
15174 fn leading_space_in_heading_text_preserved() {
15175 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":3},"content":[
15177 {"type":"text","text":" #general-channel"}
15178 ]}]}"#;
15179 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15180 let md = adf_to_markdown(&doc).unwrap();
15181 let round_tripped = markdown_to_adf(&md).unwrap();
15182 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15183 assert_eq!(
15184 inlines[0].text.as_deref(),
15185 Some(" #general-channel"),
15186 "leading spaces in heading text should be preserved"
15187 );
15188 }
15189
15190 #[test]
15191 fn leading_space_in_heading_before_bold_preserved() {
15192 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
15194 {"type":"text","text":" indented"},
15195 {"type":"text","text":" bold","marks":[{"type":"strong"}]}
15196 ]}]}"#;
15197 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15198 let md = adf_to_markdown(&doc).unwrap();
15199 let round_tripped = markdown_to_adf(&md).unwrap();
15200 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15201 assert_eq!(
15202 inlines[0].text.as_deref(),
15203 Some(" indented"),
15204 "leading spaces in heading text before bold should be preserved"
15205 );
15206 }
15207
15208 #[test]
15209 fn heading_multiple_leading_spaces_markdown_parse() {
15210 let md = "### \t #general-channel";
15212 let doc = markdown_to_adf(md).unwrap();
15213 let inlines = doc.content[0].content.as_ref().unwrap();
15214 assert_eq!(
15215 inlines[0].text.as_deref(),
15216 Some("\t #general-channel"),
15217 "leading whitespace in heading text should be preserved during JFM parsing"
15218 );
15219 }
15220
15221 #[test]
15222 fn trailing_space_in_paragraph_text_preserved() {
15223 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15225 {"type":"text","text":"word followed by space "},
15226 {"type":"text","text":"next node","marks":[{"type":"strong"}]}
15227 ]}]}"#;
15228 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15229 let md = adf_to_markdown(&doc).unwrap();
15230 let round_tripped = markdown_to_adf(&md).unwrap();
15231 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15232 assert_eq!(
15233 inlines[0].text.as_deref(),
15234 Some("word followed by space "),
15235 "trailing space in paragraph text should be preserved"
15236 );
15237 }
15238
15239 #[test]
15240 fn nested_bullet_list_roundtrip() {
15241 let adf_doc = serde_json::json!({
15243 "type": "doc",
15244 "version": 1,
15245 "content": [{
15246 "type": "bulletList",
15247 "content": [{
15248 "type": "listItem",
15249 "content": [
15250 {
15251 "type": "paragraph",
15252 "content": [{"type": "text", "text": "parent item"}]
15253 },
15254 {
15255 "type": "bulletList",
15256 "content": [
15257 {
15258 "type": "listItem",
15259 "content": [{
15260 "type": "paragraph",
15261 "content": [{"type": "text", "text": "sub item 1"}]
15262 }]
15263 },
15264 {
15265 "type": "listItem",
15266 "content": [{
15267 "type": "paragraph",
15268 "content": [{"type": "text", "text": "sub item 2"}]
15269 }]
15270 }
15271 ]
15272 }
15273 ]
15274 }]
15275 }]
15276 });
15277 let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
15278 let md = adf_to_markdown(&doc).unwrap();
15279 assert!(
15280 md.contains("- parent item\n"),
15281 "expected top-level item in markdown, got: {md}"
15282 );
15283 assert!(
15284 md.contains(" - sub item 1\n"),
15285 "expected indented sub item 1 in markdown, got: {md}"
15286 );
15287 assert!(
15288 md.contains(" - sub item 2\n"),
15289 "expected indented sub item 2 in markdown, got: {md}"
15290 );
15291
15292 let doc2 = markdown_to_adf(&md).unwrap();
15294 let list = &doc2.content[0];
15295 assert_eq!(list.node_type, "bulletList");
15296 let item = &list.content.as_ref().unwrap()[0];
15297 assert_eq!(item.node_type, "listItem");
15298 let item_content = item.content.as_ref().unwrap();
15299 assert_eq!(
15300 item_content.len(),
15301 2,
15302 "listItem should have paragraph + nested list"
15303 );
15304 assert_eq!(item_content[0].node_type, "paragraph");
15305 assert_eq!(item_content[1].node_type, "bulletList");
15306 let sub_items = item_content[1].content.as_ref().unwrap();
15307 assert_eq!(sub_items.len(), 2);
15308 }
15309
15310 #[test]
15311 fn nested_bullet_in_table_cell_roundtrip() {
15312 let md = "::::table\n:::tr\n:::td\n- parent\n - child\n:::\n:::\n::::\n";
15313 let doc = markdown_to_adf(md).unwrap();
15314 let table = &doc.content[0];
15315 let row = &table.content.as_ref().unwrap()[0];
15316 let cell = &row.content.as_ref().unwrap()[0];
15317 let list = &cell.content.as_ref().unwrap()[0];
15318 assert_eq!(list.node_type, "bulletList");
15319 let item = &list.content.as_ref().unwrap()[0];
15320 let item_content = item.content.as_ref().unwrap();
15321 assert_eq!(
15322 item_content.len(),
15323 2,
15324 "listItem should have paragraph + nested list"
15325 );
15326 assert_eq!(item_content[1].node_type, "bulletList");
15327
15328 let md2 = adf_to_markdown(&doc).unwrap();
15330 assert!(
15331 md2.contains(" - child"),
15332 "expected indented child in round-tripped markdown, got: {md2}"
15333 );
15334 }
15335
15336 #[test]
15337 fn nested_ordered_list_roundtrip() {
15338 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15340 {"type":"listItem","content":[
15341 {"type":"paragraph","content":[{"type":"text","text":"Top level"}]},
15342 {"type":"orderedList","attrs":{"order":1},"content":[
15343 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 1"}]}]},
15344 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 2"}]}]}
15345 ]}
15346 ]},
15347 {"type":"listItem","content":[
15348 {"type":"paragraph","content":[{"type":"text","text":"Second top"}]}
15349 ]}
15350 ]}]}"#;
15351 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15352 let md = adf_to_markdown(&doc).unwrap();
15353 let round_tripped = markdown_to_adf(&md).unwrap();
15354
15355 let outer = &round_tripped.content[0];
15357 assert_eq!(outer.node_type, "orderedList");
15358 assert_eq!(
15359 outer.attrs.as_ref().unwrap()["order"],
15360 1,
15361 "explicit order=1 must be preserved via trailing {{order=1}} (issue #547)"
15362 );
15363 let outer_items = outer.content.as_ref().unwrap();
15364 assert_eq!(
15365 outer_items.len(),
15366 2,
15367 "outer list should have 2 items, got {}",
15368 outer_items.len()
15369 );
15370
15371 let first_item = &outer_items[0];
15373 let first_content = first_item.content.as_ref().unwrap();
15374 assert_eq!(
15375 first_content.len(),
15376 2,
15377 "first listItem should have paragraph + nested list, got {}",
15378 first_content.len()
15379 );
15380 assert_eq!(first_content[0].node_type, "paragraph");
15381 assert_eq!(first_content[1].node_type, "orderedList");
15382 let nested_items = first_content[1].content.as_ref().unwrap();
15383 assert_eq!(nested_items.len(), 2, "nested list should have 2 items");
15384 }
15385
15386 #[test]
15387 fn nested_ordered_list_markdown_parsing() {
15388 let md = "1. Top level\n 1. Nested 1\n 2. Nested 2\n2. Second top\n";
15390 let doc = markdown_to_adf(md).unwrap();
15391 let outer = &doc.content[0];
15392 assert_eq!(outer.node_type, "orderedList");
15393 let outer_items = outer.content.as_ref().unwrap();
15394 assert_eq!(outer_items.len(), 2, "should have 2 top-level items");
15395
15396 let first_content = outer_items[0].content.as_ref().unwrap();
15397 assert_eq!(
15398 first_content.len(),
15399 2,
15400 "first item should have paragraph + nested list"
15401 );
15402 assert_eq!(first_content[1].node_type, "orderedList");
15403 }
15404
15405 #[test]
15406 fn bullet_list_nested_inside_ordered_list() {
15407 let md = "1. Ordered item\n - Bullet child 1\n - Bullet child 2\n2. Second ordered\n";
15409 let doc = markdown_to_adf(md).unwrap();
15410 let outer = &doc.content[0];
15411 assert_eq!(outer.node_type, "orderedList");
15412 let outer_items = outer.content.as_ref().unwrap();
15413 assert_eq!(outer_items.len(), 2);
15414
15415 let first_content = outer_items[0].content.as_ref().unwrap();
15416 assert_eq!(
15417 first_content.len(),
15418 2,
15419 "first item should have paragraph + nested list"
15420 );
15421 assert_eq!(first_content[1].node_type, "bulletList");
15422 let sub_items = first_content[1].content.as_ref().unwrap();
15423 assert_eq!(sub_items.len(), 2, "nested bullet list should have 2 items");
15424 }
15425
15426 #[test]
15427 fn ordered_list_order_attr_one_is_elided() {
15428 let md = "1. A\n2. B\n";
15432 let doc = markdown_to_adf(md).unwrap();
15433 assert!(
15434 doc.content[0].attrs.is_none(),
15435 "attrs should be elided when order=1"
15436 );
15437
15438 let md2 = adf_to_markdown(&doc).unwrap();
15440 let doc2 = markdown_to_adf(&md2).unwrap();
15441 assert!(
15442 doc2.content[0].attrs.is_none(),
15443 "attrs should remain elided after round-trip"
15444 );
15445 }
15446
15447 #[test]
15448 fn issue_547_ordered_list_no_attrs_roundtrip_byte_identical() {
15449 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"First item"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Second item"}]}]}]}]}"#;
15452 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15453 let md = adf_to_markdown(&doc).unwrap();
15454 let rt = markdown_to_adf(&md).unwrap();
15455 assert!(
15456 rt.content[0].attrs.is_none(),
15457 "round-tripped orderedList should not have attrs, got: {:?}",
15458 rt.content[0].attrs
15459 );
15460
15461 let rt_json = serde_json::to_string(&rt).unwrap();
15463 assert!(
15464 !rt_json.contains("\"order\""),
15465 "round-tripped JSON should not contain \"order\", got: {rt_json}"
15466 );
15467 }
15468
15469 fn assert_roundtrip_byte_identical(adf_json: &str) {
15475 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15476 let md = adf_to_markdown(&doc).unwrap();
15477 let rt = markdown_to_adf(&md).unwrap();
15478
15479 let canonical_src: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15480 let canonical_rt: serde_json::Value =
15481 serde_json::from_str(&serde_json::to_string(&rt).unwrap()).unwrap();
15482 assert_eq!(
15483 canonical_src, canonical_rt,
15484 "round-trip diverged\n src: {canonical_src}\n rt: {canonical_rt}\n md: {md:?}"
15485 );
15486 }
15487
15488 #[test]
15489 fn issue_547_single_item_no_attrs_roundtrip() {
15490 assert_roundtrip_byte_identical(
15491 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"only"}]}]}]}]}"#,
15492 );
15493 }
15494
15495 #[test]
15496 fn issue_547_many_items_no_attrs_roundtrip() {
15497 assert_roundtrip_byte_identical(
15498 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"A"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"B"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"C"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"D"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"E"}]}]}]}]}"#,
15499 );
15500 }
15501
15502 #[test]
15503 fn issue_547_non_default_order_preserved() {
15504 assert_roundtrip_byte_identical(
15507 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":5},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"fifth"}]}]}]}]}"#,
15508 );
15509 }
15510
15511 #[test]
15512 fn issue_547_nested_ordered_in_ordered_no_attrs_roundtrip() {
15513 assert_roundtrip_byte_identical(
15515 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"outer"}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}]}]}]}]}"#,
15516 );
15517 }
15518
15519 #[test]
15520 fn issue_547_ordered_nested_in_bullet_no_attrs_roundtrip() {
15521 assert_roundtrip_byte_identical(
15522 r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"bullet"}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"nested"}]}]}]}]}]}]}"#,
15523 );
15524 }
15525
15526 #[test]
15527 fn issue_547_bullet_nested_in_ordered_no_attrs_roundtrip() {
15528 assert_roundtrip_byte_identical(
15529 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"outer"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"nested"}]}]}]}]}]}]}"#,
15530 );
15531 }
15532
15533 #[test]
15534 fn issue_547_ordered_list_between_paragraphs_roundtrip() {
15535 assert_roundtrip_byte_identical(
15536 r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"intro"}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item"}]}]}]},{"type":"paragraph","content":[{"type":"text","text":"outro"}]}]}"#,
15537 );
15538 }
15539
15540 #[test]
15541 fn issue_547_ordered_list_with_marked_text_roundtrip() {
15542 assert_roundtrip_byte_identical(
15543 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"bold","marks":[{"type":"strong"}]}]}]}]}]}"#,
15544 );
15545 }
15546
15547 #[test]
15548 fn issue_547_ordered_list_with_link_roundtrip() {
15549 assert_roundtrip_byte_identical(
15550 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"site","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]}]}]}]}"#,
15551 );
15552 }
15553
15554 #[test]
15555 fn issue_547_ordered_list_with_hardbreak_roundtrip() {
15556 assert_roundtrip_byte_identical(
15557 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"},{"type":"hardBreak"},{"type":"text","text":"b"}]}]}]}]}"#,
15558 );
15559 }
15560
15561 #[test]
15562 fn issue_547_triple_nested_ordered_roundtrip() {
15563 assert_roundtrip_byte_identical(
15564 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"L1"}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"L2"}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"L3"}]}]}]}]}]}]}]}]}"#,
15565 );
15566 }
15567
15568 #[test]
15569 fn issue_547_ordered_list_heading_rule_mix_roundtrip() {
15570 assert_roundtrip_byte_identical(
15571 r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Title"}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]},{"type":"rule"}]}"#,
15572 );
15573 }
15574
15575 #[test]
15576 fn issue_547_ordered_list_listitem_localid_roundtrip() {
15577 assert_roundtrip_byte_identical(
15579 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]}]}"#,
15580 );
15581 }
15582
15583 #[test]
15584 fn issue_547_explicit_order_one_preserved_roundtrip() {
15585 assert_roundtrip_byte_identical(
15590 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"First item"}]}]}]}]}"#,
15591 );
15592 }
15593
15594 #[test]
15595 fn issue_547_explicit_order_one_nested_preserved_roundtrip() {
15596 assert_roundtrip_byte_identical(
15599 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"outer"}]},{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}]}]}]}]}"#,
15600 );
15601 }
15602
15603 #[test]
15604 fn issue_547_mixed_explicit_and_implicit_order_roundtrip() {
15605 assert_roundtrip_byte_identical(
15608 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}]}]}"#,
15609 );
15610 }
15611
15612 #[test]
15613 fn issue_547_explicit_order_one_with_listitem_localid_roundtrip() {
15614 assert_roundtrip_byte_identical(
15618 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","attrs":{"localId":"li-1"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]}]}"#,
15619 );
15620 }
15621
15622 #[test]
15623 fn issue_547_order_attr_signal_appears_only_for_explicit_one() {
15624 let no_attrs = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
15628 let explicit_one = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
15629 let order_five = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":5},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
15630
15631 let md_no =
15632 adf_to_markdown(&serde_json::from_str::<AdfDocument>(no_attrs).unwrap()).unwrap();
15633 let md_one =
15634 adf_to_markdown(&serde_json::from_str::<AdfDocument>(explicit_one).unwrap()).unwrap();
15635 let md_five =
15636 adf_to_markdown(&serde_json::from_str::<AdfDocument>(order_five).unwrap()).unwrap();
15637
15638 assert!(
15639 !md_no.contains("{order="),
15640 "no-attrs source must not emit order signal, got: {md_no:?}"
15641 );
15642 assert!(
15643 md_one.contains("{order=1}"),
15644 "explicit order=1 must emit trailing signal, got: {md_one:?}"
15645 );
15646 assert!(
15647 !md_five.contains("{order="),
15648 "order=5 is already encoded by marker; must not emit signal, got: {md_five:?}"
15649 );
15650 }
15651
15652 #[test]
15655 fn file_media_roundtrip() {
15656 let adf_doc = serde_json::json!({
15658 "type": "doc",
15659 "version": 1,
15660 "content": [{
15661 "type": "mediaSingle",
15662 "attrs": {"layout": "center"},
15663 "content": [{
15664 "type": "media",
15665 "attrs": {
15666 "type": "file",
15667 "id": "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d",
15668 "collection": "contentId-8220672100",
15669 "height": 56,
15670 "width": 312,
15671 "alt": "Screenshot.png"
15672 }
15673 }]
15674 }]
15675 });
15676 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15677 let md = adf_to_markdown(&doc).unwrap();
15678 assert!(
15679 md.contains("type=file"),
15680 "expected type=file in markdown, got: {md}"
15681 );
15682 assert!(
15683 md.contains("id=6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d"),
15684 "expected id in markdown, got: {md}"
15685 );
15686 assert!(
15687 md.contains("collection=contentId-8220672100"),
15688 "expected collection in markdown, got: {md}"
15689 );
15690 let doc2 = markdown_to_adf(&md).unwrap();
15692 let ms = &doc2.content[0];
15693 assert_eq!(ms.node_type, "mediaSingle");
15694 let media = &ms.content.as_ref().unwrap()[0];
15695 assert_eq!(media.node_type, "media");
15696 let attrs = media.attrs.as_ref().unwrap();
15697 assert_eq!(attrs["type"], "file");
15698 assert_eq!(attrs["id"], "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d");
15699 assert_eq!(attrs["collection"], "contentId-8220672100");
15700 assert_eq!(attrs["height"], 56);
15701 assert_eq!(attrs["width"], 312);
15702 assert_eq!(attrs["alt"], "Screenshot.png");
15703 }
15704
15705 #[test]
15709 fn file_media_roundtrip_issue_550_reproducer() {
15710 let adf_json = r#"{
15711 "version": 1,
15712 "type": "doc",
15713 "content": [
15714 {
15715 "type": "mediaSingle",
15716 "attrs": {"layout": "center"},
15717 "content": [
15718 {
15719 "type": "media",
15720 "attrs": {
15721 "type": "file",
15722 "id": "abc-123-def-456",
15723 "collection": "my-collection",
15724 "width": 941,
15725 "height": 655
15726 }
15727 }
15728 ]
15729 }
15730 ]
15731 }"#;
15732 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15733 let md = adf_to_markdown(&doc).unwrap();
15734 let rt = markdown_to_adf(&md).unwrap();
15735 let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15736 let actual = serde_json::to_value(&rt).unwrap();
15737 assert_eq!(
15738 actual, expected,
15739 "roundtrip should preserve file media attrs; md was:\n{md}"
15740 );
15741 }
15742
15743 #[test]
15749 fn file_media_roundtrip_id_with_spaces() {
15750 let adf_json = r#"{
15751 "version": 1,
15752 "type": "doc",
15753 "content": [
15754 {
15755 "type": "mediaSingle",
15756 "attrs": {"layout": "center"},
15757 "content": [
15758 {
15759 "type": "media",
15760 "attrs": {
15761 "type": "file",
15762 "id": "abc 123 def 456",
15763 "collection": "my-collection",
15764 "width": 800,
15765 "height": 600
15766 }
15767 }
15768 ]
15769 }
15770 ]
15771 }"#;
15772 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15773 let md = adf_to_markdown(&doc).unwrap();
15774 assert!(
15775 md.contains(r#"id="abc 123 def 456""#),
15776 "id with spaces should be quoted in JFM, got:\n{md}"
15777 );
15778 let rt = markdown_to_adf(&md).unwrap();
15779 let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15780 let actual = serde_json::to_value(&rt).unwrap();
15781 assert_eq!(
15782 actual, expected,
15783 "space-containing id must round-trip; md was:\n{md}"
15784 );
15785 }
15786
15787 #[test]
15789 fn file_media_roundtrip_collection_with_spaces() {
15790 let adf_json = r#"{
15791 "version": 1,
15792 "type": "doc",
15793 "content": [
15794 {
15795 "type": "mediaSingle",
15796 "attrs": {"layout": "center"},
15797 "content": [
15798 {
15799 "type": "media",
15800 "attrs": {
15801 "type": "file",
15802 "id": "abc-123",
15803 "collection": "my collection with spaces"
15804 }
15805 }
15806 ]
15807 }
15808 ]
15809 }"#;
15810 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15811 let md = adf_to_markdown(&doc).unwrap();
15812 let rt = markdown_to_adf(&md).unwrap();
15813 let media = &rt.content[0].content.as_ref().unwrap()[0];
15814 assert_eq!(
15815 media.attrs.as_ref().unwrap()["collection"],
15816 "my collection with spaces"
15817 );
15818 }
15819
15820 #[test]
15822 fn file_media_roundtrip_occurrence_key_with_spaces() {
15823 let adf_json = r#"{
15824 "version": 1,
15825 "type": "doc",
15826 "content": [
15827 {
15828 "type": "mediaSingle",
15829 "attrs": {"layout": "center"},
15830 "content": [
15831 {
15832 "type": "media",
15833 "attrs": {
15834 "type": "file",
15835 "id": "x",
15836 "collection": "y",
15837 "occurrenceKey": "key with spaces"
15838 }
15839 }
15840 ]
15841 }
15842 ]
15843 }"#;
15844 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15845 let md = adf_to_markdown(&doc).unwrap();
15846 let rt = markdown_to_adf(&md).unwrap();
15847 let media = &rt.content[0].content.as_ref().unwrap()[0];
15848 assert_eq!(
15849 media.attrs.as_ref().unwrap()["occurrenceKey"],
15850 "key with spaces"
15851 );
15852 }
15853
15854 #[test]
15856 fn file_media_roundtrip_id_with_quote_char() {
15857 let adf_json = r#"{
15858 "version": 1,
15859 "type": "doc",
15860 "content": [
15861 {
15862 "type": "mediaSingle",
15863 "attrs": {"layout": "center"},
15864 "content": [
15865 {
15866 "type": "media",
15867 "attrs": {
15868 "type": "file",
15869 "id": "a\"b\"c",
15870 "collection": "col"
15871 }
15872 }
15873 ]
15874 }
15875 ]
15876 }"#;
15877 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15878 let md = adf_to_markdown(&doc).unwrap();
15879 let rt = markdown_to_adf(&md).unwrap();
15880 let media = &rt.content[0].content.as_ref().unwrap()[0];
15881 assert_eq!(media.attrs.as_ref().unwrap()["id"], "a\"b\"c");
15882 }
15883
15884 #[test]
15887 fn media_inline_roundtrip_id_with_spaces() {
15888 let adf_json = r#"{
15889 "version": 1,
15890 "type": "doc",
15891 "content": [
15892 {
15893 "type": "paragraph",
15894 "content": [
15895 {"type": "text", "text": "before "},
15896 {
15897 "type": "mediaInline",
15898 "attrs": {
15899 "type": "file",
15900 "id": "a b c",
15901 "collection": "my col"
15902 }
15903 },
15904 {"type": "text", "text": " after"}
15905 ]
15906 }
15907 ]
15908 }"#;
15909 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15910 let md = adf_to_markdown(&doc).unwrap();
15911 let rt = markdown_to_adf(&md).unwrap();
15912 let inline = &rt.content[0].content.as_ref().unwrap()[1];
15913 assert_eq!(inline.node_type, "mediaInline");
15914 let attrs = inline.attrs.as_ref().unwrap();
15915 assert_eq!(attrs["id"], "a b c");
15916 assert_eq!(attrs["collection"], "my col");
15917 }
15918
15919 #[test]
15922 fn file_media_roundtrip_preserves_occurrence_key() {
15923 let adf_json = r#"{
15924 "version": 1,
15925 "type": "doc",
15926 "content": [
15927 {
15928 "type": "mediaSingle",
15929 "attrs": {"layout": "center"},
15930 "content": [
15931 {
15932 "type": "media",
15933 "attrs": {
15934 "type": "file",
15935 "id": "abc-123",
15936 "collection": "my-collection",
15937 "occurrenceKey": "unique-key-xyz",
15938 "width": 200,
15939 "height": 100
15940 }
15941 }
15942 ]
15943 }
15944 ]
15945 }"#;
15946 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15947 let md = adf_to_markdown(&doc).unwrap();
15948 assert!(
15949 md.contains("occurrenceKey=unique-key-xyz"),
15950 "expected occurrenceKey in markdown, got: {md}"
15951 );
15952 let rt = markdown_to_adf(&md).unwrap();
15953 let media = &rt.content[0].content.as_ref().unwrap()[0];
15954 let attrs = media.attrs.as_ref().unwrap();
15955 assert_eq!(attrs["occurrenceKey"], "unique-key-xyz");
15956 assert_eq!(attrs["type"], "file");
15957 assert_eq!(attrs["id"], "abc-123");
15958 assert_eq!(attrs["collection"], "my-collection");
15959 }
15960
15961 #[test]
15964 fn media_single_caption_adf_to_markdown() {
15965 let adf_doc = serde_json::json!({
15966 "type": "doc",
15967 "version": 1,
15968 "content": [{
15969 "type": "mediaSingle",
15970 "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
15971 "content": [
15972 {
15973 "type": "media",
15974 "attrs": {
15975 "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
15976 "type": "file",
15977 "collection": "contentId-123456",
15978 "width": 800,
15979 "height": 600
15980 }
15981 },
15982 {
15983 "type": "caption",
15984 "content": [{"type": "text", "text": "An image caption here"}]
15985 }
15986 ]
15987 }]
15988 });
15989 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15990 let md = adf_to_markdown(&doc).unwrap();
15991 assert!(
15992 md.contains(":::caption"),
15993 "expected :::caption in markdown, got: {md}"
15994 );
15995 assert!(
15996 md.contains("An image caption here"),
15997 "expected caption text in markdown, got: {md}"
15998 );
15999 }
16000
16001 #[test]
16002 fn media_single_caption_markdown_to_adf() {
16003 let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nAn image caption here\n:::\n";
16004 let doc = markdown_to_adf(md).unwrap();
16005 let ms = &doc.content[0];
16006 assert_eq!(ms.node_type, "mediaSingle");
16007 let content = ms.content.as_ref().unwrap();
16008 assert_eq!(content.len(), 2, "expected media + caption children");
16009 assert_eq!(content[0].node_type, "media");
16010 assert_eq!(content[1].node_type, "caption");
16011 let caption_content = content[1].content.as_ref().unwrap();
16012 assert_eq!(
16013 caption_content[0].text.as_deref(),
16014 Some("An image caption here")
16015 );
16016 }
16017
16018 #[test]
16019 fn media_single_caption_round_trip() {
16020 let adf_doc = serde_json::json!({
16022 "type": "doc",
16023 "version": 1,
16024 "content": [{
16025 "type": "mediaSingle",
16026 "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
16027 "content": [
16028 {
16029 "type": "media",
16030 "attrs": {
16031 "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
16032 "type": "file",
16033 "collection": "contentId-123456",
16034 "width": 800,
16035 "height": 600
16036 }
16037 },
16038 {
16039 "type": "caption",
16040 "content": [{"type": "text", "text": "An image caption here"}]
16041 }
16042 ]
16043 }]
16044 });
16045 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16046 let md = adf_to_markdown(&doc).unwrap();
16047 let doc2 = markdown_to_adf(&md).unwrap();
16048 let ms = &doc2.content[0];
16049 assert_eq!(ms.node_type, "mediaSingle");
16050 let content = ms.content.as_ref().unwrap();
16051 assert_eq!(
16052 content.len(),
16053 2,
16054 "expected media + caption after round-trip"
16055 );
16056 assert_eq!(content[1].node_type, "caption");
16057 let caption_content = content[1].content.as_ref().unwrap();
16058 assert_eq!(
16059 caption_content[0].text.as_deref(),
16060 Some("An image caption here")
16061 );
16062 }
16063
16064 #[test]
16065 fn media_single_caption_with_inline_marks() {
16066 let adf_doc = serde_json::json!({
16067 "type": "doc",
16068 "version": 1,
16069 "content": [{
16070 "type": "mediaSingle",
16071 "attrs": {"layout": "center"},
16072 "content": [
16073 {
16074 "type": "media",
16075 "attrs": {"type": "external", "url": "https://example.com/img.png"}
16076 },
16077 {
16078 "type": "caption",
16079 "content": [
16080 {"type": "text", "text": "A "},
16081 {"type": "text", "text": "bold", "marks": [{"type": "strong"}]},
16082 {"type": "text", "text": " caption"}
16083 ]
16084 }
16085 ]
16086 }]
16087 });
16088 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16089 let md = adf_to_markdown(&doc).unwrap();
16090 assert!(
16091 md.contains("**bold**"),
16092 "expected bold in caption, got: {md}"
16093 );
16094
16095 let doc2 = markdown_to_adf(&md).unwrap();
16096 let content = doc2.content[0].content.as_ref().unwrap();
16097 assert_eq!(content.len(), 2, "expected media + caption");
16098 assert_eq!(content[1].node_type, "caption");
16099 let caption_inlines = content[1].content.as_ref().unwrap();
16100 let bold_node = caption_inlines
16101 .iter()
16102 .find(|n| n.text.as_deref() == Some("bold"))
16103 .unwrap();
16104 let marks = bold_node.marks.as_ref().unwrap();
16105 assert_eq!(marks[0].mark_type, "strong");
16106 }
16107
16108 #[test]
16109 fn media_single_no_caption_unaffected() {
16110 let adf_doc = serde_json::json!({
16112 "type": "doc",
16113 "version": 1,
16114 "content": [{
16115 "type": "mediaSingle",
16116 "attrs": {"layout": "center"},
16117 "content": [{
16118 "type": "media",
16119 "attrs": {"type": "external", "url": "https://example.com/img.png"}
16120 }]
16121 }]
16122 });
16123 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16124 let md = adf_to_markdown(&doc).unwrap();
16125 assert!(
16126 !md.contains(":::caption"),
16127 "should not emit caption when none present"
16128 );
16129 let doc2 = markdown_to_adf(&md).unwrap();
16130 let content = doc2.content[0].content.as_ref().unwrap();
16131 assert_eq!(content.len(), 1, "should only have media child");
16132 assert_eq!(content[0].node_type, "media");
16133 }
16134
16135 #[test]
16136 fn media_single_empty_caption_round_trip() {
16137 let adf_doc = serde_json::json!({
16139 "type": "doc",
16140 "version": 1,
16141 "content": [{
16142 "type": "mediaSingle",
16143 "attrs": {"layout": "center"},
16144 "content": [
16145 {
16146 "type": "media",
16147 "attrs": {"type": "external", "url": "https://example.com/img.png"}
16148 },
16149 {
16150 "type": "caption"
16151 }
16152 ]
16153 }]
16154 });
16155 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16156 let md = adf_to_markdown(&doc).unwrap();
16157 assert!(
16158 md.contains(":::caption"),
16159 "expected :::caption even for empty caption, got: {md}"
16160 );
16161 assert!(
16162 md.contains(":::\n"),
16163 "expected closing ::: fence, got: {md}"
16164 );
16165 }
16166
16167 #[test]
16168 fn media_single_external_caption_round_trip() {
16169 let md = "\n:::caption\nImage description\n:::\n";
16171 let doc = markdown_to_adf(md).unwrap();
16172 let ms = &doc.content[0];
16173 assert_eq!(ms.node_type, "mediaSingle");
16174 let content = ms.content.as_ref().unwrap();
16175 assert_eq!(content.len(), 2);
16176 assert_eq!(content[0].node_type, "media");
16177 assert_eq!(content[1].node_type, "caption");
16178
16179 let md2 = adf_to_markdown(&doc).unwrap();
16180 let doc2 = markdown_to_adf(&md2).unwrap();
16181 let content2 = doc2.content[0].content.as_ref().unwrap();
16182 assert_eq!(content2.len(), 2);
16183 assert_eq!(content2[1].node_type, "caption");
16184 let caption_text = content2[1].content.as_ref().unwrap();
16185 assert_eq!(caption_text[0].text.as_deref(), Some("Image description"));
16186 }
16187
16188 #[test]
16191 fn media_single_caption_localid_roundtrip() {
16192 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"}]}]}]}"#;
16193 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16194 let md = adf_to_markdown(&doc).unwrap();
16195 assert!(
16196 md.contains("localId=9da8c2104471"),
16197 "caption localId should appear in markdown: {md}"
16198 );
16199 let rt = markdown_to_adf(&md).unwrap();
16200 let content = rt.content[0].content.as_ref().unwrap();
16201 let caption = &content[1];
16202 assert_eq!(caption.node_type, "caption");
16203 assert_eq!(
16204 caption.attrs.as_ref().unwrap()["localId"],
16205 "9da8c2104471",
16206 "caption localId should round-trip"
16207 );
16208 }
16209
16210 #[test]
16211 fn media_single_caption_without_localid() {
16212 let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nPlain caption\n:::\n";
16213 let doc = markdown_to_adf(md).unwrap();
16214 let caption = &doc.content[0].content.as_ref().unwrap()[1];
16215 assert_eq!(caption.node_type, "caption");
16216 assert!(
16217 caption.attrs.is_none(),
16218 "caption without localId should not gain attrs"
16219 );
16220 let md2 = adf_to_markdown(&doc).unwrap();
16221 assert!(
16222 !md2.contains("localId"),
16223 "no localId should appear in output: {md2}"
16224 );
16225 }
16226
16227 #[test]
16228 fn media_single_caption_localid_stripped_when_option_set() {
16229 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"}]}]}]}"#;
16230 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16231 let opts = RenderOptions {
16232 strip_local_ids: true,
16233 ..Default::default()
16234 };
16235 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16236 assert!(!md.contains("localId"), "localId should be stripped: {md}");
16237 }
16238
16239 #[test]
16240 fn table_width_roundtrip() {
16241 let adf_doc = serde_json::json!({
16243 "type": "doc",
16244 "version": 1,
16245 "content": [{
16246 "type": "table",
16247 "attrs": {"layout": "default", "width": 760.0},
16248 "content": [{
16249 "type": "tableRow",
16250 "content": [{
16251 "type": "tableHeader",
16252 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16253 }]
16254 }]
16255 }]
16256 });
16257 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16258 let md = adf_to_markdown(&doc).unwrap();
16259 assert!(
16260 md.contains("width=760.0"),
16261 "expected width=760.0 in markdown (float preserved), got: {md}"
16262 );
16263 let doc2 = markdown_to_adf(&md).unwrap();
16265 let table = &doc2.content[0];
16266 assert_eq!(table.node_type, "table");
16267 let table_attrs = table.attrs.as_ref().unwrap();
16268 assert_eq!(table_attrs["width"], 760.0);
16269 assert!(
16270 table_attrs["width"].is_f64(),
16271 "expected float width to be preserved as f64, got: {:?}",
16272 table_attrs["width"]
16273 );
16274 }
16275
16276 #[test]
16277 fn table_integer_width_roundtrip_preserves_integer() {
16278 let adf_doc = serde_json::json!({
16281 "type": "doc",
16282 "version": 1,
16283 "content": [{
16284 "type": "table",
16285 "attrs": {
16286 "isNumberColumnEnabled": false,
16287 "layout": "center",
16288 "localId": "abc-123",
16289 "width": 1420
16290 },
16291 "content": [{
16292 "type": "tableRow",
16293 "content": [{
16294 "type": "tableCell",
16295 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Cell"}]}]
16296 }]
16297 }]
16298 }]
16299 });
16300 let doc: crate::atlassian::adf::AdfDocument =
16301 serde_json::from_value(adf_doc.clone()).unwrap();
16302 let md = adf_to_markdown(&doc).unwrap();
16303 assert!(
16304 md.contains("width=1420"),
16305 "expected width=1420 in markdown, got: {md}"
16306 );
16307 assert!(
16308 !md.contains("width=1420.0"),
16309 "integer width should not be rendered with decimal: {md}"
16310 );
16311
16312 let doc2 = markdown_to_adf(&md).unwrap();
16313 let table = &doc2.content[0];
16314 assert_eq!(table.node_type, "table");
16315 let table_attrs = table.attrs.as_ref().unwrap();
16316 assert_eq!(table_attrs["width"], 1420);
16317 assert!(
16318 table_attrs["width"].is_u64() || table_attrs["width"].is_i64(),
16319 "width should remain an integer, got: {:?}",
16320 table_attrs["width"]
16321 );
16322 assert!(
16323 !table_attrs["width"].is_f64(),
16324 "width should not be a float, got: {:?}",
16325 table_attrs["width"]
16326 );
16327
16328 let roundtripped = serde_json::to_value(&doc2).unwrap();
16330 let orig_width = &adf_doc["content"][0]["attrs"]["width"];
16331 let rt_width = &roundtripped["content"][0]["attrs"]["width"];
16332 assert_eq!(
16333 orig_width, rt_width,
16334 "width value must roundtrip byte-for-byte"
16335 );
16336 }
16337
16338 #[test]
16339 fn table_fractional_width_roundtrip() {
16340 let adf_doc = serde_json::json!({
16342 "type": "doc",
16343 "version": 1,
16344 "content": [{
16345 "type": "table",
16346 "attrs": {"layout": "default", "width": 760.5},
16347 "content": [{
16348 "type": "tableRow",
16349 "content": [{
16350 "type": "tableHeader",
16351 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16352 }]
16353 }]
16354 }]
16355 });
16356 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16357 let md = adf_to_markdown(&doc).unwrap();
16358 assert!(
16359 md.contains("width=760.5"),
16360 "expected width=760.5 in markdown, got: {md}"
16361 );
16362 let doc2 = markdown_to_adf(&md).unwrap();
16363 let table_attrs = doc2.content[0].attrs.as_ref().unwrap();
16364 assert_eq!(table_attrs["width"], 760.5);
16365 assert!(table_attrs["width"].is_f64());
16366 }
16367
16368 #[test]
16369 fn pipe_table_integer_width_roundtrip() {
16370 let md = "| A | B |\n|---|---|\n| 1 | 2 |\n{layout=default width=1420}\n";
16372 let doc = markdown_to_adf(md).unwrap();
16373 let table = &doc.content[0];
16374 assert_eq!(table.node_type, "table");
16375 let attrs = table.attrs.as_ref().unwrap();
16376 assert_eq!(attrs["width"], 1420);
16377 assert!(
16378 attrs["width"].is_u64() || attrs["width"].is_i64(),
16379 "pipe-table width must stay integer, got: {:?}",
16380 attrs["width"]
16381 );
16382 }
16383
16384 #[test]
16385 fn file_media_width_type_roundtrip() {
16386 let adf_doc = serde_json::json!({
16388 "type": "doc",
16389 "version": 1,
16390 "content": [{
16391 "type": "mediaSingle",
16392 "attrs": {"layout": "center", "width": 312, "widthType": "pixel"},
16393 "content": [{
16394 "type": "media",
16395 "attrs": {
16396 "type": "file",
16397 "id": "abc123",
16398 "collection": "contentId-999",
16399 "height": 56,
16400 "width": 312
16401 }
16402 }]
16403 }]
16404 });
16405 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16406 let md = adf_to_markdown(&doc).unwrap();
16407 assert!(
16408 md.contains("widthType=pixel"),
16409 "expected widthType=pixel in markdown, got: {md}"
16410 );
16411 let doc2 = markdown_to_adf(&md).unwrap();
16412 let ms = &doc2.content[0];
16413 let ms_attrs = ms.attrs.as_ref().unwrap();
16414 assert_eq!(ms_attrs["widthType"], "pixel");
16415 assert_eq!(ms_attrs["width"], 312);
16416 }
16417
16418 #[test]
16419 fn file_media_pixel_width_above_100_passes_validation() {
16420 let md = "![alt](){type=file id=11111111-2222-3333-4444-555555555555 collection=contentId-98765 height=904 width=900 mediaWidth=900 widthType=pixel}\n";
16429 let adf = markdown_to_adf(md).unwrap();
16430
16431 let ms_attrs = adf.content[0].attrs.as_ref().unwrap();
16432 assert_eq!(ms_attrs["width"], 900);
16433 assert_eq!(ms_attrs["widthType"], "pixel");
16434
16435 crate::atlassian::adf_validated::validate(&adf).unwrap();
16436 }
16437
16438 #[test]
16439 fn file_media_mode_roundtrip() {
16440 let adf_doc = serde_json::json!({
16442 "type": "doc",
16443 "version": 1,
16444 "content": [{
16445 "type": "mediaSingle",
16446 "attrs": {"layout": "wide", "mode": "wide", "width": 1200},
16447 "content": [{
16448 "type": "media",
16449 "attrs": {
16450 "type": "file",
16451 "id": "abc123",
16452 "collection": "test",
16453 "width": 1200,
16454 "height": 600
16455 }
16456 }]
16457 }]
16458 });
16459 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16460 let md = adf_to_markdown(&doc).unwrap();
16461 assert!(
16462 md.contains("mode=wide"),
16463 "expected mode=wide in markdown, got: {md}"
16464 );
16465 let doc2 = markdown_to_adf(&md).unwrap();
16466 let ms = &doc2.content[0];
16467 let ms_attrs = ms.attrs.as_ref().unwrap();
16468 assert_eq!(ms_attrs["mode"], "wide");
16469 assert_eq!(ms_attrs["layout"], "wide");
16470 assert_eq!(ms_attrs["width"], 1200);
16471 }
16472
16473 #[test]
16474 fn external_media_mode_roundtrip() {
16475 let adf_doc = serde_json::json!({
16477 "type": "doc",
16478 "version": 1,
16479 "content": [{
16480 "type": "mediaSingle",
16481 "attrs": {"layout": "wide", "mode": "wide"},
16482 "content": [{
16483 "type": "media",
16484 "attrs": {
16485 "type": "external",
16486 "url": "https://example.com/image.png"
16487 }
16488 }]
16489 }]
16490 });
16491 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16492 let md = adf_to_markdown(&doc).unwrap();
16493 assert!(
16494 md.contains("mode=wide"),
16495 "expected mode=wide in markdown, got: {md}"
16496 );
16497 let doc2 = markdown_to_adf(&md).unwrap();
16498 let ms = &doc2.content[0];
16499 let ms_attrs = ms.attrs.as_ref().unwrap();
16500 assert_eq!(ms_attrs["mode"], "wide");
16501 assert_eq!(ms_attrs["layout"], "wide");
16502 }
16503
16504 #[test]
16505 fn media_mode_only_roundtrip() {
16506 let adf_doc = serde_json::json!({
16508 "type": "doc",
16509 "version": 1,
16510 "content": [{
16511 "type": "mediaSingle",
16512 "attrs": {"layout": "center", "mode": "default"},
16513 "content": [{
16514 "type": "media",
16515 "attrs": {
16516 "type": "external",
16517 "url": "https://example.com/image.png"
16518 }
16519 }]
16520 }]
16521 });
16522 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16523 let md = adf_to_markdown(&doc).unwrap();
16524 assert!(
16525 md.contains("mode=default"),
16526 "expected mode=default in markdown, got: {md}"
16527 );
16528 let doc2 = markdown_to_adf(&md).unwrap();
16529 let ms = &doc2.content[0];
16530 let ms_attrs = ms.attrs.as_ref().unwrap();
16531 assert_eq!(ms_attrs["mode"], "default");
16532 }
16533
16534 #[test]
16535 fn file_media_hex_localid_roundtrip() {
16536 let adf_doc = serde_json::json!({
16538 "type": "doc",
16539 "version": 1,
16540 "content": [{
16541 "type": "mediaSingle",
16542 "attrs": {"layout": "wide", "width": 1200, "widthType": "pixel"},
16543 "content": [{
16544 "type": "media",
16545 "attrs": {
16546 "type": "file",
16547 "id": "eb7a9c3b-314e-4458-8200-4b22b67b122e",
16548 "collection": "contentId-123",
16549 "height": 484,
16550 "width": 915,
16551 "alt": "image.png",
16552 "localId": "0e79f58ac382"
16553 }
16554 }]
16555 }]
16556 });
16557 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16558 let md = adf_to_markdown(&doc).unwrap();
16559 assert!(
16560 md.contains("localId=0e79f58ac382"),
16561 "expected localId=0e79f58ac382 in markdown, got: {md}"
16562 );
16563 let doc2 = markdown_to_adf(&md).unwrap();
16564 let ms = &doc2.content[0];
16565 let media = &ms.content.as_ref().unwrap()[0];
16566 let attrs = media.attrs.as_ref().unwrap();
16567 assert_eq!(attrs["localId"], "0e79f58ac382");
16568 }
16569
16570 #[test]
16571 fn file_media_uuid_localid_roundtrip() {
16572 let adf_doc = serde_json::json!({
16574 "type": "doc",
16575 "version": 1,
16576 "content": [{
16577 "type": "mediaSingle",
16578 "attrs": {"layout": "center"},
16579 "content": [{
16580 "type": "media",
16581 "attrs": {
16582 "type": "file",
16583 "id": "abc-123",
16584 "collection": "contentId-456",
16585 "height": 100,
16586 "width": 200,
16587 "localId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
16588 }
16589 }]
16590 }]
16591 });
16592 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16593 let md = adf_to_markdown(&doc).unwrap();
16594 assert!(
16595 md.contains("localId=a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
16596 "expected UUID localId in markdown, got: {md}"
16597 );
16598 let doc2 = markdown_to_adf(&md).unwrap();
16599 let media = &doc2.content[0].content.as_ref().unwrap()[0];
16600 let attrs = media.attrs.as_ref().unwrap();
16601 assert_eq!(attrs["localId"], "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
16602 }
16603
16604 #[test]
16605 fn file_media_null_uuid_localid_stripped() {
16606 let adf_doc = serde_json::json!({
16608 "type": "doc",
16609 "version": 1,
16610 "content": [{
16611 "type": "mediaSingle",
16612 "attrs": {"layout": "center"},
16613 "content": [{
16614 "type": "media",
16615 "attrs": {
16616 "type": "file",
16617 "id": "abc-123",
16618 "collection": "contentId-456",
16619 "height": 100,
16620 "width": 200,
16621 "localId": "00000000-0000-0000-0000-000000000000"
16622 }
16623 }]
16624 }]
16625 });
16626 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16627 let md = adf_to_markdown(&doc).unwrap();
16628 assert!(
16629 !md.contains("localId="),
16630 "null UUID localId should be stripped, got: {md}"
16631 );
16632 }
16633
16634 #[test]
16635 fn file_media_localid_stripped_when_option_set() {
16636 let adf_doc = serde_json::json!({
16638 "type": "doc",
16639 "version": 1,
16640 "content": [{
16641 "type": "mediaSingle",
16642 "attrs": {"layout": "center"},
16643 "content": [{
16644 "type": "media",
16645 "attrs": {
16646 "type": "file",
16647 "id": "abc-123",
16648 "collection": "contentId-456",
16649 "height": 100,
16650 "width": 200,
16651 "localId": "0e79f58ac382"
16652 }
16653 }]
16654 }]
16655 });
16656 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16657 let opts = RenderOptions {
16658 strip_local_ids: true,
16659 ..Default::default()
16660 };
16661 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16662 assert!(
16663 !md.contains("localId="),
16664 "localId should be stripped with strip_local_ids, got: {md}"
16665 );
16666 }
16667
16668 #[test]
16669 fn external_media_localid_roundtrip() {
16670 let adf_doc = serde_json::json!({
16672 "type": "doc",
16673 "version": 1,
16674 "content": [{
16675 "type": "mediaSingle",
16676 "attrs": {"layout": "center"},
16677 "content": [{
16678 "type": "media",
16679 "attrs": {
16680 "type": "external",
16681 "url": "https://example.com/image.png",
16682 "alt": "test",
16683 "localId": "deadbeef1234"
16684 }
16685 }]
16686 }]
16687 });
16688 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16689 let md = adf_to_markdown(&doc).unwrap();
16690 assert!(
16691 md.contains("localId=deadbeef1234"),
16692 "expected localId in markdown for external media, got: {md}"
16693 );
16694 let doc2 = markdown_to_adf(&md).unwrap();
16695 let media = &doc2.content[0].content.as_ref().unwrap()[0];
16696 let attrs = media.attrs.as_ref().unwrap();
16697 assert_eq!(attrs["localId"], "deadbeef1234");
16698 }
16699
16700 #[test]
16701 fn bracket_in_text_not_parsed_as_link() {
16702 let md = ":check_mark: [Task] Unable to start trial ([Link](https://example.com/link))";
16704 let doc = markdown_to_adf(md).unwrap();
16705 let para = &doc.content[0];
16706 assert_eq!(para.node_type, "paragraph");
16707 let content = para.content.as_ref().unwrap();
16708 let text_nodes: Vec<_> = content.iter().filter(|n| n.node_type == "text").collect();
16710 let has_task_bracket = text_nodes
16711 .iter()
16712 .any(|n| n.text.as_deref().unwrap_or("").contains("[Task]"));
16713 assert!(
16714 has_task_bracket,
16715 "expected [Task] in plain text, nodes: {content:?}"
16716 );
16717 let link_nodes: Vec<_> = content
16719 .iter()
16720 .filter(|n| {
16721 n.marks
16722 .as_ref()
16723 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
16724 })
16725 .collect();
16726 assert!(!link_nodes.is_empty(), "expected a link node");
16727 assert_eq!(
16728 link_nodes[0].text.as_deref(),
16729 Some("Link"),
16730 "link text should be 'Link'"
16731 );
16732 }
16733
16734 #[test]
16735 fn empty_paragraph_roundtrip() {
16736 let mut adf_in = AdfDocument::new();
16738 adf_in.content = vec![
16739 AdfNode::paragraph(vec![AdfNode::text("before")]),
16740 AdfNode::paragraph(vec![]),
16741 AdfNode::paragraph(vec![AdfNode::text("after")]),
16742 ];
16743 let md = adf_to_markdown(&adf_in).unwrap();
16744 let adf_out = markdown_to_adf(&md).unwrap();
16745 assert_eq!(
16746 adf_out.content.len(),
16747 3,
16748 "should have 3 blocks, markdown:\n{md}"
16749 );
16750 assert_eq!(adf_out.content[0].node_type, "paragraph");
16751 assert_eq!(adf_out.content[1].node_type, "paragraph");
16752 assert!(
16753 adf_out.content[1].content.is_none(),
16754 "middle paragraph should be empty"
16755 );
16756 assert_eq!(adf_out.content[2].node_type, "paragraph");
16757 }
16758
16759 #[test]
16760 fn nbsp_paragraph_roundtrip() {
16761 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}";
16763 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16764 let md = adf_to_markdown(&doc).unwrap();
16765 assert!(
16766 md.contains("::paragraph["),
16767 "NBSP paragraph should use directive form: {md}"
16768 );
16769 let rt = markdown_to_adf(&md).unwrap();
16770 assert_eq!(rt.content.len(), 1, "should have 1 block");
16771 assert_eq!(rt.content[0].node_type, "paragraph");
16772 let text = rt.content[0].content.as_ref().unwrap()[0]
16773 .text
16774 .as_deref()
16775 .unwrap_or("");
16776 assert_eq!(text, "\u{00a0}", "NBSP should survive round-trip");
16777 }
16778
16779 #[test]
16780 fn nbsp_in_nested_expand_roundtrip() {
16781 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"nestedExpand\",\"attrs\":{\"title\":\"Section\"},\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}]}";
16783 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16784 let md = adf_to_markdown(&doc).unwrap();
16785 let rt = markdown_to_adf(&md).unwrap();
16786 let ne = &rt.content[0];
16787 assert_eq!(ne.node_type, "nestedExpand");
16788 let inner = ne.content.as_ref().unwrap();
16789 assert_eq!(inner.len(), 1, "should have 1 inner block");
16790 assert_eq!(inner[0].node_type, "paragraph");
16791 let content = inner[0].content.as_ref().unwrap();
16792 assert!(!content.is_empty(), "paragraph should not be empty");
16793 let text = content[0].text.as_deref().unwrap_or("");
16794 assert_eq!(text, "\u{00a0}", "NBSP should survive in nestedExpand");
16795 }
16796
16797 #[test]
16798 fn nbsp_followed_by_content() {
16799 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\"}]}]}";
16801 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16802 let md = adf_to_markdown(&doc).unwrap();
16803 let rt = markdown_to_adf(&md).unwrap();
16804 assert!(rt.content.len() >= 2, "should have at least 2 blocks");
16805 let after_para = rt.content.iter().find(|n| {
16807 n.node_type == "paragraph"
16808 && n.content
16809 .as_ref()
16810 .and_then(|c| c.first())
16811 .and_then(|n| n.text.as_deref())
16812 .is_some_and(|t| t.contains("after"))
16813 });
16814 assert!(after_para.is_some(), "should have paragraph with 'after'");
16815 }
16816
16817 #[test]
16818 fn nbsp_paragraph_with_marks_survives() {
16819 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\",\"marks\":[{\"type\":\"strong\"}]}]}]}";
16822 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16823 let md = adf_to_markdown(&doc).unwrap();
16824 assert!(md.contains("**"), "should have bold markers: {md}");
16825 let rt = markdown_to_adf(&md).unwrap();
16826 let content = rt.content[0].content.as_ref().unwrap();
16827 assert!(!content.is_empty(), "should preserve content");
16828 }
16829
16830 #[test]
16831 fn regular_paragraph_unchanged() {
16832 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}"#;
16834 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16835 let md = adf_to_markdown(&doc).unwrap();
16836 assert!(
16837 !md.contains("::paragraph"),
16838 "regular paragraphs should not use directive form: {md}"
16839 );
16840 assert!(md.contains("hello"));
16841 }
16842
16843 #[test]
16844 fn paragraph_directive_with_content_parsed() {
16845 let md = "::paragraph[\u{00a0}]\n";
16847 let doc = markdown_to_adf(md).unwrap();
16848 assert_eq!(doc.content.len(), 1);
16849 assert_eq!(doc.content[0].node_type, "paragraph");
16850 let content = doc.content[0].content.as_ref().unwrap();
16851 assert!(!content.is_empty(), "should have inline content");
16852 assert_eq!(content[0].text.as_deref().unwrap(), "\u{00a0}");
16853 }
16854
16855 #[test]
16856 fn nbsp_paragraph_in_list_item_with_nested_list() {
16857 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"}]}]}]}]}]}]}"#;
16859 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16860 let md = adf_to_markdown(&doc).unwrap();
16861 let rt = markdown_to_adf(&md).unwrap();
16862 let list = &rt.content[0];
16863 assert_eq!(list.node_type, "bulletList");
16864 let item = &list.content.as_ref().unwrap()[0];
16865 let item_content = item.content.as_ref().unwrap();
16866 assert_eq!(
16867 item_content.len(),
16868 2,
16869 "listItem should have paragraph + nested list, got: {item_content:?}"
16870 );
16871 let para = &item_content[0];
16872 assert_eq!(para.node_type, "paragraph");
16873 let para_content = para
16874 .content
16875 .as_ref()
16876 .expect("paragraph should have content");
16877 assert!(
16878 !para_content.is_empty(),
16879 "NBSP paragraph content should not be empty"
16880 );
16881 assert_eq!(
16882 para_content[0].text.as_deref().unwrap(),
16883 "\u{00a0}",
16884 "NBSP should survive round-trip inside listItem"
16885 );
16886 }
16887
16888 #[test]
16889 fn nbsp_paragraph_in_list_item_with_local_ids() {
16890 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"}]}]}]}]}]}]}"#;
16892 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16893 let md = adf_to_markdown(&doc).unwrap();
16894 let rt = markdown_to_adf(&md).unwrap();
16895 let list = &rt.content[0];
16896 let item = &list.content.as_ref().unwrap()[0];
16897 assert_eq!(
16899 item.attrs.as_ref().unwrap()["localId"],
16900 "li-001",
16901 "listItem localId should survive"
16902 );
16903 let item_content = item.content.as_ref().unwrap();
16904 assert_eq!(item_content.len(), 2);
16905 let para = &item_content[0];
16907 assert_eq!(
16908 para.attrs.as_ref().unwrap()["localId"],
16909 "p-001",
16910 "paragraph localId should survive"
16911 );
16912 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16913 assert_eq!(text, "\u{00a0}", "NBSP should survive with localIds");
16914 }
16915
16916 #[test]
16917 fn nbsp_paragraph_in_list_item_without_nested_list() {
16918 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"}]}]}]}]}"#;
16920 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16921 let md = adf_to_markdown(&doc).unwrap();
16922 let rt = markdown_to_adf(&md).unwrap();
16923 let list = &rt.content[0];
16924 let item = &list.content.as_ref().unwrap()[0];
16925 let para = &item.content.as_ref().unwrap()[0];
16926 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16927 assert_eq!(text, "\u{00a0}", "NBSP should survive in simple list item");
16928 }
16929
16930 #[test]
16931 fn nbsp_paragraph_in_ordered_list_item_with_nested_list() {
16932 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"}]}]}]}]}]}]}"#;
16934 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16935 let md = adf_to_markdown(&doc).unwrap();
16936 let rt = markdown_to_adf(&md).unwrap();
16937 let list = &rt.content[0];
16938 let item = &list.content.as_ref().unwrap()[0];
16939 let item_content = item.content.as_ref().unwrap();
16940 assert_eq!(item_content.len(), 2);
16941 let para = &item_content[0];
16942 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16943 assert_eq!(text, "\u{00a0}", "NBSP should survive in ordered list item");
16944 }
16945
16946 #[test]
16947 fn list_item_leading_space_preserved() {
16948 let md = "- hello world\n- - text";
16950 let doc = markdown_to_adf(md).unwrap();
16951 let list = &doc.content[0];
16952 assert_eq!(list.node_type, "bulletList");
16953 let items = list.content.as_ref().unwrap();
16954 let first_para = &items[0].content.as_ref().unwrap()[0];
16956 let first_text = &first_para.content.as_ref().unwrap()[0];
16957 assert_eq!(first_text.text.as_deref(), Some("hello world"));
16958 }
16959
16960 #[test]
16961 fn list_item_leading_space_not_stripped() {
16962 let md = "- leading space text";
16965 let doc = markdown_to_adf(md).unwrap();
16966 let list = &doc.content[0];
16967 let items = list.content.as_ref().unwrap();
16968 let para = &items[0].content.as_ref().unwrap()[0];
16969 let text_node = ¶.content.as_ref().unwrap()[0];
16970 assert_eq!(
16972 text_node.text.as_deref(),
16973 Some(" leading space text"),
16974 "leading space should be preserved"
16975 );
16976 }
16977
16978 #[test]
16983 fn hardbreak_in_cell_uses_directive_table() {
16984 let adf = AdfDocument {
16987 version: 1,
16988 doc_type: "doc".to_string(),
16989 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16990 AdfNode::table_cell(vec![AdfNode::paragraph(vec![
16991 AdfNode::text("before"),
16992 AdfNode::hard_break(),
16993 AdfNode::text("after"),
16994 ])]),
16995 ])])],
16996 };
16997 let md = adf_to_markdown(&adf).unwrap();
16998 assert!(
17000 md.contains(":::td") || md.contains("::::table"),
17001 "Table with hardBreak should use directive form, got:\n{md}"
17002 );
17003 assert!(
17004 !md.contains("| before"),
17005 "Should NOT use pipe syntax with hardBreak"
17006 );
17007 }
17008
17009 #[test]
17010 fn hardbreak_in_cell_roundtrips() {
17011 let adf = AdfDocument {
17013 version: 1,
17014 doc_type: "doc".to_string(),
17015 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17016 AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17017 AdfNode::text("line one"),
17018 AdfNode::hard_break(),
17019 AdfNode::text("line two"),
17020 ])]),
17021 ])])],
17022 };
17023 let md = adf_to_markdown(&adf).unwrap();
17024 let roundtripped = markdown_to_adf(&md).unwrap();
17025
17026 assert_eq!(roundtripped.content.len(), 1);
17028 assert_eq!(roundtripped.content[0].node_type, "table");
17029 let rows = roundtripped.content[0].content.as_ref().unwrap();
17030 assert_eq!(
17031 rows.len(),
17032 1,
17033 "Should have exactly 1 row, got {}",
17034 rows.len()
17035 );
17036 }
17037
17038 #[test]
17039 fn hardbreak_in_paragraph_roundtrips() {
17040 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17042 {"type":"text","text":"line one"},
17043 {"type":"hardBreak"},
17044 {"type":"text","text":"line two"}
17045 ]}]}"#;
17046 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17047 let md = adf_to_markdown(&doc).unwrap();
17048 let round_tripped = markdown_to_adf(&md).unwrap();
17049 let inlines = round_tripped.content[0].content.as_ref().unwrap();
17050 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17051 assert_eq!(
17052 types,
17053 vec!["text", "hardBreak", "text"],
17054 "hardBreak should be preserved, got: {types:?}"
17055 );
17056 assert_eq!(inlines[0].text.as_deref(), Some("line one"));
17057 assert_eq!(inlines[2].text.as_deref(), Some("line two"));
17058 }
17059
17060 #[test]
17061 fn consecutive_hardbreaks_in_paragraph_roundtrip() {
17062 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17064 {"type":"text","text":"before"},
17065 {"type":"hardBreak"},
17066 {"type":"hardBreak"},
17067 {"type":"text","text":"after"}
17068 ]}]}"#;
17069 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17070 let md = adf_to_markdown(&doc).unwrap();
17071 let round_tripped = markdown_to_adf(&md).unwrap();
17072 assert_eq!(
17073 round_tripped.content.len(),
17074 1,
17075 "Should remain a single paragraph, got {} blocks",
17076 round_tripped.content.len()
17077 );
17078 let inlines = round_tripped.content[0].content.as_ref().unwrap();
17079 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17080 assert_eq!(
17081 types,
17082 vec!["text", "hardBreak", "hardBreak", "text"],
17083 "Both hardBreaks should be preserved, got: {types:?}"
17084 );
17085 assert_eq!(inlines[0].text.as_deref(), Some("before"));
17086 assert_eq!(inlines[3].text.as_deref(), Some("after"));
17087 }
17088
17089 #[test]
17090 fn hardbreak_only_paragraph_roundtrips() {
17091 let adf_json = r#"{"version":1,"type":"doc","content":[
17093 {"type":"paragraph","content":[{"type":"hardBreak"}]}
17094 ]}"#;
17095 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17096 let md = adf_to_markdown(&doc).unwrap();
17097 let round_tripped = markdown_to_adf(&md).unwrap();
17098 assert_eq!(
17099 round_tripped.content.len(),
17100 1,
17101 "Paragraph should not be dropped, got {} blocks",
17102 round_tripped.content.len()
17103 );
17104 let inlines = round_tripped.content[0].content.as_ref().unwrap();
17105 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17106 assert_eq!(
17107 types,
17108 vec!["hardBreak"],
17109 "hardBreak-only paragraph should preserve its content, got: {types:?}"
17110 );
17111 }
17112
17113 #[test]
17114 fn issue_410_full_reproducer_roundtrips() {
17115 let adf_json = r#"{"version":1,"type":"doc","content":[
17117 {"type":"paragraph","content":[
17118 {"type":"text","text":"before"},
17119 {"type":"hardBreak"},
17120 {"type":"hardBreak"},
17121 {"type":"text","text":"after"}
17122 ]},
17123 {"type":"paragraph","content":[
17124 {"type":"hardBreak"}
17125 ]}
17126 ]}"#;
17127 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17128 let md = adf_to_markdown(&doc).unwrap();
17129 let round_tripped = markdown_to_adf(&md).unwrap();
17130 assert_eq!(
17131 round_tripped.content.len(),
17132 2,
17133 "Should have exactly 2 paragraphs, got {}",
17134 round_tripped.content.len()
17135 );
17136 let p1 = round_tripped.content[0].content.as_ref().unwrap();
17138 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
17139 assert_eq!(types1, vec!["text", "hardBreak", "hardBreak", "text"]);
17140 let p2 = round_tripped.content[1].content.as_ref().unwrap();
17142 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
17143 assert_eq!(types2, vec!["hardBreak"]);
17144 }
17145
17146 #[test]
17147 fn trailing_space_hardbreak_still_parsed() {
17148 let md = "line one \nline two\n";
17150 let doc = markdown_to_adf(md).unwrap();
17151 let inlines = doc.content[0].content.as_ref().unwrap();
17152 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17153 assert_eq!(
17154 types,
17155 vec!["text", "hardBreak", "text"],
17156 "Trailing-space hardBreak should still parse, got: {types:?}"
17157 );
17158 }
17159
17160 #[test]
17161 fn trailing_hardbreak_at_end_of_paragraph_roundtrips() {
17162 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17164 {"type":"text","text":"text"},
17165 {"type":"hardBreak"}
17166 ]}]}"#;
17167 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17168 let md = adf_to_markdown(&doc).unwrap();
17169 let round_tripped = markdown_to_adf(&md).unwrap();
17170 let inlines = round_tripped.content[0].content.as_ref().unwrap();
17171 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17172 assert_eq!(
17173 types,
17174 vec!["text", "hardBreak"],
17175 "Trailing hardBreak should be preserved, got: {types:?}"
17176 );
17177 }
17178
17179 #[test]
17180 #[test]
17181 fn table_with_header_row_uses_pipe_syntax() {
17182 let adf = AdfDocument {
17184 version: 1,
17185 doc_type: "doc".to_string(),
17186 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17187 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("header cell")])]),
17188 ])])],
17189 };
17190 let md = adf_to_markdown(&adf).unwrap();
17191 assert!(
17192 md.contains("| header cell |"),
17193 "Table with header row should use pipe syntax, got:\n{md}"
17194 );
17195 }
17196
17197 #[test]
17198 fn table_without_header_row_uses_directive_syntax() {
17199 let adf = AdfDocument {
17202 version: 1,
17203 doc_type: "doc".to_string(),
17204 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17205 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("simple cell")])]),
17206 ])])],
17207 };
17208 let md = adf_to_markdown(&adf).unwrap();
17209 assert!(
17210 md.contains("::::table"),
17211 "Table without header row should use directive syntax, got:\n{md}"
17212 );
17213 }
17214
17215 #[test]
17216 fn tablecell_first_row_preserved_on_roundtrip() {
17217 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[
17219 {"type":"tableRow","content":[
17220 {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row1 cell"}]}]}
17221 ]},
17222 {"type":"tableRow","content":[
17223 {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row2 cell"}]}]}
17224 ]}
17225 ]}]}"#;
17226 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17227 let md = adf_to_markdown(&doc).unwrap();
17228 let round_tripped = markdown_to_adf(&md).unwrap();
17229 let rows = round_tripped.content[0].content.as_ref().unwrap();
17230 let row0_cell = &rows[0].content.as_ref().unwrap()[0];
17231 assert_eq!(
17232 row0_cell.node_type, "tableCell",
17233 "first row cell should remain tableCell, got: {}",
17234 row0_cell.node_type
17235 );
17236 let row1_cell = &rows[1].content.as_ref().unwrap()[0];
17237 assert_eq!(row1_cell.node_type, "tableCell");
17238 }
17239
17240 #[test]
17241 fn mixed_header_and_cell_first_row_uses_pipe() {
17242 let adf = AdfDocument {
17244 version: 1,
17245 doc_type: "doc".to_string(),
17246 content: vec![AdfNode::table(vec![
17247 AdfNode::table_row(vec![
17248 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17249 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17250 ]),
17251 AdfNode::table_row(vec![
17252 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
17253 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
17254 ]),
17255 ])],
17256 };
17257 let md = adf_to_markdown(&adf).unwrap();
17258 assert!(
17259 md.contains("| H1 |"),
17260 "Table with header first row should use pipe syntax, got:\n{md}"
17261 );
17262 assert!(!md.contains("::::table"), "should not use directive syntax");
17263 }
17264
17265 #[test]
17268 fn render_pipe_table_escapes_pipe_in_code_span_cell() {
17269 let adf = AdfDocument {
17272 version: 1,
17273 doc_type: "doc".to_string(),
17274 content: vec![AdfNode::table(vec![
17275 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17276 AdfNode::text("Header"),
17277 ])])]),
17278 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17279 AdfNode::text_with_marks("a|b", vec![AdfMark::code()]),
17280 ])])]),
17281 ])],
17282 };
17283 let md = adf_to_markdown(&adf).unwrap();
17284 assert!(
17285 md.contains(r"`a\|b`"),
17286 "Pipe inside code span must be escaped, got:\n{md}"
17287 );
17288 }
17289
17290 #[test]
17291 fn render_pipe_table_escapes_pipe_in_plain_text_cell() {
17292 let adf = AdfDocument {
17293 version: 1,
17294 doc_type: "doc".to_string(),
17295 content: vec![AdfNode::table(vec![
17296 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17297 AdfNode::text("Header"),
17298 ])])]),
17299 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17300 AdfNode::text("x|y"),
17301 ])])]),
17302 ])],
17303 };
17304 let md = adf_to_markdown(&adf).unwrap();
17305 assert!(
17306 md.contains(r"x\|y"),
17307 "Pipe inside plain-text cell must be escaped, got:\n{md}"
17308 );
17309 }
17310
17311 #[test]
17312 fn code_span_with_pipe_in_table_cell_roundtrips() {
17313 let adf_json = r#"{
17315 "version": 1,
17316 "type": "doc",
17317 "content": [{
17318 "type": "table",
17319 "attrs": {"isNumberColumnEnabled": false, "layout": "default", "localId": "abc-789"},
17320 "content": [
17321 {"type": "tableRow", "content": [
17322 {"type": "tableHeader", "attrs": {}, "content": [
17323 {"type": "paragraph", "content": [{"type": "text", "text": "Before"}]}
17324 ]},
17325 {"type": "tableHeader", "attrs": {}, "content": [
17326 {"type": "paragraph", "content": [{"type": "text", "text": "After"}]}
17327 ]}
17328 ]},
17329 {"type": "tableRow", "content": [
17330 {"type": "tableCell", "attrs": {}, "content": [
17331 {"type": "paragraph", "content": [
17332 {"type": "text", "text": "parse(json).extract[T]", "marks": [{"type": "code"}]}
17333 ]}
17334 ]},
17335 {"type": "tableCell", "attrs": {}, "content": [
17336 {"type": "paragraph", "content": [
17337 {"type": "text", "text": "parser.decode[T|json]", "marks": [{"type": "code"}]}
17338 ]}
17339 ]}
17340 ]}
17341 ]
17342 }]
17343 }"#;
17344 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17345 let md = adf_to_markdown(&doc).unwrap();
17346 let round_tripped = markdown_to_adf(&md).unwrap();
17347
17348 let rows = round_tripped.content[0].content.as_ref().unwrap();
17349 assert_eq!(
17350 rows.len(),
17351 2,
17352 "Table should have 2 rows, got: {}",
17353 rows.len()
17354 );
17355
17356 let body_row = rows[1].content.as_ref().unwrap();
17357 assert_eq!(
17358 body_row.len(),
17359 2,
17360 "Body row should have 2 cells (not split by the pipe), got: {}",
17361 body_row.len()
17362 );
17363
17364 let second_cell = &body_row[1];
17365 let para = second_cell.content.as_ref().unwrap().first().unwrap();
17366 let inlines = para.content.as_ref().unwrap();
17367 assert_eq!(inlines.len(), 1, "Cell should have a single text node");
17368 assert_eq!(
17369 inlines[0].text.as_deref(),
17370 Some("parser.decode[T|json]"),
17371 "Code-span text must be preserved with literal pipe"
17372 );
17373 let marks = inlines[0]
17374 .marks
17375 .as_ref()
17376 .expect("code mark must be preserved");
17377 assert!(
17378 marks.iter().any(|m| m.mark_type == "code"),
17379 "text node should carry the code mark"
17380 );
17381 }
17382
17383 #[test]
17384 fn plain_text_pipe_in_table_cell_roundtrips() {
17385 let adf = AdfDocument {
17387 version: 1,
17388 doc_type: "doc".to_string(),
17389 content: vec![AdfNode::table(vec![
17390 AdfNode::table_row(vec![
17391 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17392 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17393 ]),
17394 AdfNode::table_row(vec![
17395 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a|b")])]),
17396 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("c")])]),
17397 ]),
17398 ])],
17399 };
17400 let md = adf_to_markdown(&adf).unwrap();
17401 let round_tripped = markdown_to_adf(&md).unwrap();
17402 let rows = round_tripped.content[0].content.as_ref().unwrap();
17403 let body_row = rows[1].content.as_ref().unwrap();
17404 assert_eq!(
17405 body_row.len(),
17406 2,
17407 "Body row should keep 2 cells, got: {}",
17408 body_row.len()
17409 );
17410 let first_cell_text = body_row[0].content.as_ref().unwrap()[0]
17411 .content
17412 .as_ref()
17413 .unwrap()[0]
17414 .text
17415 .as_deref();
17416 assert_eq!(first_cell_text, Some("a|b"));
17417 }
17418
17419 #[test]
17420 fn cell_contains_hard_break_true() {
17421 let para = AdfNode::paragraph(vec![
17422 AdfNode::text("a"),
17423 AdfNode::hard_break(),
17424 AdfNode::text("b"),
17425 ]);
17426 assert!(cell_contains_hard_break(¶));
17427 }
17428
17429 #[test]
17430 fn cell_contains_hard_break_false() {
17431 let para = AdfNode::paragraph(vec![AdfNode::text("no break here")]);
17432 assert!(!cell_contains_hard_break(¶));
17433 }
17434
17435 #[test]
17436 fn cell_contains_hard_break_empty() {
17437 let para = AdfNode::paragraph(vec![]);
17438 assert!(!cell_contains_hard_break(¶));
17439 }
17440
17441 #[test]
17444 fn multi_paragraph_panel_roundtrips() {
17445 let adf = AdfDocument {
17446 version: 1,
17447 doc_type: "doc".to_string(),
17448 content: vec![AdfNode {
17449 node_type: "panel".to_string(),
17450 attrs: Some(serde_json::json!({"panelType": "info"})),
17451 content: Some(vec![
17452 AdfNode::paragraph(vec![AdfNode::text("First paragraph.")]),
17453 AdfNode::paragraph(vec![AdfNode::text("Second paragraph.")]),
17454 ]),
17455 text: None,
17456 marks: None,
17457 local_id: None,
17458 parameters: None,
17459 }],
17460 };
17461
17462 let md = adf_to_markdown(&adf).unwrap();
17463 assert!(
17465 md.contains("First paragraph.\n\nSecond paragraph."),
17466 "Panel should have blank line between paragraphs, got:\n{md}"
17467 );
17468
17469 let roundtripped = markdown_to_adf(&md).unwrap();
17471 assert_eq!(roundtripped.content.len(), 1);
17472 assert_eq!(roundtripped.content[0].node_type, "panel");
17473 let panel_content = roundtripped.content[0].content.as_ref().unwrap();
17474 assert_eq!(
17475 panel_content.len(),
17476 2,
17477 "Panel should have 2 paragraphs after round-trip, got {}",
17478 panel_content.len()
17479 );
17480 }
17481
17482 #[test]
17483 fn multi_paragraph_expand_roundtrips() {
17484 let adf = AdfDocument {
17485 version: 1,
17486 doc_type: "doc".to_string(),
17487 content: vec![AdfNode {
17488 node_type: "expand".to_string(),
17489 attrs: Some(serde_json::json!({"title": "Details"})),
17490 content: Some(vec![
17491 AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17492 AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17493 ]),
17494 text: None,
17495 marks: None,
17496 local_id: None,
17497 parameters: None,
17498 }],
17499 };
17500
17501 let md = adf_to_markdown(&adf).unwrap();
17502 let roundtripped = markdown_to_adf(&md).unwrap();
17503 let expand_content = roundtripped.content[0].content.as_ref().unwrap();
17504 assert_eq!(
17505 expand_content.len(),
17506 2,
17507 "Expand should have 2 paragraphs after round-trip, got {}",
17508 expand_content.len()
17509 );
17510 }
17511
17512 #[test]
17513 fn consecutive_nested_expands_in_table_cell_roundtrip() {
17514 let cell_content = vec![
17515 AdfNode {
17516 node_type: "nestedExpand".to_string(),
17517 attrs: Some(serde_json::json!({"title": "First"})),
17518 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 1")])]),
17519 text: None,
17520 marks: None,
17521 local_id: None,
17522 parameters: None,
17523 },
17524 AdfNode {
17525 node_type: "nestedExpand".to_string(),
17526 attrs: Some(serde_json::json!({"title": "Second"})),
17527 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 2")])]),
17528 text: None,
17529 marks: None,
17530 local_id: None,
17531 parameters: None,
17532 },
17533 ];
17534 let adf = AdfDocument {
17535 version: 1,
17536 doc_type: "doc".to_string(),
17537 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17538 AdfNode::table_cell(cell_content),
17539 ])])],
17540 };
17541
17542 let md = adf_to_markdown(&adf).unwrap();
17543 assert!(
17544 md.contains(":::\n\n:::nested-expand"),
17545 "Should have blank line between consecutive nested-expands in cell, got:\n{md}"
17546 );
17547
17548 let rt = markdown_to_adf(&md).unwrap();
17549 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17550 .content
17551 .as_ref()
17552 .unwrap()[0];
17553 let cell_nodes = cell.content.as_ref().unwrap();
17554 let expand_count = cell_nodes
17555 .iter()
17556 .filter(|n| n.node_type == "nestedExpand")
17557 .count();
17558 assert_eq!(
17559 expand_count, 2,
17560 "Both nested-expands should survive round-trip, got {expand_count}"
17561 );
17562 }
17563
17564 #[test]
17565 fn multi_paragraph_in_table_cell_roundtrip() {
17566 let adf = AdfDocument {
17568 version: 1,
17569 doc_type: "doc".to_string(),
17570 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17571 AdfNode::table_cell(vec![
17572 AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17573 AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17574 ]),
17575 ])])],
17576 };
17577
17578 let md = adf_to_markdown(&adf).unwrap();
17579 assert!(
17580 md.contains("Para one.\n\nPara two."),
17581 "Should have blank line between paragraphs in cell, got:\n{md}"
17582 );
17583
17584 let rt = markdown_to_adf(&md).unwrap();
17585 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17586 .content
17587 .as_ref()
17588 .unwrap()[0];
17589 let para_count = cell
17590 .content
17591 .as_ref()
17592 .unwrap()
17593 .iter()
17594 .filter(|n| n.node_type == "paragraph")
17595 .count();
17596 assert_eq!(para_count, 2, "Both paragraphs should survive round-trip");
17597 }
17598
17599 #[test]
17600 fn panel_inside_table_cell_roundtrip() {
17601 let adf = AdfDocument {
17603 version: 1,
17604 doc_type: "doc".to_string(),
17605 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17606 AdfNode::table_cell(vec![
17607 AdfNode::paragraph(vec![AdfNode::text("Before panel.")]),
17608 AdfNode {
17609 node_type: "panel".to_string(),
17610 attrs: Some(serde_json::json!({"panelType": "info"})),
17611 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text(
17612 "Panel content",
17613 )])]),
17614 text: None,
17615 marks: None,
17616 local_id: None,
17617 parameters: None,
17618 },
17619 ]),
17620 ])])],
17621 };
17622
17623 let md = adf_to_markdown(&adf).unwrap();
17624 assert!(
17625 md.contains(":::panel"),
17626 "Should contain panel directive, got:\n{md}"
17627 );
17628
17629 let rt = markdown_to_adf(&md).unwrap();
17630 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17631 .content
17632 .as_ref()
17633 .unwrap()[0];
17634 let has_panel = cell
17635 .content
17636 .as_ref()
17637 .unwrap()
17638 .iter()
17639 .any(|n| n.node_type == "panel");
17640 assert!(has_panel, "Panel should survive round-trip in table cell");
17641 }
17642
17643 #[test]
17644 fn three_consecutive_expands_in_table_cell() {
17645 let make_expand = |title: &str| AdfNode {
17646 node_type: "nestedExpand".to_string(),
17647 attrs: Some(serde_json::json!({"title": title})),
17648 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("content")])]),
17649 text: None,
17650 marks: None,
17651 local_id: None,
17652 parameters: None,
17653 };
17654 let adf = AdfDocument {
17655 version: 1,
17656 doc_type: "doc".to_string(),
17657 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17658 AdfNode::table_cell(vec![
17659 make_expand("First"),
17660 make_expand("Second"),
17661 make_expand("Third"),
17662 ]),
17663 ])])],
17664 };
17665
17666 let md = adf_to_markdown(&adf).unwrap();
17667 let rt = markdown_to_adf(&md).unwrap();
17668 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17669 .content
17670 .as_ref()
17671 .unwrap()[0];
17672 let expand_count = cell
17673 .content
17674 .as_ref()
17675 .unwrap()
17676 .iter()
17677 .filter(|n| n.node_type == "nestedExpand")
17678 .count();
17679 assert_eq!(expand_count, 3, "All 3 expands should survive round-trip");
17680 }
17681
17682 #[test]
17685 fn nested_expand_inside_panel() {
17686 let md = ":::panel{type=info}\n:::expand{title=\"Details\"}\nHidden content\n:::\nMore panel content\n:::";
17691 let adf = markdown_to_adf(md).unwrap();
17692
17693 let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
17694 assert!(err.violations.iter().any(|v| matches!(
17695 v,
17696 crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
17697 parent_type, child_type, ..
17698 } if parent_type == "panel" && child_type == "expand"
17699 )));
17700 }
17701
17702 #[test]
17703 fn nested_expand_inside_table_cell() {
17704 let md = "::::table\n:::tr\n:::td\n:::expand{title=\"Details\"}\nExpand content\n:::\n:::\n:::\n::::";
17708 let adf = markdown_to_adf(md).unwrap();
17709
17710 let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
17711 assert!(err.violations.iter().any(|v| matches!(
17712 v,
17713 crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
17714 parent_type, child_type, ..
17715 } if parent_type == "tableCell" && child_type == "expand"
17716 )));
17717 }
17718
17719 #[test]
17720 fn nested_expand_inside_layout_column() {
17721 let md = ":::layout\n:::column{width=50}\n:::expand{title=\"Col Expand\"}\nExpanded\n:::\n:::\n:::column{width=50}\nFiller paragraph.\n:::\n:::";
17726 let adf = markdown_to_adf(md).unwrap();
17727
17728 assert_eq!(adf.content.len(), 1);
17729 assert_eq!(adf.content[0].node_type, "layoutSection");
17730
17731 let columns = adf.content[0].content.as_ref().unwrap();
17732 assert_eq!(columns.len(), 2);
17733 let col_content = columns[0].content.as_ref().unwrap();
17734 assert!(
17735 col_content.iter().any(|n| n.node_type == "expand"),
17736 "Column should contain an expand node, got: {:?}",
17737 col_content.iter().map(|n| &n.node_type).collect::<Vec<_>>()
17738 );
17739
17740 crate::atlassian::adf_validated::validate(&adf).unwrap();
17742 }
17743
17744 #[test]
17745 fn expand_localid_in_directive_attrs() {
17746 let adf_json = r#"{"version":1,"type":"doc","content":[
17748 {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
17749 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17750 ]}
17751 ]}"#;
17752 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17753 let md = adf_to_markdown(&doc).unwrap();
17754 assert!(
17755 md.contains("localId=exp-001"),
17756 "should contain localId: {md}"
17757 );
17758 assert!(
17759 md.contains(":::expand{"),
17760 "should have expand directive with attrs: {md}"
17761 );
17762 assert!(
17763 !md.contains(":::\n{localId="),
17764 "localId should NOT be trailing: {md}"
17765 );
17766 }
17767
17768 #[test]
17769 fn expand_localid_roundtrip() {
17770 let adf_json = r#"{"version":1,"type":"doc","content":[
17771 {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
17772 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17773 ]}
17774 ]}"#;
17775 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17776 let md = adf_to_markdown(&doc).unwrap();
17777 let rt = markdown_to_adf(&md).unwrap();
17778 let expand = &rt.content[0];
17779 assert_eq!(expand.node_type, "expand");
17780 assert_eq!(
17781 expand.local_id.as_deref(),
17782 Some("exp-001"),
17783 "expand localId should survive round-trip"
17784 );
17785 assert_eq!(
17786 expand.attrs.as_ref().unwrap()["title"],
17787 "Details",
17788 "expand title should survive round-trip"
17789 );
17790 }
17791
17792 #[test]
17793 fn nested_expand_localid_roundtrip() {
17794 let adf_json = r#"{"version":1,"type":"doc","content":[
17795 {"type":"nestedExpand","attrs":{"localId":"ne-001","title":"S"},"content":[
17796 {"type":"paragraph","content":[{"type":"text","text":"content"}]}
17797 ]}
17798 ]}"#;
17799 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17800 let md = adf_to_markdown(&doc).unwrap();
17801 assert!(
17802 md.contains(":::nested-expand{"),
17803 "should have directive: {md}"
17804 );
17805 assert!(md.contains("localId=ne-001"), "should have localId: {md}");
17806 let rt = markdown_to_adf(&md).unwrap();
17807 let ne = &rt.content[0];
17808 assert_eq!(ne.node_type, "nestedExpand");
17809 assert_eq!(ne.local_id.as_deref(), Some("ne-001"));
17810 }
17811
17812 #[test]
17813 fn nested_expand_localid_followed_by_content() {
17814 let adf_json = "{\
17816 \"version\":1,\"type\":\"doc\",\"content\":[\
17817 {\"type\":\"nestedExpand\",\"attrs\":{\"localId\":\"exp-001\",\"title\":\"S\"},\"content\":[\
17818 {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}\
17819 ]},\
17820 {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]}\
17821 ]}";
17822 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17823 let md = adf_to_markdown(&doc).unwrap();
17824 let rt = markdown_to_adf(&md).unwrap();
17825 let ne = &rt.content[0];
17827 assert_eq!(ne.node_type, "nestedExpand");
17828 assert_eq!(
17829 ne.local_id.as_deref(),
17830 Some("exp-001"),
17831 "nestedExpand should preserve localId"
17832 );
17833 let para = &rt.content[1];
17835 assert_eq!(para.node_type, "paragraph");
17836 let text = para.content.as_ref().unwrap()[0]
17837 .text
17838 .as_deref()
17839 .unwrap_or("");
17840 assert!(
17841 !text.contains("localId"),
17842 "following paragraph should not contain localId: {text}"
17843 );
17844 assert!(
17845 text.contains("after"),
17846 "following paragraph should contain 'after': {text}"
17847 );
17848 }
17849
17850 #[test]
17851 fn expand_localid_without_title() {
17852 let adf_json = r#"{"version":1,"type":"doc","content":[
17853 {"type":"expand","attrs":{"localId":"exp-002"},"content":[
17854 {"type":"paragraph","content":[{"type":"text","text":"no title"}]}
17855 ]}
17856 ]}"#;
17857 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17858 let md = adf_to_markdown(&doc).unwrap();
17859 assert!(
17860 md.contains(":::expand{localId=exp-002}"),
17861 "should have localId without title: {md}"
17862 );
17863 let rt = markdown_to_adf(&md).unwrap();
17864 assert_eq!(rt.content[0].local_id.as_deref(), Some("exp-002"));
17865 }
17866
17867 #[test]
17868 fn expand_localid_stripped() {
17869 let adf_json = r#"{"version":1,"type":"doc","content":[
17870 {"type":"expand","attrs":{"localId":"exp-001","title":"X"},"content":[
17871 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17872 ]}
17873 ]}"#;
17874 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17875 let opts = RenderOptions {
17876 strip_local_ids: true,
17877 };
17878 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
17879 assert!(!md.contains("localId"), "localId should be stripped: {md}");
17880 assert!(
17881 md.contains(":::expand{title=\"X\"}"),
17882 "title should remain: {md}"
17883 );
17884 }
17885
17886 #[test]
17889 fn expand_top_level_localid_roundtrip() {
17890 let adf_json = r#"{"version":1,"type":"doc","content":[
17892 {"type":"expand","attrs":{"title":"My Section"},"localId":"abc-123","content":[
17893 {"type":"paragraph","content":[{"type":"text","text":"hello"}]}
17894 ]}
17895 ]}"#;
17896 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17897 assert_eq!(doc.content[0].local_id.as_deref(), Some("abc-123"));
17898 let md = adf_to_markdown(&doc).unwrap();
17899 assert!(
17900 md.contains("localId=abc-123"),
17901 "JFM should contain localId: {md}"
17902 );
17903 let rt = markdown_to_adf(&md).unwrap();
17904 let expand = &rt.content[0];
17905 assert_eq!(expand.node_type, "expand");
17906 assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
17907 assert_eq!(
17908 expand.attrs.as_ref().unwrap()["title"],
17909 "My Section",
17910 "title should survive round-trip"
17911 );
17912 }
17913
17914 #[test]
17915 fn expand_parameters_roundtrip() {
17916 let adf_json = r#"{"version":1,"type":"doc","content":[
17918 {"type":"expand","attrs":{"title":"Props"},"parameters":{"macroMetadata":{"macroId":{"value":"m-001"},"schemaVersion":{"value":"1"}}},"content":[
17919 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17920 ]}
17921 ]}"#;
17922 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17923 assert!(doc.content[0].parameters.is_some());
17924 let md = adf_to_markdown(&doc).unwrap();
17925 assert!(md.contains("params="), "JFM should contain params: {md}");
17926 let rt = markdown_to_adf(&md).unwrap();
17927 let expand = &rt.content[0];
17928 let params = expand
17929 .parameters
17930 .as_ref()
17931 .expect("parameters should survive round-trip");
17932 assert_eq!(params["macroMetadata"]["macroId"]["value"], "m-001");
17933 assert_eq!(params["macroMetadata"]["schemaVersion"]["value"], "1");
17934 }
17935
17936 #[test]
17937 fn expand_localid_and_parameters_roundtrip() {
17938 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"}]}]}]}"#;
17940 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17941 let md = adf_to_markdown(&doc).unwrap();
17942 let rt = markdown_to_adf(&md).unwrap();
17943 let expand = &rt.content[0];
17944 assert_eq!(expand.node_type, "expand");
17945 assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
17946 assert_eq!(expand.attrs.as_ref().unwrap()["title"], "My Section");
17947 let params = expand
17948 .parameters
17949 .as_ref()
17950 .expect("parameters should survive");
17951 assert_eq!(params["macroMetadata"]["macroId"]["value"], "macro-001");
17952 assert_eq!(params["macroMetadata"]["title"], "Page Properties");
17953 }
17954
17955 #[test]
17956 fn nested_expand_top_level_localid_and_parameters_roundtrip() {
17957 let adf_json = r#"{"version":1,"type":"doc","content":[
17958 {"type":"nestedExpand","attrs":{"title":"Nested"},"localId":"ne-100","parameters":{"macroMetadata":{"macroId":{"value":"nm-001"}}},"content":[
17959 {"type":"paragraph","content":[{"type":"text","text":"inner"}]}
17960 ]}
17961 ]}"#;
17962 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17963 let md = adf_to_markdown(&doc).unwrap();
17964 assert!(
17965 md.contains(":::nested-expand{"),
17966 "should use nested-expand: {md}"
17967 );
17968 assert!(md.contains("localId=ne-100"), "should have localId: {md}");
17969 assert!(md.contains("params="), "should have params: {md}");
17970 let rt = markdown_to_adf(&md).unwrap();
17971 let ne = &rt.content[0];
17972 assert_eq!(ne.node_type, "nestedExpand");
17973 assert_eq!(ne.local_id.as_deref(), Some("ne-100"));
17974 assert_eq!(
17975 ne.parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
17976 "nm-001"
17977 );
17978 }
17979
17980 #[test]
17981 fn expand_top_level_localid_stripped() {
17982 let adf_json = r#"{"version":1,"type":"doc","content":[
17984 {"type":"expand","attrs":{"title":"X"},"localId":"exp-strip","content":[
17985 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17986 ]}
17987 ]}"#;
17988 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17989 let opts = RenderOptions {
17990 strip_local_ids: true,
17991 };
17992 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
17993 assert!(!md.contains("localId"), "localId should be stripped: {md}");
17994 assert!(
17995 md.contains(":::expand{title=\"X\"}"),
17996 "title should remain: {md}"
17997 );
17998 }
17999
18000 #[test]
18001 fn expand_parameters_without_localid() {
18002 let adf_json = r#"{"version":1,"type":"doc","content":[
18004 {"type":"expand","attrs":{"title":"P"},"parameters":{"macroMetadata":{"macroId":{"value":"solo"}}},"content":[
18005 {"type":"paragraph","content":[{"type":"text","text":"data"}]}
18006 ]}
18007 ]}"#;
18008 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18009 let md = adf_to_markdown(&doc).unwrap();
18010 assert!(!md.contains("localId"), "no localId: {md}");
18011 assert!(md.contains("params="), "has params: {md}");
18012 let rt = markdown_to_adf(&md).unwrap();
18013 assert!(rt.content[0].local_id.is_none());
18014 assert_eq!(
18015 rt.content[0].parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
18016 "solo"
18017 );
18018 }
18019
18020 #[test]
18021 fn expand_localid_without_parameters() {
18022 let adf_json = r#"{"version":1,"type":"doc","content":[
18024 {"type":"expand","attrs":{"title":"L"},"localId":"lid-only","content":[
18025 {"type":"paragraph","content":[{"type":"text","text":"txt"}]}
18026 ]}
18027 ]}"#;
18028 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18029 let md = adf_to_markdown(&doc).unwrap();
18030 assert!(md.contains("localId=lid-only"), "has localId: {md}");
18031 assert!(!md.contains("params="), "no params: {md}");
18032 let rt = markdown_to_adf(&md).unwrap();
18033 assert_eq!(rt.content[0].local_id.as_deref(), Some("lid-only"));
18034 assert!(rt.content[0].parameters.is_none());
18035 }
18036
18037 #[test]
18038 fn nested_panel_inside_panel() {
18039 let md = ":::panel{type=info}\n:::panel{type=warning}\nInner warning\n:::\n:::";
18040 let adf = markdown_to_adf(md).unwrap();
18041
18042 assert_eq!(adf.content.len(), 1);
18044 assert_eq!(adf.content[0].node_type, "panel");
18045
18046 let panel_content = adf.content[0].content.as_ref().unwrap();
18048 assert!(
18049 panel_content.iter().any(|n| n.node_type == "panel"),
18050 "Outer panel should contain an inner panel, got: {:?}",
18051 panel_content
18052 .iter()
18053 .map(|n| &n.node_type)
18054 .collect::<Vec<_>>()
18055 );
18056 }
18057
18058 #[test]
18059 fn content_after_directive_table_is_preserved() {
18060 let md = "\
18062## Before table
18063
18064::::table{layout=default}
18065:::tr
18066:::th{}
18067Cell
18068:::
18069:::
18070::::
18071
18072## After table
18073
18074Paragraph after.";
18075 let adf = markdown_to_adf(md).unwrap();
18076 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18077 assert_eq!(
18078 types,
18079 vec!["heading", "table", "heading", "paragraph"],
18080 "Content after table was dropped: got {types:?}"
18081 );
18082 }
18083
18084 #[test]
18085 fn paragraph_after_directive_table_is_preserved() {
18086 let md = "\
18088::::table{layout=default}
18089:::tr
18090:::th{}
18091Header
18092:::
18093:::
18094::::
18095
18096Just a paragraph.";
18097 let adf = markdown_to_adf(md).unwrap();
18098 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18099 assert_eq!(
18100 types,
18101 vec!["table", "paragraph"],
18102 "Paragraph after table was dropped: got {types:?}"
18103 );
18104 }
18105
18106 #[test]
18107 fn extension_after_directive_table_is_preserved() {
18108 let md = "\
18110::::table{layout=default}
18111:::tr
18112:::th{}
18113Header
18114:::
18115:::
18116::::
18117
18118::extension{type=com.atlassian.confluence.macro.core key=toc}";
18119 let adf = markdown_to_adf(md).unwrap();
18120 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18121 assert_eq!(
18122 types,
18123 vec!["table", "extension"],
18124 "Extension after table was dropped: got {types:?}"
18125 );
18126 }
18127
18128 #[test]
18129 fn multiple_blocks_after_directive_table() {
18130 let md = "\
18132## Heading 1
18133
18134::::table{layout=default}
18135:::tr
18136:::td{}
18137A
18138:::
18139:::td{}
18140B
18141:::
18142:::
18143::::
18144
18145## Heading 2
18146
18147Some text.
18148
18149---
18150
18151::::table{layout=default}
18152:::tr
18153:::th{}
18154C
18155:::
18156:::
18157::::
18158
18159## Heading 3";
18160 let adf = markdown_to_adf(md).unwrap();
18161 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18162 assert_eq!(
18163 types,
18164 vec![
18165 "heading",
18166 "table",
18167 "heading",
18168 "paragraph",
18169 "rule",
18170 "table",
18171 "heading"
18172 ],
18173 "Content after tables was dropped: got {types:?}"
18174 );
18175 }
18176
18177 #[test]
18180 fn adf_table_caption_to_markdown() {
18181 let doc = AdfDocument {
18182 version: 1,
18183 doc_type: "doc".to_string(),
18184 content: vec![AdfNode::table(vec![
18185 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18186 AdfNode::text("cell"),
18187 ])])]),
18188 AdfNode::caption(vec![AdfNode::text("Table caption")]),
18189 ])],
18190 };
18191 let md = adf_to_markdown(&doc).unwrap();
18192 assert!(
18193 md.contains("::::table"),
18194 "table with caption must use directive form"
18195 );
18196 assert!(
18197 md.contains(":::caption"),
18198 "caption directive missing, got: {md}"
18199 );
18200 assert!(
18201 md.contains("Table caption"),
18202 "caption text missing, got: {md}"
18203 );
18204 }
18205
18206 #[test]
18207 fn directive_table_caption_parses() {
18208 let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nTable caption\n:::\n::::\n";
18209 let doc = markdown_to_adf(md).unwrap();
18210 let table = &doc.content[0];
18211 assert_eq!(table.node_type, "table");
18212 let children = table.content.as_ref().unwrap();
18213 assert_eq!(children.len(), 2, "expected row + caption");
18214 assert_eq!(children[0].node_type, "tableRow");
18215 assert_eq!(children[1].node_type, "caption");
18216 let caption_content = children[1].content.as_ref().unwrap();
18217 assert_eq!(caption_content[0].text.as_deref(), Some("Table caption"));
18218 }
18219
18220 #[test]
18221 fn table_caption_round_trip_from_adf_json() {
18222 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18223 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18224 {"type":"caption","content":[{"type":"text","text":"Table caption"}]}
18225 ]}]}"#;
18226 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18227 let md = adf_to_markdown(&doc).unwrap();
18228 assert!(md.contains("Table caption"), "caption text lost in ADF→JFM");
18229 let round_tripped = markdown_to_adf(&md).unwrap();
18230 let children = round_tripped.content[0].content.as_ref().unwrap();
18231 let caption = children.iter().find(|n| n.node_type == "caption");
18232 assert!(caption.is_some(), "caption lost on round-trip");
18233 let caption_text = caption.unwrap().content.as_ref().unwrap();
18234 assert_eq!(caption_text[0].text.as_deref(), Some("Table caption"));
18235 }
18236
18237 #[test]
18238 fn table_caption_with_inline_marks_round_trips() {
18239 let doc = AdfDocument {
18240 version: 1,
18241 doc_type: "doc".to_string(),
18242 content: vec![AdfNode::table(vec![
18243 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18244 AdfNode::text("data"),
18245 ])])]),
18246 AdfNode::caption(vec![
18247 AdfNode::text("Caption with "),
18248 AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
18249 ]),
18250 ])],
18251 };
18252 let md = adf_to_markdown(&doc).unwrap();
18253 assert!(md.contains("**bold**"), "bold mark missing in caption");
18254 let round_tripped = markdown_to_adf(&md).unwrap();
18255 let caption = round_tripped.content[0]
18256 .content
18257 .as_ref()
18258 .unwrap()
18259 .iter()
18260 .find(|n| n.node_type == "caption")
18261 .expect("caption node missing after round-trip");
18262 let inlines = caption.content.as_ref().unwrap();
18263 let bold_node = inlines.iter().find(|n| {
18264 n.marks
18265 .as_ref()
18266 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
18267 });
18268 assert!(bold_node.is_some(), "bold mark lost in caption round-trip");
18269 }
18270
18271 #[test]
18274 fn table_caption_localid_roundtrip() {
18275 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18276 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18277 {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Table with localId"}]}
18278 ]}]}"#;
18279 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18280 let md = adf_to_markdown(&doc).unwrap();
18281 assert!(
18282 md.contains("localId=abcdef123456"),
18283 "table caption localId should appear in markdown: {md}"
18284 );
18285 let rt = markdown_to_adf(&md).unwrap();
18286 let caption = rt.content[0]
18287 .content
18288 .as_ref()
18289 .unwrap()
18290 .iter()
18291 .find(|n| n.node_type == "caption")
18292 .expect("caption should survive round-trip");
18293 assert_eq!(
18294 caption.attrs.as_ref().unwrap()["localId"],
18295 "abcdef123456",
18296 "table caption localId should round-trip"
18297 );
18298 }
18299
18300 #[test]
18301 fn table_caption_without_localid_unchanged() {
18302 let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nPlain caption\n:::\n::::\n";
18303 let doc = markdown_to_adf(md).unwrap();
18304 let caption = doc.content[0]
18305 .content
18306 .as_ref()
18307 .unwrap()
18308 .iter()
18309 .find(|n| n.node_type == "caption")
18310 .unwrap();
18311 assert!(
18312 caption.attrs.is_none(),
18313 "table caption without localId should not gain attrs"
18314 );
18315 let md2 = adf_to_markdown(&doc).unwrap();
18316 assert!(!md2.contains("localId"), "no localId should appear: {md2}");
18317 }
18318
18319 #[test]
18320 fn table_caption_localid_stripped_when_option_set() {
18321 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18322 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18323 {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Stripped"}]}
18324 ]}]}"#;
18325 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18326 let opts = RenderOptions {
18327 strip_local_ids: true,
18328 ..Default::default()
18329 };
18330 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18331 assert!(
18332 !md.contains("localId"),
18333 "table caption localId should be stripped: {md}"
18334 );
18335 }
18336
18337 #[test]
18338 #[test]
18339 fn tablecell_empty_attrs_preserved_on_roundtrip() {
18340 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"}]}]}]}]}]}"#;
18342 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18343 let md = adf_to_markdown(&doc).unwrap();
18344 let round_tripped = markdown_to_adf(&md).unwrap();
18345 let rows = round_tripped.content[0].content.as_ref().unwrap();
18346 let cell = &rows[0].content.as_ref().unwrap()[0];
18347 assert!(
18348 cell.attrs.is_some(),
18349 "tableCell attrs should be preserved, got None"
18350 );
18351 assert_eq!(
18352 cell.attrs.as_ref().unwrap(),
18353 &serde_json::json!({}),
18354 "tableCell attrs should be an empty object"
18355 );
18356 }
18357
18358 #[test]
18359 fn tablecell_empty_attrs_serialized_in_json() {
18360 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"}]}]}]}]}]}"#;
18362 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18363 let md = adf_to_markdown(&doc).unwrap();
18364 let round_tripped = markdown_to_adf(&md).unwrap();
18365 let json = serde_json::to_string(&round_tripped).unwrap();
18366 assert!(
18367 json.contains(r#""attrs":{}"#),
18368 "serialized JSON should contain \"attrs\":{{}}, got: {json}"
18369 );
18370 }
18371
18372 #[test]
18373 fn tablecell_empty_attrs_renders_braces_in_markdown() {
18374 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"}]}]}]}]}]}"#;
18376 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18377 let md = adf_to_markdown(&doc).unwrap();
18378 assert!(
18380 md.contains("{} hello"),
18381 "cell with empty attrs should render '{{}} hello', got: {md}"
18382 );
18383 assert!(
18384 !md.contains("{} world"),
18385 "cell without attrs should not render '{{}}', got: {md}"
18386 );
18387 }
18388
18389 #[test]
18390 fn tablecell_no_attrs_unchanged_on_roundtrip() {
18391 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"}]}]}]}]}]}"#;
18393 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18394 let md = adf_to_markdown(&doc).unwrap();
18395 let round_tripped = markdown_to_adf(&md).unwrap();
18396 let rows = round_tripped.content[0].content.as_ref().unwrap();
18397 let cell = &rows[0].content.as_ref().unwrap()[0];
18398 assert!(
18399 cell.attrs.is_none(),
18400 "tableCell without attrs should stay None, got: {:?}",
18401 cell.attrs
18402 );
18403 }
18404
18405 #[test]
18406 fn tablecell_nonempty_attrs_preserved_on_roundtrip() {
18407 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"}]}]}]}]}]}"##;
18409 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18410 let md = adf_to_markdown(&doc).unwrap();
18411 let round_tripped = markdown_to_adf(&md).unwrap();
18412 let rows = round_tripped.content[0].content.as_ref().unwrap();
18413 let cell = &rows[1].content.as_ref().unwrap()[0];
18414 let attrs = cell.attrs.as_ref().unwrap();
18415 assert_eq!(attrs["background"], "#DEEBFF");
18416 assert_eq!(attrs["colspan"], 2);
18417 }
18418
18419 #[test]
18420 fn pipe_table_not_used_when_caption_present() {
18421 let doc = AdfDocument {
18422 version: 1,
18423 doc_type: "doc".to_string(),
18424 content: vec![AdfNode::table(vec![
18425 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
18426 AdfNode::text("H"),
18427 ])])]),
18428 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18429 AdfNode::text("D"),
18430 ])])]),
18431 AdfNode::caption(vec![AdfNode::text("cap")]),
18432 ])],
18433 };
18434 let md = adf_to_markdown(&doc).unwrap();
18435 assert!(
18436 md.contains("::::table"),
18437 "pipe syntax should not be used when caption is present"
18438 );
18439 }
18440
18441 #[test]
18444 fn hardbreak_with_ordered_marker_in_bullet_item_roundtrips() {
18445 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18448 {"type":"listItem","content":[{"type":"paragraph","content":[
18449 {"type":"text","text":"1. First item"},
18450 {"type":"hardBreak"},
18451 {"type":"text","text":"2. Honouring existing commitments"}
18452 ]}]}
18453 ]}]}"#;
18454 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18455 let md = adf_to_markdown(&doc).unwrap();
18456
18457 assert!(
18459 md.contains(" 2. Honouring"),
18460 "Continuation line should be indented, got:\n{md}"
18461 );
18462
18463 let rt = markdown_to_adf(&md).unwrap();
18465 let list = &rt.content[0];
18466 assert_eq!(list.node_type, "bulletList");
18467 let items = list.content.as_ref().unwrap();
18468 assert_eq!(
18469 items.len(),
18470 1,
18471 "Should be one list item, got {}",
18472 items.len()
18473 );
18474
18475 let para = &items[0].content.as_ref().unwrap()[0];
18476 let inlines = para.content.as_ref().unwrap();
18477 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18478 assert_eq!(
18479 types,
18480 vec!["text", "hardBreak", "text"],
18481 "Expected text+hardBreak+text, got {types:?}"
18482 );
18483 assert_eq!(
18484 inlines[2].text.as_deref().unwrap(),
18485 "2. Honouring existing commitments"
18486 );
18487 }
18488
18489 #[test]
18490 fn hardbreak_with_ordered_marker_in_ordered_item_roundtrips() {
18491 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18493 {"type":"listItem","content":[{"type":"paragraph","content":[
18494 {"type":"text","text":"Introduction "},
18495 {"type":"hardBreak"},
18496 {"type":"text","text":"3. Third point"}
18497 ]}]}
18498 ]}]}"#;
18499 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18500 let md = adf_to_markdown(&doc).unwrap();
18501 let rt = markdown_to_adf(&md).unwrap();
18502
18503 let list = &rt.content[0];
18504 assert_eq!(list.node_type, "orderedList");
18505 let items = list.content.as_ref().unwrap();
18506 assert_eq!(items.len(), 1);
18507
18508 let para = &items[0].content.as_ref().unwrap()[0];
18509 let inlines = para.content.as_ref().unwrap();
18510 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18511 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18512 assert_eq!(inlines[2].text.as_deref().unwrap(), "3. Third point");
18513 }
18514
18515 #[test]
18516 fn hardbreak_with_bullet_marker_in_bullet_item_roundtrips() {
18517 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18519 {"type":"listItem","content":[{"type":"paragraph","content":[
18520 {"type":"text","text":"Header "},
18521 {"type":"hardBreak"},
18522 {"type":"text","text":"- not a sub-item"}
18523 ]}]}
18524 ]}]}"#;
18525 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18526 let md = adf_to_markdown(&doc).unwrap();
18527 let rt = markdown_to_adf(&md).unwrap();
18528
18529 let list = &rt.content[0];
18530 assert_eq!(list.node_type, "bulletList");
18531 let items = list.content.as_ref().unwrap();
18532 assert_eq!(
18533 items.len(),
18534 1,
18535 "Should be one list item, not {}",
18536 items.len()
18537 );
18538
18539 let para = &items[0].content.as_ref().unwrap()[0];
18540 let inlines = para.content.as_ref().unwrap();
18541 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18542 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18543 assert_eq!(inlines[2].text.as_deref().unwrap(), "- not a sub-item");
18544 }
18545
18546 #[test]
18547 fn hardbreak_continuation_followed_by_sub_list() {
18548 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18550 {"type":"listItem","content":[
18551 {"type":"paragraph","content":[
18552 {"type":"text","text":"Main item "},
18553 {"type":"hardBreak"},
18554 {"type":"text","text":"continued here"}
18555 ]},
18556 {"type":"bulletList","content":[
18557 {"type":"listItem","content":[{"type":"paragraph","content":[
18558 {"type":"text","text":"sub-item"}
18559 ]}]}
18560 ]}
18561 ]}
18562 ]}]}"#;
18563 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18564 let md = adf_to_markdown(&doc).unwrap();
18565 let rt = markdown_to_adf(&md).unwrap();
18566
18567 let list = &rt.content[0];
18568 let items = list.content.as_ref().unwrap();
18569 assert_eq!(items.len(), 1);
18570
18571 let item_content = items[0].content.as_ref().unwrap();
18572 assert_eq!(item_content.len(), 2, "Expected paragraph + nested list");
18573 assert_eq!(item_content[0].node_type, "paragraph");
18574 assert_eq!(item_content[1].node_type, "bulletList");
18575
18576 let inlines = item_content[0].content.as_ref().unwrap();
18578 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18579 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18580 }
18581
18582 #[test]
18583 fn multiple_hardbreaks_with_numbered_text_roundtrip() {
18584 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18586 {"type":"listItem","content":[{"type":"paragraph","content":[
18587 {"type":"text","text":"Preamble "},
18588 {"type":"hardBreak"},
18589 {"type":"text","text":"1. Alpha "},
18590 {"type":"hardBreak"},
18591 {"type":"text","text":"2. Bravo"}
18592 ]}]}
18593 ]}]}"#;
18594 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18595 let md = adf_to_markdown(&doc).unwrap();
18596 let rt = markdown_to_adf(&md).unwrap();
18597
18598 let items = rt.content[0].content.as_ref().unwrap();
18599 assert_eq!(items.len(), 1);
18600
18601 let inlines = items[0].content.as_ref().unwrap()[0]
18602 .content
18603 .as_ref()
18604 .unwrap();
18605 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18606 assert_eq!(
18607 types,
18608 vec!["text", "hardBreak", "text", "hardBreak", "text"]
18609 );
18610 }
18611
18612 #[test]
18613 fn trailing_hardbreak_in_bullet_item_roundtrips() {
18614 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18618 {"type":"listItem","content":[{"type":"paragraph","content":[
18619 {"type":"text","text":"ends with break"},
18620 {"type":"hardBreak"}
18621 ]}]}
18622 ]}]}"#;
18623 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18624 let md = adf_to_markdown(&doc).unwrap();
18625 let rt = markdown_to_adf(&md).unwrap();
18626
18627 let list = &rt.content[0];
18628 assert_eq!(list.node_type, "bulletList");
18629 let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18630 .content
18631 .as_ref()
18632 .unwrap();
18633 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18634 assert_eq!(types, vec!["text", "hardBreak"]);
18635 }
18636
18637 #[test]
18638 fn trailing_hardbreak_in_ordered_item_roundtrips() {
18639 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18642 {"type":"listItem","content":[{"type":"paragraph","content":[
18643 {"type":"text","text":"ends with break"},
18644 {"type":"hardBreak"}
18645 ]}]}
18646 ]}]}"#;
18647 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18648 let md = adf_to_markdown(&doc).unwrap();
18649 let rt = markdown_to_adf(&md).unwrap();
18650
18651 let list = &rt.content[0];
18652 assert_eq!(list.node_type, "orderedList");
18653 let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18654 .content
18655 .as_ref()
18656 .unwrap();
18657 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18658 assert_eq!(types, vec!["text", "hardBreak"]);
18659 }
18660
18661 #[test]
18662 fn trailing_space_hardbreak_continuation_in_bullet_item() {
18663 let md = "- first line \n 2. continued\n";
18667 let doc = markdown_to_adf(md).unwrap();
18668
18669 let list = &doc.content[0];
18670 assert_eq!(list.node_type, "bulletList");
18671 let items = list.content.as_ref().unwrap();
18672 assert_eq!(
18673 items.len(),
18674 1,
18675 "Should be one list item, got {}",
18676 items.len()
18677 );
18678
18679 let para = &items[0].content.as_ref().unwrap()[0];
18680 let inlines = para.content.as_ref().unwrap();
18681 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18682 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18683 assert_eq!(inlines[2].text.as_deref().unwrap(), "2. continued");
18684 }
18685
18686 #[test]
18687 fn trailing_space_hardbreak_continuation_in_ordered_item() {
18688 let md = "1. first line \n - continued\n";
18691 let doc = markdown_to_adf(md).unwrap();
18692
18693 let list = &doc.content[0];
18694 assert_eq!(list.node_type, "orderedList");
18695 let items = list.content.as_ref().unwrap();
18696 assert_eq!(
18697 items.len(),
18698 1,
18699 "Should be one list item, got {}",
18700 items.len()
18701 );
18702
18703 let para = &items[0].content.as_ref().unwrap()[0];
18704 let inlines = para.content.as_ref().unwrap();
18705 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18706 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18707 assert_eq!(inlines[2].text.as_deref().unwrap(), "- continued");
18708 }
18709
18710 #[test]
18711 fn multi_paragraph_list_item_with_ordered_marker_roundtrips() {
18712 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18715 {"type":"listItem","content":[
18716 {"type":"paragraph","content":[{"type":"text","text":"some preamble"}]},
18717 {"type":"paragraph","content":[{"type":"text","text":"2. Honouring existing commitments"}]}
18718 ]}
18719 ]}]}"#;
18720 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18721 let md = adf_to_markdown(&doc).unwrap();
18722 let rt = markdown_to_adf(&md).unwrap();
18723
18724 assert_eq!(rt.content.len(), 1, "Should be one top-level block");
18725 let list = &rt.content[0];
18726 assert_eq!(list.node_type, "bulletList");
18727 let items = list.content.as_ref().unwrap();
18728 assert_eq!(items.len(), 1);
18729 let item_content = items[0].content.as_ref().unwrap();
18730 assert_eq!(
18731 item_content.len(),
18732 2,
18733 "Expected 2 paragraphs inside the list item, got {}",
18734 item_content.len()
18735 );
18736 assert_eq!(item_content[0].node_type, "paragraph");
18737 assert_eq!(item_content[1].node_type, "paragraph");
18738 let text = item_content[1].content.as_ref().unwrap()[0]
18739 .text
18740 .as_deref()
18741 .unwrap();
18742 assert_eq!(text, "2. Honouring existing commitments");
18743 }
18744
18745 #[test]
18746 fn multi_paragraph_list_item_with_bullet_marker_roundtrips() {
18747 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18749 {"type":"listItem","content":[
18750 {"type":"paragraph","content":[{"type":"text","text":"preamble"}]},
18751 {"type":"paragraph","content":[{"type":"text","text":"- not a sub-item"}]}
18752 ]}
18753 ]}]}"#;
18754 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18755 let md = adf_to_markdown(&doc).unwrap();
18756 let rt = markdown_to_adf(&md).unwrap();
18757
18758 let items = rt.content[0].content.as_ref().unwrap();
18759 assert_eq!(items.len(), 1);
18760 let item_content = items[0].content.as_ref().unwrap();
18761 assert_eq!(item_content.len(), 2);
18762 assert_eq!(item_content[1].node_type, "paragraph");
18763 let text = item_content[1].content.as_ref().unwrap()[0]
18764 .text
18765 .as_deref()
18766 .unwrap();
18767 assert_eq!(text, "- not a sub-item");
18768 }
18769
18770 #[test]
18771 fn backslash_escape_in_inline_text() {
18772 let nodes = parse_inline(r"2\. text");
18774 assert_eq!(nodes.len(), 1, "Should be one text node");
18775 assert_eq!(nodes[0].text.as_deref().unwrap(), "2. text");
18776 }
18777
18778 #[test]
18779 fn escape_list_marker_ordered() {
18780 assert_eq!(escape_list_marker("2. text"), r"2\. text");
18781 assert_eq!(escape_list_marker("10. tenth"), r"10\. tenth");
18782 }
18783
18784 #[test]
18785 fn escape_list_marker_bullet() {
18786 assert_eq!(escape_list_marker("- text"), r"\- text");
18787 assert_eq!(escape_list_marker("* text"), r"\* text");
18788 assert_eq!(escape_list_marker("+ text"), r"\+ text");
18789 }
18790
18791 #[test]
18792 fn escape_list_marker_plain() {
18793 assert_eq!(escape_list_marker("plain text"), "plain text");
18794 assert_eq!(escape_list_marker("no. marker"), "no. marker");
18795 }
18796
18797 #[test]
18798 fn escape_emoji_shortcodes_basic() {
18799 assert_eq!(escape_emoji_shortcodes(":fire:"), r"\:fire:");
18800 assert_eq!(
18801 escape_emoji_shortcodes("hello :wave: world"),
18802 r"hello \:wave: world"
18803 );
18804 }
18805
18806 #[test]
18807 fn escape_emoji_shortcodes_double_colon() {
18808 assert_eq!(
18810 escape_emoji_shortcodes("Status::Active::Running"),
18811 r"Status:\:Active::Running"
18812 );
18813 }
18814
18815 #[test]
18816 fn escape_emoji_shortcodes_no_match() {
18817 assert_eq!(escape_emoji_shortcodes("Time is 10:30"), "Time is 10:30");
18819 assert_eq!(escape_emoji_shortcodes("no colons here"), "no colons here");
18820 assert_eq!(escape_emoji_shortcodes("trailing:"), "trailing:");
18821 assert_eq!(escape_emoji_shortcodes(":"), ":");
18822 }
18823
18824 #[test]
18825 fn escape_emoji_shortcodes_mixed() {
18826 assert_eq!(
18827 escape_emoji_shortcodes("Alert :fire: on pod:pod42"),
18828 r"Alert \:fire: on pod:pod42"
18829 );
18830 }
18831
18832 #[test]
18833 fn escape_emoji_shortcodes_unicode() {
18834 assert_eq!(escape_emoji_shortcodes(":Café:"), r"\:Café:");
18839 assert_eq!(escape_emoji_shortcodes(":über:"), r"\:über:");
18840 assert_eq!(escape_emoji_shortcodes(":配置:"), r"\:配置:");
18841 assert_eq!(
18842 escape_emoji_shortcodes("ZBC::配置::Production"),
18843 r"ZBC:\:配置::Production"
18844 );
18845 }
18846
18847 #[test]
18848 fn escape_emoji_shortcodes_mixed_script_name() {
18849 assert_eq!(escape_emoji_shortcodes(":abc配置:"), r"\:abc配置:");
18852 assert_eq!(escape_emoji_shortcodes(":配置abc:"), r"\:配置abc:");
18853 }
18854
18855 #[test]
18856 fn escape_emoji_shortcodes_unicode_followed_by_non_colon() {
18857 assert_eq!(escape_emoji_shortcodes(":Café world:"), ":Café world:");
18862 }
18863
18864 #[test]
18865 fn escape_emoji_shortcodes_name_runs_to_end() {
18866 assert_eq!(escape_emoji_shortcodes(":abc"), ":abc");
18872 assert_eq!(escape_emoji_shortcodes(":配置"), ":配置");
18873 }
18874
18875 #[test]
18876 fn unicode_shortcode_pattern_text_round_trips_as_text() {
18877 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18880 {"type":"text","text":"Visit :Café: today"}
18881 ]}]}"#;
18882 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18883
18884 let md = adf_to_markdown(&doc).unwrap();
18885 let round_tripped = markdown_to_adf(&md).unwrap();
18886 let content = round_tripped.content[0].content.as_ref().unwrap();
18887
18888 assert_eq!(
18889 content.len(),
18890 1,
18891 "should be a single text node, got: {content:?}"
18892 );
18893 assert_eq!(content[0].node_type, "text");
18894 assert_eq!(content[0].text.as_deref().unwrap(), "Visit :Café: today");
18895 }
18896
18897 #[test]
18898 fn unicode_double_colon_pattern_text_round_trips() {
18899 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18901 {"type":"text","text":"Use ZBC::配置::Production for prod"}
18902 ]}]}"#;
18903 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18904
18905 let md = adf_to_markdown(&doc).unwrap();
18906 let round_tripped = markdown_to_adf(&md).unwrap();
18907 let content = round_tripped.content[0].content.as_ref().unwrap();
18908
18909 assert_eq!(
18910 content.len(),
18911 1,
18912 "should be a single text node, got: {content:?}"
18913 );
18914 assert_eq!(
18915 content[0].text.as_deref().unwrap(),
18916 "Use ZBC::配置::Production for prod"
18917 );
18918 }
18919
18920 #[test]
18921 fn merge_adjacent_text_nodes() {
18922 let mut nodes = vec![AdfNode::text("a"), AdfNode::text("b"), AdfNode::text("c")];
18923 merge_adjacent_text(&mut nodes);
18924 assert_eq!(nodes.len(), 1);
18925 assert_eq!(nodes[0].text.as_deref().unwrap(), "abc");
18926 }
18927
18928 #[test]
18931 fn issue_455_paragraph_hardbreak_ordered_marker_roundtrips() {
18932 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18935 {"type":"text","text":"Introduction: "},
18936 {"type":"hardBreak"},
18937 {"type":"text","text":"1. This text follows a hardBreak"}
18938 ]}]}"#;
18939 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18940 let md = adf_to_markdown(&doc).unwrap();
18941 let rt = markdown_to_adf(&md).unwrap();
18942
18943 assert_eq!(rt.content.len(), 1, "Should remain one block");
18944 assert_eq!(rt.content[0].node_type, "paragraph");
18945 let inlines = rt.content[0].content.as_ref().unwrap();
18946 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18947 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18948 assert_eq!(
18949 inlines[2].text.as_deref(),
18950 Some("1. This text follows a hardBreak")
18951 );
18952 }
18953
18954 #[test]
18955 fn issue_455_paragraph_hardbreak_bullet_marker_roundtrips() {
18956 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18958 {"type":"text","text":"Intro"},
18959 {"type":"hardBreak"},
18960 {"type":"text","text":"- not a list item"}
18961 ]}]}"#;
18962 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18963 let md = adf_to_markdown(&doc).unwrap();
18964 let rt = markdown_to_adf(&md).unwrap();
18965
18966 assert_eq!(rt.content.len(), 1);
18967 assert_eq!(rt.content[0].node_type, "paragraph");
18968 let inlines = rt.content[0].content.as_ref().unwrap();
18969 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18970 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18971 assert_eq!(inlines[2].text.as_deref(), Some("- not a list item"));
18972 }
18973
18974 #[test]
18975 fn issue_455_paragraph_hardbreak_heading_marker_roundtrips() {
18976 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18978 {"type":"text","text":"Intro"},
18979 {"type":"hardBreak"},
18980 {"type":"text","text":"# not a heading"}
18981 ]}]}"##;
18982 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18983 let md = adf_to_markdown(&doc).unwrap();
18984 let rt = markdown_to_adf(&md).unwrap();
18985
18986 assert_eq!(rt.content.len(), 1);
18987 assert_eq!(rt.content[0].node_type, "paragraph");
18988 let inlines = rt.content[0].content.as_ref().unwrap();
18989 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18990 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18991 assert_eq!(inlines[2].text.as_deref(), Some("# not a heading"));
18992 }
18993
18994 #[test]
18995 fn issue_455_paragraph_hardbreak_blockquote_marker_roundtrips() {
18996 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18998 {"type":"text","text":"Intro"},
18999 {"type":"hardBreak"},
19000 {"type":"text","text":"> not a blockquote"}
19001 ]}]}"#;
19002 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19003 let md = adf_to_markdown(&doc).unwrap();
19004 let rt = markdown_to_adf(&md).unwrap();
19005
19006 assert_eq!(rt.content.len(), 1);
19007 assert_eq!(rt.content[0].node_type, "paragraph");
19008 let inlines = rt.content[0].content.as_ref().unwrap();
19009 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19010 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19011 assert_eq!(inlines[2].text.as_deref(), Some("> not a blockquote"));
19012 }
19013
19014 #[test]
19015 fn issue_455_paragraph_multiple_hardbreaks_with_ordered_markers() {
19016 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19018 {"type":"text","text":"Preamble"},
19019 {"type":"hardBreak"},
19020 {"type":"text","text":"1. First"},
19021 {"type":"hardBreak"},
19022 {"type":"text","text":"2. Second"},
19023 {"type":"hardBreak"},
19024 {"type":"text","text":"3. Third"}
19025 ]}]}"#;
19026 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19027 let md = adf_to_markdown(&doc).unwrap();
19028 let rt = markdown_to_adf(&md).unwrap();
19029
19030 assert_eq!(rt.content.len(), 1);
19031 assert_eq!(rt.content[0].node_type, "paragraph");
19032 let inlines = rt.content[0].content.as_ref().unwrap();
19033 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19034 assert_eq!(
19035 types,
19036 vec![
19037 "text",
19038 "hardBreak",
19039 "text",
19040 "hardBreak",
19041 "text",
19042 "hardBreak",
19043 "text"
19044 ]
19045 );
19046 assert_eq!(inlines[2].text.as_deref(), Some("1. First"));
19047 assert_eq!(inlines[4].text.as_deref(), Some("2. Second"));
19048 assert_eq!(inlines[6].text.as_deref(), Some("3. Third"));
19049 }
19050
19051 #[test]
19052 fn issue_455_paragraph_hardbreak_jfm_indentation() {
19053 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19055 {"type":"text","text":"Intro"},
19056 {"type":"hardBreak"},
19057 {"type":"text","text":"1. continued"}
19058 ]}]}"#;
19059 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19060 let md = adf_to_markdown(&doc).unwrap();
19061 assert!(
19062 md.contains("Intro\\\n 1. continued"),
19063 "Continuation should be 2-space-indented, got: {md:?}"
19064 );
19065 }
19066
19067 #[test]
19068 fn issue_455_paragraph_hardbreak_from_jfm() {
19069 let md = "Intro\\\n 1. This is continuation text\n";
19072 let doc = markdown_to_adf(md).unwrap();
19073
19074 assert_eq!(doc.content.len(), 1);
19075 assert_eq!(doc.content[0].node_type, "paragraph");
19076 let inlines = doc.content[0].content.as_ref().unwrap();
19077 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19078 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19079 assert_eq!(
19080 inlines[2].text.as_deref(),
19081 Some("1. This is continuation text")
19082 );
19083 }
19084
19085 #[test]
19086 fn issue_455_paragraph_starts_with_ordered_marker_and_hardbreak() {
19087 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19091 {"type":"text","text":"1. Starting with a number"},
19092 {"type":"hardBreak"},
19093 {"type":"text","text":"continuation after break"}
19094 ]}]}"#;
19095 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19096 let md = adf_to_markdown(&doc).unwrap();
19097 assert!(
19099 md.contains(r"1\. Starting with a number"),
19100 "First line should have escaped list marker, got: {md:?}"
19101 );
19102 let rt = markdown_to_adf(&md).unwrap();
19103
19104 assert_eq!(rt.content.len(), 1);
19105 assert_eq!(rt.content[0].node_type, "paragraph");
19106 let inlines = rt.content[0].content.as_ref().unwrap();
19107 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19108 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19109 assert_eq!(
19110 inlines[0].text.as_deref(),
19111 Some("1. Starting with a number")
19112 );
19113 assert_eq!(inlines[2].text.as_deref(), Some("continuation after break"));
19114 }
19115
19116 #[test]
19117 fn ordered_marker_paragraph_in_table_cell_roundtrips() {
19118 let adf_json = r#"{"version":1,"type":"doc","content":[{
19121 "type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},
19122 "content":[{"type":"tableRow","content":[{
19123 "type":"tableCell","attrs":{"colspan":1,"rowspan":1},
19124 "content":[{"type":"paragraph","content":[
19125 {"type":"text","text":"2. Honouring existing commitments"}
19126 ]}]
19127 }]}]
19128 }]}"#;
19129 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19130 let md = adf_to_markdown(&doc).unwrap();
19131 let rt = markdown_to_adf(&md).unwrap();
19132
19133 let table = &rt.content[0];
19134 let cell = &table.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0];
19135 let para = &cell.content.as_ref().unwrap()[0];
19136 assert_eq!(para.node_type, "paragraph");
19137 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
19138 assert_eq!(text, "2. Honouring existing commitments");
19139 }
19140
19141 #[test]
19142 fn bullet_marker_paragraph_standalone_roundtrips() {
19143 let adf_json = r#"{"version":1,"type":"doc","content":[
19146 {"type":"paragraph","content":[
19147 {"type":"text","text":"- not a list item"}
19148 ]}
19149 ]}"#;
19150 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19151 let md = adf_to_markdown(&doc).unwrap();
19152 assert!(
19153 md.contains(r"\- not a list item"),
19154 "Should escape the leading dash, got:\n{md}"
19155 );
19156 let rt = markdown_to_adf(&md).unwrap();
19157 assert_eq!(rt.content[0].node_type, "paragraph");
19158 let text = rt.content[0].content.as_ref().unwrap()[0]
19159 .text
19160 .as_deref()
19161 .unwrap();
19162 assert_eq!(text, "- not a list item");
19163 }
19164
19165 #[test]
19166 fn merge_adjacent_text_skips_non_text_nodes() {
19167 let mut nodes = vec![
19170 AdfNode::text("a"),
19171 AdfNode::hard_break(),
19172 AdfNode::text("b"),
19173 ];
19174 merge_adjacent_text(&mut nodes);
19175 assert_eq!(nodes.len(), 3);
19176 }
19177
19178 #[test]
19179 fn star_bullet_paragraph_roundtrips() {
19180 let adf_json = r#"{"version":1,"type":"doc","content":[
19183 {"type":"paragraph","content":[
19184 {"type":"text","text":"* starred"}
19185 ]}
19186 ]}"#;
19187 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19188 let md = adf_to_markdown(&doc).unwrap();
19189 let rt = markdown_to_adf(&md).unwrap();
19190 assert_eq!(rt.content[0].node_type, "paragraph");
19191 assert_eq!(
19192 rt.content[0].content.as_ref().unwrap()[0]
19193 .text
19194 .as_deref()
19195 .unwrap(),
19196 "* starred"
19197 );
19198 }
19199
19200 #[test]
19203 fn issue_388_ordered_list_with_strong_hardbreak_roundtrips() {
19204 let adf_json = r#"{"version":1,"type":"doc","content":[
19207 {"type":"orderedList","attrs":{"order":1},"content":[
19208 {"type":"listItem","content":[
19209 {"type":"paragraph","content":[
19210 {"type":"text","text":"Bold heading","marks":[{"type":"strong"}]},
19211 {"type":"hardBreak"},
19212 {"type":"text","text":"Content after break"}
19213 ]}
19214 ]},
19215 {"type":"listItem","content":[
19216 {"type":"paragraph","content":[
19217 {"type":"text","text":"Second item","marks":[{"type":"strong"}]},
19218 {"type":"hardBreak"},
19219 {"type":"text","text":"More content"}
19220 ]}
19221 ]}
19222 ]}
19223 ]}"#;
19224 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19225 let md = adf_to_markdown(&doc).unwrap();
19226 let rt = markdown_to_adf(&md).unwrap();
19227
19228 assert_eq!(
19230 rt.content.len(),
19231 1,
19232 "Should be 1 block (orderedList), got {}",
19233 rt.content.len()
19234 );
19235 assert_eq!(rt.content[0].node_type, "orderedList");
19236 let items = rt.content[0].content.as_ref().unwrap();
19237 assert_eq!(
19238 items.len(),
19239 2,
19240 "Should have 2 listItems, got {}",
19241 items.len()
19242 );
19243
19244 let p1 = items[0].content.as_ref().unwrap()[0]
19246 .content
19247 .as_ref()
19248 .unwrap();
19249 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19250 assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19251 assert_eq!(p1[0].text.as_deref(), Some("Bold heading"));
19252 assert_eq!(p1[2].text.as_deref(), Some("Content after break"));
19253
19254 let p2 = items[1].content.as_ref().unwrap()[0]
19256 .content
19257 .as_ref()
19258 .unwrap();
19259 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19260 assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19261 assert_eq!(p2[0].text.as_deref(), Some("Second item"));
19262 assert_eq!(p2[2].text.as_deref(), Some("More content"));
19263 }
19264
19265 #[test]
19266 fn issue_388_bullet_list_with_strong_hardbreak_roundtrips() {
19267 let adf_json = r#"{"version":1,"type":"doc","content":[
19269 {"type":"bulletList","content":[
19270 {"type":"listItem","content":[
19271 {"type":"paragraph","content":[
19272 {"type":"text","text":"First","marks":[{"type":"strong"}]},
19273 {"type":"hardBreak"},
19274 {"type":"text","text":"details"}
19275 ]}
19276 ]},
19277 {"type":"listItem","content":[
19278 {"type":"paragraph","content":[
19279 {"type":"text","text":"Second","marks":[{"type":"em"}]},
19280 {"type":"hardBreak"},
19281 {"type":"text","text":"more details"}
19282 ]}
19283 ]}
19284 ]}
19285 ]}"#;
19286 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19287 let md = adf_to_markdown(&doc).unwrap();
19288 let rt = markdown_to_adf(&md).unwrap();
19289
19290 assert_eq!(rt.content.len(), 1);
19291 assert_eq!(rt.content[0].node_type, "bulletList");
19292 let items = rt.content[0].content.as_ref().unwrap();
19293 assert_eq!(items.len(), 2);
19294
19295 let p1 = items[0].content.as_ref().unwrap()[0]
19296 .content
19297 .as_ref()
19298 .unwrap();
19299 assert_eq!(p1[0].text.as_deref(), Some("First"));
19300 assert_eq!(p1[2].text.as_deref(), Some("details"));
19301
19302 let p2 = items[1].content.as_ref().unwrap()[0]
19303 .content
19304 .as_ref()
19305 .unwrap();
19306 assert_eq!(p2[0].text.as_deref(), Some("Second"));
19307 assert_eq!(p2[2].text.as_deref(), Some("more details"));
19308 }
19309
19310 #[test]
19311 fn issue_388_ordered_list_hardbreak_jfm_indentation() {
19312 let adf_json = r#"{"version":1,"type":"doc","content":[
19314 {"type":"orderedList","attrs":{"order":1},"content":[
19315 {"type":"listItem","content":[
19316 {"type":"paragraph","content":[
19317 {"type":"text","text":"heading","marks":[{"type":"strong"}]},
19318 {"type":"hardBreak"},
19319 {"type":"text","text":"body"}
19320 ]}
19321 ]}
19322 ]}
19323 ]}"#;
19324 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19325 let md = adf_to_markdown(&doc).unwrap();
19326 assert!(
19327 md.contains("1. **heading**\\\n body"),
19328 "Continuation should be indented, got:\n{md}"
19329 );
19330 }
19331
19332 #[test]
19333 fn issue_388_ordered_list_hardbreak_from_jfm() {
19334 let md = "1. **bold**\\\n continued\n2. **also bold**\\\n also continued\n";
19336 let doc = markdown_to_adf(md).unwrap();
19337
19338 assert_eq!(doc.content.len(), 1);
19339 assert_eq!(doc.content[0].node_type, "orderedList");
19340 let items = doc.content[0].content.as_ref().unwrap();
19341 assert_eq!(items.len(), 2);
19342
19343 let p1 = items[0].content.as_ref().unwrap()[0]
19344 .content
19345 .as_ref()
19346 .unwrap();
19347 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19348 assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19349 assert_eq!(p1[0].text.as_deref(), Some("bold"));
19350 assert_eq!(p1[2].text.as_deref(), Some("continued"));
19351
19352 let p2 = items[1].content.as_ref().unwrap()[0]
19353 .content
19354 .as_ref()
19355 .unwrap();
19356 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19357 assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19358 }
19359
19360 #[test]
19361 fn issue_388_bullet_list_hardbreak_from_jfm() {
19362 let md = "- first\\\n second\n- third\\\n fourth\n";
19364 let doc = markdown_to_adf(md).unwrap();
19365
19366 assert_eq!(doc.content.len(), 1);
19367 assert_eq!(doc.content[0].node_type, "bulletList");
19368 let items = doc.content[0].content.as_ref().unwrap();
19369 assert_eq!(items.len(), 2);
19370
19371 for (i, expected) in [("first", "second"), ("third", "fourth")]
19372 .iter()
19373 .enumerate()
19374 {
19375 let p = items[i].content.as_ref().unwrap()[0]
19376 .content
19377 .as_ref()
19378 .unwrap();
19379 let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19380 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19381 assert_eq!(p[0].text.as_deref(), Some(expected.0));
19382 assert_eq!(p[2].text.as_deref(), Some(expected.1));
19383 }
19384 }
19385
19386 #[test]
19387 fn issue_433_heading_hardbreak_roundtrips() {
19388 let adf_json = r#"{"version":1,"type":"doc","content":[{
19390 "type":"heading",
19391 "attrs":{"level":1},
19392 "content":[
19393 {"type":"text","text":"Line one"},
19394 {"type":"hardBreak"},
19395 {"type":"text","text":"Line two"}
19396 ]
19397 }]}"#;
19398 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19399 let md = adf_to_markdown(&doc).unwrap();
19400 let rt = markdown_to_adf(&md).unwrap();
19401
19402 assert_eq!(
19403 rt.content.len(),
19404 1,
19405 "Should remain a single heading, got {} blocks",
19406 rt.content.len()
19407 );
19408 assert_eq!(rt.content[0].node_type, "heading");
19409 let inlines = rt.content[0].content.as_ref().unwrap();
19410 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19411 assert_eq!(
19412 types,
19413 vec!["text", "hardBreak", "text"],
19414 "hardBreak should be preserved, got: {types:?}"
19415 );
19416 assert_eq!(inlines[0].text.as_deref(), Some("Line one"));
19417 assert_eq!(inlines[2].text.as_deref(), Some("Line two"));
19418 }
19419
19420 #[test]
19421 fn issue_433_heading_hardbreak_jfm_indentation() {
19422 let adf_json = r#"{"version":1,"type":"doc","content":[{
19424 "type":"heading",
19425 "attrs":{"level":2},
19426 "content":[
19427 {"type":"text","text":"Title"},
19428 {"type":"hardBreak"},
19429 {"type":"text","text":"Subtitle"}
19430 ]
19431 }]}"#;
19432 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19433 let md = adf_to_markdown(&doc).unwrap();
19434 assert!(
19435 md.contains("## Title\\\n Subtitle"),
19436 "Continuation should be indented, got:\n{md}"
19437 );
19438 }
19439
19440 #[test]
19441 fn issue_433_heading_hardbreak_from_jfm() {
19442 let md = "# First\\\n Second\n";
19444 let doc = markdown_to_adf(md).unwrap();
19445
19446 assert_eq!(doc.content.len(), 1);
19447 assert_eq!(doc.content[0].node_type, "heading");
19448 let inlines = doc.content[0].content.as_ref().unwrap();
19449 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19450 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19451 assert_eq!(inlines[0].text.as_deref(), Some("First"));
19452 assert_eq!(inlines[2].text.as_deref(), Some("Second"));
19453 }
19454
19455 #[test]
19456 fn issue_433_heading_consecutive_hardbreaks_roundtrip() {
19457 let adf_json = r#"{"version":1,"type":"doc","content":[{
19459 "type":"heading",
19460 "attrs":{"level":3},
19461 "content":[
19462 {"type":"text","text":"A"},
19463 {"type":"hardBreak"},
19464 {"type":"hardBreak"},
19465 {"type":"text","text":"B"}
19466 ]
19467 }]}"#;
19468 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19469 let md = adf_to_markdown(&doc).unwrap();
19470 let rt = markdown_to_adf(&md).unwrap();
19471
19472 assert_eq!(rt.content.len(), 1, "Should remain a single heading");
19473 assert_eq!(rt.content[0].node_type, "heading");
19474 let inlines = rt.content[0].content.as_ref().unwrap();
19475 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19476 assert_eq!(types, vec!["text", "hardBreak", "hardBreak", "text"]);
19477 }
19478
19479 #[test]
19480 fn issue_433_heading_with_strong_and_hardbreak_roundtrips() {
19481 let adf_json = r#"{"version":1,"type":"doc","content":[{
19483 "type":"heading",
19484 "attrs":{"level":1},
19485 "content":[
19486 {"type":"text","text":"Bold title","marks":[{"type":"strong"}]},
19487 {"type":"hardBreak"},
19488 {"type":"text","text":"plain continuation"}
19489 ]
19490 }]}"#;
19491 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19492 let md = adf_to_markdown(&doc).unwrap();
19493 let rt = markdown_to_adf(&md).unwrap();
19494
19495 assert_eq!(rt.content.len(), 1);
19496 assert_eq!(rt.content[0].node_type, "heading");
19497 let inlines = rt.content[0].content.as_ref().unwrap();
19498 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19499 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19500 assert_eq!(inlines[0].text.as_deref(), Some("Bold title"));
19501 assert_eq!(inlines[2].text.as_deref(), Some("plain continuation"));
19502 }
19503
19504 #[test]
19505 fn issue_433_heading_with_link_and_hardbreak_roundtrips() {
19506 let adf_json = r#"{"version":1,"type":"doc","content":[{
19508 "type":"heading",
19509 "attrs":{"level":1},
19510 "content":[
19511 {"type":"text","text":"Click here","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
19512 {"type":"hardBreak"},
19513 {"type":"text","text":"Subtitle text"}
19514 ]
19515 }]}"#;
19516 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19517 let md = adf_to_markdown(&doc).unwrap();
19518 let rt = markdown_to_adf(&md).unwrap();
19519
19520 assert_eq!(rt.content.len(), 1);
19521 assert_eq!(rt.content[0].node_type, "heading");
19522 let inlines = rt.content[0].content.as_ref().unwrap();
19523 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19524 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19525 assert_eq!(inlines[2].text.as_deref(), Some("Subtitle text"));
19526 }
19527
19528 #[test]
19529 fn has_trailing_hard_break_backslash() {
19530 assert!(has_trailing_hard_break("text\\"));
19531 assert!(has_trailing_hard_break("**bold**\\"));
19532 }
19533
19534 #[test]
19535 fn has_trailing_hard_break_trailing_spaces() {
19536 assert!(has_trailing_hard_break("text "));
19537 assert!(has_trailing_hard_break("word "));
19538 }
19539
19540 #[test]
19541 fn has_trailing_hard_break_false() {
19542 assert!(!has_trailing_hard_break("plain text"));
19543 assert!(!has_trailing_hard_break("text "));
19544 assert!(!has_trailing_hard_break(""));
19545 }
19546
19547 #[test]
19548 fn collect_hardbreak_continuations_collects_indented() {
19549 let input = "first\\\n second\n third\n";
19552 let mut parser = MarkdownParser::new(input);
19553 parser.advance(); let mut text = "first\\".to_string();
19555 parser.collect_hardbreak_continuations(&mut text);
19556 assert_eq!(text, "first\\\nsecond");
19557 }
19558
19559 #[test]
19560 fn collect_hardbreak_continuations_stops_at_non_indented() {
19561 let input = "first\\\nnot indented\n";
19562 let mut parser = MarkdownParser::new(input);
19563 parser.advance();
19564 let mut text = "first\\".to_string();
19565 parser.collect_hardbreak_continuations(&mut text);
19566 assert_eq!(text, "first\\");
19568 }
19569
19570 #[test]
19571 fn collect_hardbreak_continuations_no_trailing_break() {
19572 let input = "plain\n indented\n";
19574 let mut parser = MarkdownParser::new(input);
19575 parser.advance();
19576 let mut text = "plain".to_string();
19577 parser.collect_hardbreak_continuations(&mut text);
19578 assert_eq!(text, "plain");
19579 }
19580
19581 #[test]
19582 fn collect_hardbreak_continuations_chained() {
19583 let input = "a\\\n b\\\n c\\\n d\n";
19585 let mut parser = MarkdownParser::new(input);
19586 parser.advance();
19587 let mut text = "a\\".to_string();
19588 parser.collect_hardbreak_continuations(&mut text);
19589 assert_eq!(text, "a\\\nb\\\nc\\\nd");
19590 }
19591
19592 #[test]
19593 fn collect_hardbreak_continuations_stops_before_image_line() {
19594 let input = "text\\\n {type=file id=x}\n";
19597 let mut parser = MarkdownParser::new(input);
19598 parser.advance(); let mut text = "text\\".to_string();
19600 parser.collect_hardbreak_continuations(&mut text);
19601 assert_eq!(text, "text\\");
19603 assert!(!parser.at_end());
19605 assert!(parser.current_line().contains(""));
19606 }
19607
19608 #[test]
19609 fn is_block_level_continuation_marker_positive_cases() {
19610 assert!(is_block_level_continuation_marker(""));
19612 assert!(is_block_level_continuation_marker("```ruby"));
19613 assert!(is_block_level_continuation_marker(":::panel{type=info}"));
19614 }
19615
19616 #[test]
19617 fn is_block_level_continuation_marker_negative_cases() {
19618 assert!(!is_block_level_continuation_marker("plain text"));
19620 assert!(!is_block_level_continuation_marker("- nested item"));
19621 assert!(!is_block_level_continuation_marker("continuation\\"));
19622 assert!(!is_block_level_continuation_marker(""));
19623 assert!(!is_block_level_continuation_marker("::partial"));
19625 assert!(!is_block_level_continuation_marker("`inline`"));
19627 }
19628
19629 #[test]
19630 fn collect_hardbreak_continuations_stops_before_code_fence() {
19631 let input = "text\\\n ```ruby\n Foo::Bar::Baz\n ```\n";
19635 let mut parser = MarkdownParser::new(input);
19636 parser.advance();
19637 let mut text = "text\\".to_string();
19638 parser.collect_hardbreak_continuations(&mut text);
19639 assert_eq!(text, "text\\");
19640 assert!(!parser.at_end());
19641 assert!(parser.current_line().starts_with(" ```"));
19642 }
19643
19644 #[test]
19645 fn collect_hardbreak_continuations_stops_before_container_directive() {
19646 let input = "text\\\n :::panel{type=info}\n body\n :::\n";
19650 let mut parser = MarkdownParser::new(input);
19651 parser.advance();
19652 let mut text = "text\\".to_string();
19653 parser.collect_hardbreak_continuations(&mut text);
19654 assert_eq!(text, "text\\");
19655 assert!(!parser.at_end());
19656 assert!(parser.current_line().contains(":::panel"));
19657 }
19658
19659 #[test]
19660 fn collect_hardbreak_continuations_stops_before_indented_code_fence() {
19661 let input = "text\\\n ```text\n :fire:\n ```\n";
19665 let mut parser = MarkdownParser::new(input);
19666 parser.advance();
19667 let mut text = "text\\".to_string();
19668 parser.collect_hardbreak_continuations(&mut text);
19669 assert_eq!(text, "text\\");
19670 assert!(!parser.at_end());
19671 assert!(parser.current_line().contains("```text"));
19672 }
19673
19674 #[test]
19675 fn ordered_list_with_sub_content_after_hardbreak() {
19676 let adf_json = r#"{"version":1,"type":"doc","content":[
19679 {"type":"orderedList","attrs":{"order":1},"content":[
19680 {"type":"listItem","content":[
19681 {"type":"paragraph","content":[
19682 {"type":"text","text":"parent"},
19683 {"type":"hardBreak"},
19684 {"type":"text","text":"continued"}
19685 ]},
19686 {"type":"bulletList","content":[
19687 {"type":"listItem","content":[
19688 {"type":"paragraph","content":[
19689 {"type":"text","text":"child"}
19690 ]}
19691 ]}
19692 ]}
19693 ]}
19694 ]}
19695 ]}"#;
19696 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19697 let md = adf_to_markdown(&doc).unwrap();
19698 let rt = markdown_to_adf(&md).unwrap();
19699
19700 assert_eq!(rt.content.len(), 1);
19701 assert_eq!(rt.content[0].node_type, "orderedList");
19702 let item_content = rt.content[0].content.as_ref().unwrap()[0]
19703 .content
19704 .as_ref()
19705 .unwrap();
19706 let p = item_content[0].content.as_ref().unwrap();
19708 let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19709 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19710 assert_eq!(p[0].text.as_deref(), Some("parent"));
19711 assert_eq!(p[2].text.as_deref(), Some("continued"));
19712 assert_eq!(item_content[1].node_type, "bulletList");
19714 }
19715
19716 #[test]
19717 fn render_list_item_content_no_content() {
19718 let item = AdfNode {
19720 node_type: "listItem".to_string(),
19721 attrs: None,
19722 content: None,
19723 text: None,
19724 marks: None,
19725 local_id: None,
19726 parameters: None,
19727 };
19728 let mut output = String::new();
19729 let opts = RenderOptions::default();
19730 render_list_item_content(&item, &mut output, &opts);
19731 assert_eq!(output, "\n");
19732 }
19733
19734 #[test]
19735 fn render_list_item_content_empty_content() {
19736 let item = AdfNode::list_item(vec![]);
19738 let mut output = String::new();
19739 let opts = RenderOptions::default();
19740 render_list_item_content(&item, &mut output, &opts);
19741 assert_eq!(output, "\n");
19742 }
19743
19744 #[test]
19745 fn plus_bullet_paragraph_roundtrips() {
19746 let adf_json = r#"{"version":1,"type":"doc","content":[
19749 {"type":"paragraph","content":[
19750 {"type":"text","text":"+ plus"}
19751 ]}
19752 ]}"#;
19753 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19754 let md = adf_to_markdown(&doc).unwrap();
19755 let rt = markdown_to_adf(&md).unwrap();
19756 assert_eq!(rt.content[0].node_type, "paragraph");
19757 assert_eq!(
19758 rt.content[0].content.as_ref().unwrap()[0]
19759 .text
19760 .as_deref()
19761 .unwrap(),
19762 "+ plus"
19763 );
19764 }
19765
19766 #[test]
19769 fn issue_430_file_media_in_bullet_list_roundtrip() {
19770 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19773 {"type":"listItem","content":[{
19774 "type":"mediaSingle",
19775 "attrs":{"layout":"center","width":1009,"widthType":"pixel"},
19776 "content":[{
19777 "type":"media",
19778 "attrs":{"collection":"contentId-123","height":576,"id":"00066e8e-554e-4d7e-af59-a0ef2888bdb6","type":"file","width":1009}
19779 }]
19780 }]}
19781 ]}]}"#;
19782 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19783 let md = adf_to_markdown(&doc).unwrap();
19784 let rt = markdown_to_adf(&md).unwrap();
19785
19786 let list = &rt.content[0];
19787 assert_eq!(list.node_type, "bulletList");
19788 let item = &list.content.as_ref().unwrap()[0];
19789 assert_eq!(item.node_type, "listItem");
19790 let ms = &item.content.as_ref().unwrap()[0];
19791 assert_eq!(ms.node_type, "mediaSingle");
19792 let ms_attrs = ms.attrs.as_ref().unwrap();
19793 assert_eq!(ms_attrs["layout"], "center");
19794 assert_eq!(ms_attrs["width"], 1009);
19795 assert_eq!(ms_attrs["widthType"], "pixel");
19796 let media = &ms.content.as_ref().unwrap()[0];
19797 assert_eq!(media.node_type, "media");
19798 let m_attrs = media.attrs.as_ref().unwrap();
19799 assert_eq!(m_attrs["type"], "file");
19800 assert_eq!(m_attrs["id"], "00066e8e-554e-4d7e-af59-a0ef2888bdb6");
19801 assert_eq!(m_attrs["collection"], "contentId-123");
19802 assert_eq!(m_attrs["height"], 576);
19803 assert_eq!(m_attrs["width"], 1009);
19804 }
19805
19806 #[test]
19807 fn issue_430_file_media_in_ordered_list_roundtrip() {
19808 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19810 {"type":"listItem","content":[{
19811 "type":"mediaSingle",
19812 "attrs":{"layout":"center"},
19813 "content":[{
19814 "type":"media",
19815 "attrs":{"type":"file","id":"abc-123","collection":"contentId-456","height":100,"width":200}
19816 }]
19817 }]}
19818 ]}]}"#;
19819 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19820 let md = adf_to_markdown(&doc).unwrap();
19821 let rt = markdown_to_adf(&md).unwrap();
19822
19823 let list = &rt.content[0];
19824 assert_eq!(list.node_type, "orderedList");
19825 let item = &list.content.as_ref().unwrap()[0];
19826 assert_eq!(item.node_type, "listItem");
19827 let ms = &item.content.as_ref().unwrap()[0];
19828 assert_eq!(ms.node_type, "mediaSingle");
19829 let media = &ms.content.as_ref().unwrap()[0];
19830 assert_eq!(media.node_type, "media");
19831 let m_attrs = media.attrs.as_ref().unwrap();
19832 assert_eq!(m_attrs["type"], "file");
19833 assert_eq!(m_attrs["id"], "abc-123");
19834 assert_eq!(m_attrs["collection"], "contentId-456");
19835 }
19836
19837 #[test]
19838 fn issue_430_external_media_in_bullet_list_roundtrip() {
19839 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19841 {"type":"listItem","content":[{
19842 "type":"mediaSingle",
19843 "attrs":{"layout":"center"},
19844 "content":[{
19845 "type":"media",
19846 "attrs":{"type":"external","url":"https://example.com/img.png","alt":"Photo"}
19847 }]
19848 }]}
19849 ]}]}"#;
19850 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19851 let md = adf_to_markdown(&doc).unwrap();
19852 let rt = markdown_to_adf(&md).unwrap();
19853
19854 let list = &rt.content[0];
19855 assert_eq!(list.node_type, "bulletList");
19856 let item = &list.content.as_ref().unwrap()[0];
19857 let ms = &item.content.as_ref().unwrap()[0];
19858 assert_eq!(ms.node_type, "mediaSingle");
19859 let media = &ms.content.as_ref().unwrap()[0];
19860 assert_eq!(media.node_type, "media");
19861 let m_attrs = media.attrs.as_ref().unwrap();
19862 assert_eq!(m_attrs["type"], "external");
19863 assert_eq!(m_attrs["url"], "https://example.com/img.png");
19864 }
19865
19866 #[test]
19867 fn issue_430_media_with_paragraph_siblings_in_list_item() {
19868 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19871 {"type":"listItem","content":[
19872 {"type":"paragraph","content":[{"type":"text","text":"Caption:"}]},
19873 {"type":"mediaSingle","attrs":{"layout":"center"},
19874 "content":[{"type":"media","attrs":{"type":"file","id":"img-001","collection":"col-1","height":50,"width":100}}]}
19875 ]}
19876 ]}]}"#;
19877 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19878 let md = adf_to_markdown(&doc).unwrap();
19879 let rt = markdown_to_adf(&md).unwrap();
19880
19881 let item = &rt.content[0].content.as_ref().unwrap()[0];
19882 let children = item.content.as_ref().unwrap();
19883 assert_eq!(children.len(), 2, "expected 2 children in listItem");
19884 assert_eq!(children[0].node_type, "paragraph");
19885 assert_eq!(children[1].node_type, "mediaSingle");
19886 let media = &children[1].content.as_ref().unwrap()[0];
19887 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-001");
19888 }
19889
19890 #[test]
19891 fn issue_430_multiple_media_in_list_items() {
19892 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19894 {"type":"listItem","content":[{
19895 "type":"mediaSingle","attrs":{"layout":"center"},
19896 "content":[{"type":"media","attrs":{"type":"file","id":"img-a","collection":"c1","height":10,"width":20}}]
19897 }]},
19898 {"type":"listItem","content":[{
19899 "type":"mediaSingle","attrs":{"layout":"center"},
19900 "content":[{"type":"media","attrs":{"type":"file","id":"img-b","collection":"c2","height":30,"width":40}}]
19901 }]}
19902 ]}]}"#;
19903 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19904 let md = adf_to_markdown(&doc).unwrap();
19905 let rt = markdown_to_adf(&md).unwrap();
19906
19907 let items = rt.content[0].content.as_ref().unwrap();
19908 assert_eq!(items.len(), 2);
19909 for (i, expected_id) in [("img-a", "c1"), ("img-b", "c2")].iter().enumerate() {
19910 let ms = &items[i].content.as_ref().unwrap()[0];
19911 assert_eq!(ms.node_type, "mediaSingle");
19912 let m_attrs = ms.content.as_ref().unwrap()[0].attrs.as_ref().unwrap();
19913 assert_eq!(m_attrs["id"], expected_id.0);
19914 assert_eq!(m_attrs["collection"], expected_id.1);
19915 }
19916 }
19917
19918 #[test]
19919 fn issue_430_jfm_to_adf_media_in_bullet_item() {
19920 let md = "- ![](){type=file id=test-id collection=col-1 height=100 width=200}\n";
19923 let doc = markdown_to_adf(md).unwrap();
19924
19925 let list = &doc.content[0];
19926 assert_eq!(list.node_type, "bulletList");
19927 let item = &list.content.as_ref().unwrap()[0];
19928 let ms = &item.content.as_ref().unwrap()[0];
19929 assert_eq!(
19930 ms.node_type, "mediaSingle",
19931 "expected mediaSingle, got {}",
19932 ms.node_type
19933 );
19934 let media = &ms.content.as_ref().unwrap()[0];
19935 assert_eq!(media.node_type, "media");
19936 let m_attrs = media.attrs.as_ref().unwrap();
19937 assert_eq!(m_attrs["type"], "file");
19938 assert_eq!(m_attrs["id"], "test-id");
19939 }
19940
19941 #[test]
19942 fn issue_430_jfm_to_adf_media_in_ordered_item() {
19943 let md = "1. \n";
19945 let doc = markdown_to_adf(md).unwrap();
19946
19947 let list = &doc.content[0];
19948 assert_eq!(list.node_type, "orderedList");
19949 let item = &list.content.as_ref().unwrap()[0];
19950 let ms = &item.content.as_ref().unwrap()[0];
19951 assert_eq!(
19952 ms.node_type, "mediaSingle",
19953 "expected mediaSingle, got {}",
19954 ms.node_type
19955 );
19956 }
19957
19958 #[test]
19959 fn issue_430_media_then_paragraph_in_bullet_list_roundtrip() {
19960 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19963 {"type":"listItem","content":[
19964 {"type":"mediaSingle","attrs":{"layout":"center"},
19965 "content":[{"type":"media","attrs":{"type":"file","id":"img-first","collection":"col-1","height":50,"width":100}}]},
19966 {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
19967 ]}
19968 ]}]}"#;
19969 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19970 let md = adf_to_markdown(&doc).unwrap();
19971 let rt = markdown_to_adf(&md).unwrap();
19972
19973 let item = &rt.content[0].content.as_ref().unwrap()[0];
19974 let children = item.content.as_ref().unwrap();
19975 assert_eq!(children.len(), 2, "expected 2 children in listItem");
19976 assert_eq!(children[0].node_type, "mediaSingle");
19977 let media = &children[0].content.as_ref().unwrap()[0];
19978 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-first");
19979 assert_eq!(children[1].node_type, "paragraph");
19980 }
19981
19982 #[test]
19983 fn issue_430_media_then_paragraph_in_ordered_list_roundtrip() {
19984 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19986 {"type":"listItem","content":[
19987 {"type":"mediaSingle","attrs":{"layout":"center"},
19988 "content":[{"type":"media","attrs":{"type":"file","id":"img-ord","collection":"col-2","height":60,"width":120}}]},
19989 {"type":"paragraph","content":[{"type":"text","text":"Description"}]}
19990 ]}
19991 ]}]}"#;
19992 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19993 let md = adf_to_markdown(&doc).unwrap();
19994 let rt = markdown_to_adf(&md).unwrap();
19995
19996 let item = &rt.content[0].content.as_ref().unwrap()[0];
19997 let children = item.content.as_ref().unwrap();
19998 assert_eq!(children.len(), 2, "expected 2 children in listItem");
19999 assert_eq!(children[0].node_type, "mediaSingle");
20000 assert_eq!(children[1].node_type, "paragraph");
20001 }
20002
20003 #[test]
20004 fn issue_430_external_media_with_width_type_roundtrip() {
20005 let adf_json = r#"{"version":1,"type":"doc","content":[{
20007 "type":"mediaSingle",
20008 "attrs":{"layout":"wide","width":800,"widthType":"pixel"},
20009 "content":[{
20010 "type":"media",
20011 "attrs":{"type":"external","url":"https://example.com/photo.png","alt":"wide photo"}
20012 }]
20013 }]}"#;
20014 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20015 let md = adf_to_markdown(&doc).unwrap();
20016 assert!(
20017 md.contains("widthType=pixel"),
20018 "expected widthType=pixel in markdown, got: {md}"
20019 );
20020 let rt = markdown_to_adf(&md).unwrap();
20021 let ms = &rt.content[0];
20022 assert_eq!(ms.node_type, "mediaSingle");
20023 let ms_attrs = ms.attrs.as_ref().unwrap();
20024 assert_eq!(ms_attrs["widthType"], "pixel");
20025 assert_eq!(ms_attrs["width"], 800);
20026 assert_eq!(ms_attrs["layout"], "wide");
20027 }
20028
20029 #[test]
20032 fn issue_490_paragraph_with_hardbreak_then_media_single_roundtrip() {
20033 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20036 {"type":"listItem","content":[
20037 {"type":"paragraph","content":[
20038 {"type":"text","text":"Item with image:"},
20039 {"type":"hardBreak"}
20040 ]},
20041 {"type":"mediaSingle","attrs":{"layout":"center","width":400,"widthType":"pixel"},
20042 "content":[{"type":"media","attrs":{
20043 "id":"aabbccdd-1234-5678-abcd-aabbccdd1234",
20044 "type":"file",
20045 "collection":"contentId-123456",
20046 "width":800,
20047 "height":600
20048 }}]}
20049 ]}
20050 ]}]}"#;
20051 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20052 let md = adf_to_markdown(&doc).unwrap();
20053 let rt = markdown_to_adf(&md).unwrap();
20054
20055 let item = &rt.content[0].content.as_ref().unwrap()[0];
20056 let children = item.content.as_ref().unwrap();
20057 assert_eq!(children.len(), 2, "expected 2 children in listItem");
20058 assert_eq!(children[0].node_type, "paragraph");
20059 assert_eq!(
20060 children[1].node_type, "mediaSingle",
20061 "expected mediaSingle, got {:?}",
20062 children[1].node_type
20063 );
20064 let media = &children[1].content.as_ref().unwrap()[0];
20065 let m_attrs = media.attrs.as_ref().unwrap();
20066 assert_eq!(m_attrs["id"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
20067 assert_eq!(m_attrs["collection"], "contentId-123456");
20068 assert_eq!(m_attrs["height"], 600);
20069 assert_eq!(m_attrs["width"], 800);
20070 }
20071
20072 #[test]
20073 fn issue_490_paragraph_with_hardbreak_then_media_single_ordered_list() {
20074 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20076 {"type":"listItem","content":[
20077 {"type":"paragraph","content":[
20078 {"type":"text","text":"Step with screenshot:"},
20079 {"type":"hardBreak"}
20080 ]},
20081 {"type":"mediaSingle","attrs":{"layout":"center"},
20082 "content":[{"type":"media","attrs":{
20083 "id":"ord-media-id","type":"file","collection":"col-ord","width":640,"height":480
20084 }}]}
20085 ]}
20086 ]}]}"#;
20087 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20088 let md = adf_to_markdown(&doc).unwrap();
20089 let rt = markdown_to_adf(&md).unwrap();
20090
20091 let item = &rt.content[0].content.as_ref().unwrap()[0];
20092 let children = item.content.as_ref().unwrap();
20093 assert_eq!(children.len(), 2, "expected 2 children in listItem");
20094 assert_eq!(children[0].node_type, "paragraph");
20095 assert_eq!(children[1].node_type, "mediaSingle");
20096 let media = &children[1].content.as_ref().unwrap()[0];
20097 assert_eq!(media.attrs.as_ref().unwrap()["id"], "ord-media-id");
20098 }
20099
20100 #[test]
20101 fn issue_490_hardbreak_continuation_does_not_swallow_media_line() {
20102 let md = "- Item with image:\\\n ![](){type=file id=test-490 collection=col height=100 width=200}\n";
20105 let doc = markdown_to_adf(md).unwrap();
20106
20107 let item = &doc.content[0].content.as_ref().unwrap()[0];
20108 let children = item.content.as_ref().unwrap();
20109 assert_eq!(children.len(), 2, "expected 2 children in listItem");
20110 assert_eq!(children[0].node_type, "paragraph");
20111 assert_eq!(
20112 children[1].node_type, "mediaSingle",
20113 "expected mediaSingle as second child, got {:?}",
20114 children[1].node_type
20115 );
20116 let media = &children[1].content.as_ref().unwrap()[0];
20117 assert_eq!(media.attrs.as_ref().unwrap()["id"], "test-490");
20118 }
20119
20120 #[test]
20121 fn issue_490_hardbreak_continuation_still_works_for_text() {
20122 let md = "- first line\\\n second line\n";
20124 let doc = markdown_to_adf(md).unwrap();
20125
20126 let item = &doc.content[0].content.as_ref().unwrap()[0];
20127 let children = item.content.as_ref().unwrap();
20128 assert_eq!(
20129 children.len(),
20130 1,
20131 "expected 1 child (paragraph) in listItem"
20132 );
20133 assert_eq!(children[0].node_type, "paragraph");
20134 let inlines = children[0].content.as_ref().unwrap();
20135 assert_eq!(inlines.len(), 3);
20137 assert_eq!(inlines[0].node_type, "text");
20138 assert_eq!(inlines[1].node_type, "hardBreak");
20139 assert_eq!(inlines[2].node_type, "text");
20140 }
20141
20142 #[test]
20143 fn issue_490_external_media_after_hardbreak_roundtrip() {
20144 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20146 {"type":"listItem","content":[
20147 {"type":"paragraph","content":[
20148 {"type":"text","text":"See image:"},
20149 {"type":"hardBreak"}
20150 ]},
20151 {"type":"mediaSingle","attrs":{"layout":"center"},
20152 "content":[{"type":"media","attrs":{
20153 "type":"external","url":"https://example.com/photo.png","alt":"photo"
20154 }}]}
20155 ]}
20156 ]}]}"#;
20157 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20158 let md = adf_to_markdown(&doc).unwrap();
20159 let rt = markdown_to_adf(&md).unwrap();
20160
20161 let item = &rt.content[0].content.as_ref().unwrap()[0];
20162 let children = item.content.as_ref().unwrap();
20163 assert_eq!(children.len(), 2);
20164 assert_eq!(children[0].node_type, "paragraph");
20165 assert_eq!(children[1].node_type, "mediaSingle");
20166 let media = &children[1].content.as_ref().unwrap()[0];
20167 let m_attrs = media.attrs.as_ref().unwrap();
20168 assert_eq!(m_attrs["url"], "https://example.com/photo.png");
20169 }
20170
20171 #[test]
20172 fn issue_490_multiple_hardbreaks_then_media_single() {
20173 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20175 {"type":"listItem","content":[
20176 {"type":"paragraph","content":[
20177 {"type":"text","text":"line one"},
20178 {"type":"hardBreak"},
20179 {"type":"text","text":"line two"},
20180 {"type":"hardBreak"}
20181 ]},
20182 {"type":"mediaSingle","attrs":{"layout":"center"},
20183 "content":[{"type":"media","attrs":{
20184 "type":"file","id":"multi-hb","collection":"col-m","width":320,"height":240
20185 }}]}
20186 ]}
20187 ]}]}"#;
20188 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20189 let md = adf_to_markdown(&doc).unwrap();
20190 let rt = markdown_to_adf(&md).unwrap();
20191
20192 let item = &rt.content[0].content.as_ref().unwrap()[0];
20193 let children = item.content.as_ref().unwrap();
20194 assert_eq!(children.len(), 2, "expected paragraph + mediaSingle");
20195 assert_eq!(children[0].node_type, "paragraph");
20196 assert_eq!(children[1].node_type, "mediaSingle");
20197 let media = &children[1].content.as_ref().unwrap()[0];
20198 assert_eq!(media.attrs.as_ref().unwrap()["id"], "multi-hb");
20199 }
20200
20201 #[test]
20204 fn issue_525_listitem_localid_with_mediasingle_roundtrip() {
20205 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"}]}]}]}]}]}]}"#;
20208 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20209 let md = adf_to_markdown(&doc).unwrap();
20210 let rt = markdown_to_adf(&md).unwrap();
20211
20212 let list = &rt.content[0];
20213 assert_eq!(list.node_type, "bulletList");
20214 let item = &list.content.as_ref().unwrap()[0];
20215 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20217 assert_eq!(
20218 item_attrs["localId"], "aabbccdd-1234-5678-abcd-000000000001",
20219 "listItem localId must survive round-trip"
20220 );
20221 let children = item.content.as_ref().unwrap();
20222 assert_eq!(
20223 children.len(),
20224 3,
20225 "expected mediaSingle + paragraph + bulletList"
20226 );
20227 assert_eq!(children[0].node_type, "mediaSingle");
20228 assert_eq!(children[1].node_type, "paragraph");
20229 assert_eq!(children[2].node_type, "bulletList");
20230 }
20231
20232 #[test]
20233 fn issue_525_listitem_localid_with_mediasingle_only() {
20234 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20236 {"type":"listItem","attrs":{"localId":"li-media-only"},"content":[
20237 {"type":"mediaSingle","attrs":{"layout":"center"},
20238 "content":[{"type":"media","attrs":{"type":"file","id":"m-001","collection":"c1","height":50,"width":100}}]}
20239 ]}
20240 ]}]}"#;
20241 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20242 let md = adf_to_markdown(&doc).unwrap();
20243 let rt = markdown_to_adf(&md).unwrap();
20244
20245 let item = &rt.content[0].content.as_ref().unwrap()[0];
20246 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20247 assert_eq!(
20248 item_attrs["localId"], "li-media-only",
20249 "listItem localId must survive when sole child is mediaSingle"
20250 );
20251 assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20252 }
20253
20254 #[test]
20255 fn issue_525_listitem_localid_with_external_media() {
20256 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20258 {"type":"listItem","attrs":{"localId":"li-ext-media"},"content":[
20259 {"type":"mediaSingle","attrs":{"layout":"center"},
20260 "content":[{"type":"media","attrs":{"type":"external","url":"https://example.com/img.png","alt":"photo"}}]}
20261 ]}
20262 ]}]}"#;
20263 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20264 let md = adf_to_markdown(&doc).unwrap();
20265 let rt = markdown_to_adf(&md).unwrap();
20266
20267 let item = &rt.content[0].content.as_ref().unwrap()[0];
20268 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20269 assert_eq!(
20270 item_attrs["localId"], "li-ext-media",
20271 "listItem localId must survive with external mediaSingle"
20272 );
20273 }
20274
20275 #[test]
20276 fn issue_525_listitem_localid_with_mediasingle_in_ordered_list() {
20277 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20279 {"type":"listItem","attrs":{"localId":"li-ord-media"},"content":[
20280 {"type":"mediaSingle","attrs":{"layout":"center","width":200,"widthType":"pixel"},
20281 "content":[{"type":"media","attrs":{"type":"file","id":"ord-m-001","collection":"col-ord","height":80,"width":160}}]},
20282 {"type":"paragraph","content":[{"type":"text","text":"ordered item text"}]}
20283 ]}
20284 ]}]}"#;
20285 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20286 let md = adf_to_markdown(&doc).unwrap();
20287 let rt = markdown_to_adf(&md).unwrap();
20288
20289 let item = &rt.content[0].content.as_ref().unwrap()[0];
20290 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20291 assert_eq!(
20292 item_attrs["localId"], "li-ord-media",
20293 "listItem localId must survive in ordered list with mediaSingle"
20294 );
20295 let children = item.content.as_ref().unwrap();
20296 assert_eq!(children[0].node_type, "mediaSingle");
20297 assert_eq!(children[1].node_type, "paragraph");
20298 }
20299
20300 #[test]
20301 fn issue_525_jfm_localid_on_mediasingle_line_parses_correctly() {
20302 let md = "- ![](){type=file id=test-525 collection=col height=100 width=200 mediaWidth=100 widthType=pixel} {localId=li-jfm-525}\n";
20305 let doc = markdown_to_adf(md).unwrap();
20306
20307 let item = &doc.content[0].content.as_ref().unwrap()[0];
20308 let item_attrs = item
20309 .attrs
20310 .as_ref()
20311 .expect("listItem attrs must be present from JFM");
20312 assert_eq!(item_attrs["localId"], "li-jfm-525");
20313 assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20314 }
20315
20316 #[test]
20317 fn issue_525_encoding_emits_localid_on_mediasingle_line() {
20318 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20321 {"type":"listItem","attrs":{"localId":"li-emit-check"},"content":[
20322 {"type":"mediaSingle","attrs":{"layout":"center"},
20323 "content":[{"type":"media","attrs":{"type":"file","id":"m-emit","collection":"c-emit","height":10,"width":20}}]}
20324 ]}
20325 ]}]}"#;
20326 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20327 let md = adf_to_markdown(&doc).unwrap();
20328 assert!(
20329 md.contains("{localId=li-emit-check}"),
20330 "expected localId in JFM output, got: {md}"
20331 );
20332 for line in md.lines() {
20334 if line.contains("![") {
20335 assert!(
20336 line.contains("localId=li-emit-check"),
20337 "localId must be on the same line as the image: {line}"
20338 );
20339 }
20340 }
20341 }
20342
20343 #[test]
20346 fn adf_placeholder_to_markdown() {
20347 let doc = AdfDocument {
20348 version: 1,
20349 doc_type: "doc".to_string(),
20350 content: vec![AdfNode::paragraph(vec![AdfNode::placeholder(
20351 "Type something here",
20352 )])],
20353 };
20354 let md = adf_to_markdown(&doc).unwrap();
20355 assert!(
20356 md.contains(":placeholder[Type something here]"),
20357 "expected :placeholder directive, got: {md}"
20358 );
20359 }
20360
20361 #[test]
20362 fn markdown_placeholder_to_adf() {
20363 let doc = markdown_to_adf("Before :placeholder[Enter name] after").unwrap();
20364 let content = doc.content[0].content.as_ref().unwrap();
20365 assert_eq!(content[1].node_type, "placeholder");
20366 let attrs = content[1].attrs.as_ref().unwrap();
20367 assert_eq!(attrs["text"], "Enter name");
20368 }
20369
20370 #[test]
20371 fn placeholder_round_trip() {
20372 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"placeholder","attrs":{"text":"Type something here"}}]}]}"#;
20373 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20374 let md = adf_to_markdown(&doc).unwrap();
20375 let rt = markdown_to_adf(&md).unwrap();
20376 let content = rt.content[0].content.as_ref().unwrap();
20377 assert_eq!(content.len(), 1);
20378 assert_eq!(content[0].node_type, "placeholder");
20379 let attrs = content[0].attrs.as_ref().unwrap();
20380 assert_eq!(attrs["text"], "Type something here");
20381 }
20382
20383 #[test]
20384 fn placeholder_empty_text() {
20385 let doc = AdfDocument {
20386 version: 1,
20387 doc_type: "doc".to_string(),
20388 content: vec![AdfNode::paragraph(vec![AdfNode::placeholder("")])],
20389 };
20390 let md = adf_to_markdown(&doc).unwrap();
20391 assert!(
20392 md.contains(":placeholder[]"),
20393 "expected empty placeholder directive, got: {md}"
20394 );
20395 let rt = markdown_to_adf(&md).unwrap();
20396 let content = rt.content[0].content.as_ref().unwrap();
20397 assert_eq!(content[0].node_type, "placeholder");
20398 assert_eq!(content[0].attrs.as_ref().unwrap()["text"], "");
20399 }
20400
20401 #[test]
20402 fn placeholder_with_surrounding_text() {
20403 let md = "Click :placeholder[here] to continue\n";
20404 let doc = markdown_to_adf(md).unwrap();
20405 let content = doc.content[0].content.as_ref().unwrap();
20406 assert_eq!(content[0].text.as_deref(), Some("Click "));
20407 assert_eq!(content[1].node_type, "placeholder");
20408 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "here");
20409 assert_eq!(content[2].text.as_deref(), Some(" to continue"));
20410 }
20411
20412 #[test]
20413 fn placeholder_missing_attrs() {
20414 let doc = AdfDocument {
20416 version: 1,
20417 doc_type: "doc".to_string(),
20418 content: vec![AdfNode::paragraph(vec![AdfNode {
20419 node_type: "placeholder".to_string(),
20420 attrs: None,
20421 content: None,
20422 text: None,
20423 marks: None,
20424 local_id: None,
20425 parameters: None,
20426 }])],
20427 };
20428 let md = adf_to_markdown(&doc).unwrap();
20429 assert!(!md.contains("placeholder"));
20431 }
20432
20433 #[test]
20435 fn mention_in_table_bullet_list_preserves_id_and_local_id() {
20436 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":" "}]}]}]}]}]}]}]}"#;
20437 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20438 let md = adf_to_markdown(&doc).unwrap();
20439 let rt = markdown_to_adf(&md).unwrap();
20440
20441 let cell = &rt.content[0].content.as_ref().unwrap()[0]
20443 .content
20444 .as_ref()
20445 .unwrap()[0];
20446 let list = &cell.content.as_ref().unwrap()[0];
20447 let list_item = &list.content.as_ref().unwrap()[0];
20448
20449 assert!(
20451 list_item
20452 .attrs
20453 .as_ref()
20454 .and_then(|a| a.get("localId"))
20455 .is_none(),
20456 "localId should stay on the mention, not the listItem"
20457 );
20458
20459 let para = &list_item.content.as_ref().unwrap()[0];
20460 let inlines = para.content.as_ref().unwrap();
20461
20462 assert_eq!(inlines.len(), 3, "expected 3 inline nodes, got {inlines:?}");
20464
20465 assert_eq!(inlines[0].node_type, "text");
20466 assert_eq!(inlines[0].text.as_deref(), Some("prefix text "));
20467
20468 assert_eq!(inlines[1].node_type, "mention");
20469 let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20470 assert_eq!(
20471 mention_attrs["id"], "aabbccdd11223344aabbccdd",
20472 "mention id must be preserved"
20473 );
20474 assert_eq!(
20475 mention_attrs["localId"], "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
20476 "mention localId must be preserved"
20477 );
20478 assert_eq!(mention_attrs["text"], "@Alice Example");
20479
20480 assert_eq!(inlines[2].node_type, "text");
20481 assert_eq!(inlines[2].text.as_deref(), Some(" "));
20482 }
20483
20484 #[test]
20485 fn mention_in_bullet_list_preserves_id_and_local_id() {
20486 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":" "}]}]}]}]}"#;
20488 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20489 let md = adf_to_markdown(&doc).unwrap();
20490 let rt = markdown_to_adf(&md).unwrap();
20491
20492 let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20493 assert!(
20494 list_item
20495 .attrs
20496 .as_ref()
20497 .and_then(|a| a.get("localId"))
20498 .is_none(),
20499 "localId should stay on the mention, not the listItem"
20500 );
20501
20502 let para = &list_item.content.as_ref().unwrap()[0];
20503 let inlines = para.content.as_ref().unwrap();
20504 assert_eq!(inlines[0].node_type, "mention");
20505 let mention_attrs = inlines[0].attrs.as_ref().unwrap();
20506 assert_eq!(mention_attrs["id"], "user123");
20507 assert_eq!(
20508 mention_attrs["localId"],
20509 "11111111-2222-3333-4444-555555555555"
20510 );
20511 }
20512
20513 #[test]
20514 fn mention_in_ordered_list_preserves_id_and_local_id() {
20515 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"}}]}]}]}]}"#;
20516 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20517 let md = adf_to_markdown(&doc).unwrap();
20518 let rt = markdown_to_adf(&md).unwrap();
20519
20520 let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20521 assert!(
20522 list_item
20523 .attrs
20524 .as_ref()
20525 .and_then(|a| a.get("localId"))
20526 .is_none(),
20527 "localId should stay on the mention, not the listItem"
20528 );
20529
20530 let para = &list_item.content.as_ref().unwrap()[0];
20531 let inlines = para.content.as_ref().unwrap();
20532 assert_eq!(inlines[1].node_type, "mention");
20533 let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20534 assert_eq!(mention_attrs["id"], "xyz");
20535 assert_eq!(mention_attrs["localId"], "aaaa-bbbb");
20536 }
20537
20538 #[test]
20539 fn list_item_own_local_id_with_mention_both_preserved() {
20540 let md = "- hello :mention[@Eve]{id=e1 localId=mention-lid} {localId=item-lid}\n";
20543 let doc = markdown_to_adf(md).unwrap();
20544 let list_item = &doc.content[0].content.as_ref().unwrap()[0];
20545
20546 let item_attrs = list_item.attrs.as_ref().unwrap();
20548 assert_eq!(item_attrs["localId"], "item-lid");
20549
20550 let para = &list_item.content.as_ref().unwrap()[0];
20552 let inlines = para.content.as_ref().unwrap();
20553 let mention = inlines.iter().find(|n| n.node_type == "mention").unwrap();
20554 let mention_attrs = mention.attrs.as_ref().unwrap();
20555 assert_eq!(mention_attrs["id"], "e1");
20556 assert_eq!(mention_attrs["localId"], "mention-lid");
20557 }
20558
20559 #[test]
20560 fn extract_trailing_local_id_ignores_directive_attrs() {
20561 let line = "text :mention[@X]{id=abc localId=uuid}";
20564 let (text, lid, plid) = extract_trailing_local_id(line);
20565 assert_eq!(text, line, "text should be unchanged");
20566 assert!(
20567 lid.is_none(),
20568 "should not extract localId from directive attrs"
20569 );
20570 assert!(plid.is_none());
20571 }
20572
20573 #[test]
20574 fn extract_trailing_local_id_matches_standalone_block() {
20575 let line = "some text {localId=abc-123}";
20577 let (text, lid, plid) = extract_trailing_local_id(line);
20578 assert_eq!(text, "some text");
20579 assert_eq!(lid.as_deref(), Some("abc-123"));
20580 assert!(plid.is_none());
20581 }
20582
20583 #[test]
20586 fn newline_in_text_node_roundtrips_in_bullet_list() {
20587 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"}]}]}]}]}"#;
20591 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20592 let md = adf_to_markdown(&doc).unwrap();
20593 let rt = markdown_to_adf(&md).unwrap();
20594
20595 assert_eq!(rt.content.len(), 1);
20597 let list = &rt.content[0];
20598 assert_eq!(list.node_type, "bulletList");
20599 let items = list.content.as_ref().unwrap();
20600 assert_eq!(items.len(), 1);
20601
20602 let item_content = items[0].content.as_ref().unwrap();
20604 assert_eq!(
20605 item_content.len(),
20606 1,
20607 "listItem should have exactly one paragraph"
20608 );
20609 assert_eq!(item_content[0].node_type, "paragraph");
20610
20611 let inlines = item_content[0].content.as_ref().unwrap();
20613 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20614 assert_eq!(
20615 types,
20616 vec!["text", "hardBreak", "text"],
20617 "embedded newline should stay in a single text node, not produce extra hardBreaks"
20618 );
20619 assert_eq!(
20620 inlines[2].text.as_deref(),
20621 Some("first command\nsecond command")
20622 );
20623 }
20624
20625 #[test]
20626 fn newline_in_text_node_roundtrips_in_ordered_list() {
20627 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"}]}]}]}]}"#;
20629 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20630 let md = adf_to_markdown(&doc).unwrap();
20631 let rt = markdown_to_adf(&md).unwrap();
20632
20633 let list = &rt.content[0];
20634 assert_eq!(list.node_type, "orderedList");
20635 let items = list.content.as_ref().unwrap();
20636 assert_eq!(items.len(), 1);
20637
20638 let item_content = items[0].content.as_ref().unwrap();
20639 assert_eq!(item_content.len(), 1);
20640 assert_eq!(item_content[0].node_type, "paragraph");
20641
20642 let inlines = item_content[0].content.as_ref().unwrap();
20643 assert_eq!(inlines.len(), 1);
20644 assert_eq!(inlines[0].node_type, "text");
20645 assert_eq!(inlines[0].text.as_deref(), Some("first\nsecond"));
20646 }
20647
20648 #[test]
20649 fn newline_in_text_node_roundtrips_in_paragraph() {
20650 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello\nworld"}]}]}"#;
20653 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20654 let md = adf_to_markdown(&doc).unwrap();
20655 assert!(
20656 md.contains("hello\\nworld"),
20657 "newline in text node should render as escaped \\n: {md:?}"
20658 );
20659
20660 let rt = markdown_to_adf(&md).unwrap();
20661 let inlines = rt.content[0].content.as_ref().unwrap();
20662 assert_eq!(inlines.len(), 1);
20663 assert_eq!(inlines[0].text.as_deref(), Some("hello\nworld"));
20664 }
20665
20666 #[test]
20667 fn multiple_newlines_in_text_node_roundtrip() {
20668 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a\nb\nc"}]}]}]}]}"#;
20670 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20671 let md = adf_to_markdown(&doc).unwrap();
20672 let rt = markdown_to_adf(&md).unwrap();
20673
20674 let item_content = rt.content[0].content.as_ref().unwrap()[0]
20675 .content
20676 .as_ref()
20677 .unwrap();
20678 assert_eq!(item_content.len(), 1);
20679
20680 let inlines = item_content[0].content.as_ref().unwrap();
20681 assert_eq!(inlines.len(), 1);
20682 assert_eq!(inlines[0].text.as_deref(), Some("a\nb\nc"));
20683 }
20684
20685 #[test]
20686 fn newline_in_marked_text_node_roundtrips() {
20687 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold\ntext","marks":[{"type":"strong"}]}]}]}"#;
20690 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20691 let md = adf_to_markdown(&doc).unwrap();
20692 assert!(
20693 md.contains("**bold\\ntext**"),
20694 "bold text with embedded newline should stay in one marked run: {md:?}"
20695 );
20696
20697 let rt = markdown_to_adf(&md).unwrap();
20698 let inlines = rt.content[0].content.as_ref().unwrap();
20699 assert_eq!(inlines.len(), 1);
20700 assert_eq!(inlines[0].text.as_deref(), Some("bold\ntext"));
20701 assert!(inlines[0]
20702 .marks
20703 .as_ref()
20704 .unwrap()
20705 .iter()
20706 .any(|m| m.mark_type == "strong"));
20707 }
20708
20709 #[test]
20710 fn trailing_newline_in_text_node_roundtrips() {
20711 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"trailing\n"}]}]}"#;
20714 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20715 let md = adf_to_markdown(&doc).unwrap();
20716 assert!(
20717 md.contains("trailing\\n"),
20718 "trailing newline should be escaped: {md:?}"
20719 );
20720
20721 let rt = markdown_to_adf(&md).unwrap();
20722 let inlines = rt.content[0].content.as_ref().unwrap();
20723 assert_eq!(inlines.len(), 1);
20724 assert_eq!(inlines[0].text.as_deref(), Some("trailing\n"));
20725 }
20726
20727 #[test]
20728 fn hardbreak_and_embedded_newline_are_distinct() {
20729 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"}]}]}"#;
20732 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20733 let md = adf_to_markdown(&doc).unwrap();
20734 let rt = markdown_to_adf(&md).unwrap();
20735
20736 let inlines = rt.content[0].content.as_ref().unwrap();
20737 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20738 assert_eq!(
20739 types,
20740 vec!["text", "hardBreak", "text", "hardBreak", "text"]
20741 );
20742 assert_eq!(inlines[0].text.as_deref(), Some("before"));
20743 assert_eq!(inlines[2].text.as_deref(), Some("mid\ndle"));
20744 assert_eq!(inlines[4].text.as_deref(), Some("after"));
20745 }
20746
20747 #[test]
20750 fn issue_472_bullet_list_trailing_hardbreak_roundtrips() {
20751 let adf_json = r#"{"version":1,"type":"doc","content":[
20754 {"type":"bulletList","content":[
20755 {"type":"listItem","content":[
20756 {"type":"paragraph","content":[
20757 {"type":"text","text":"First item"},
20758 {"type":"hardBreak"}
20759 ]}
20760 ]},
20761 {"type":"listItem","content":[
20762 {"type":"paragraph","content":[
20763 {"type":"text","text":"Second item"}
20764 ]}
20765 ]}
20766 ]}
20767 ]}"#;
20768 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20769 let md = adf_to_markdown(&doc).unwrap();
20770 let rt = markdown_to_adf(&md).unwrap();
20771
20772 assert_eq!(
20774 rt.content.len(),
20775 1,
20776 "Should be 1 block (bulletList), got {}",
20777 rt.content.len()
20778 );
20779 assert_eq!(rt.content[0].node_type, "bulletList");
20780 let items = rt.content[0].content.as_ref().unwrap();
20781 assert_eq!(
20782 items.len(),
20783 2,
20784 "Should have 2 listItems, got {}",
20785 items.len()
20786 );
20787
20788 let p1 = items[0].content.as_ref().unwrap()[0]
20790 .content
20791 .as_ref()
20792 .unwrap();
20793 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20794 assert_eq!(types1, vec!["text", "hardBreak"]);
20795 assert_eq!(p1[0].text.as_deref(), Some("First item"));
20796
20797 let p2 = items[1].content.as_ref().unwrap()[0]
20799 .content
20800 .as_ref()
20801 .unwrap();
20802 assert_eq!(p2[0].text.as_deref(), Some("Second item"));
20803 }
20804
20805 #[test]
20806 fn issue_472_ordered_list_trailing_hardbreak_roundtrips() {
20807 let adf_json = r#"{"version":1,"type":"doc","content":[
20809 {"type":"orderedList","attrs":{"order":1},"content":[
20810 {"type":"listItem","content":[
20811 {"type":"paragraph","content":[
20812 {"type":"text","text":"Alpha"},
20813 {"type":"hardBreak"}
20814 ]}
20815 ]},
20816 {"type":"listItem","content":[
20817 {"type":"paragraph","content":[
20818 {"type":"text","text":"Beta"}
20819 ]}
20820 ]}
20821 ]}
20822 ]}"#;
20823 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20824 let md = adf_to_markdown(&doc).unwrap();
20825 let rt = markdown_to_adf(&md).unwrap();
20826
20827 assert_eq!(rt.content.len(), 1);
20828 assert_eq!(rt.content[0].node_type, "orderedList");
20829 let items = rt.content[0].content.as_ref().unwrap();
20830 assert_eq!(items.len(), 2);
20831
20832 let p1 = items[0].content.as_ref().unwrap()[0]
20833 .content
20834 .as_ref()
20835 .unwrap();
20836 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20837 assert_eq!(types1, vec!["text", "hardBreak"]);
20838 assert_eq!(p1[0].text.as_deref(), Some("Alpha"));
20839 }
20840
20841 #[test]
20842 fn issue_472_trailing_hardbreak_jfm_no_blank_line() {
20843 let adf_json = r#"{"version":1,"type":"doc","content":[
20846 {"type":"bulletList","content":[
20847 {"type":"listItem","content":[
20848 {"type":"paragraph","content":[
20849 {"type":"text","text":"Hello"},
20850 {"type":"hardBreak"}
20851 ]}
20852 ]},
20853 {"type":"listItem","content":[
20854 {"type":"paragraph","content":[
20855 {"type":"text","text":"World"}
20856 ]}
20857 ]}
20858 ]}
20859 ]}"#;
20860 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20861 let md = adf_to_markdown(&doc).unwrap();
20862
20863 assert_eq!(md, "- Hello\\\n- World\n");
20865 }
20866
20867 #[test]
20868 fn issue_472_multiple_trailing_hardbreaks_roundtrip() {
20869 let adf_json = r#"{"version":1,"type":"doc","content":[
20871 {"type":"bulletList","content":[
20872 {"type":"listItem","content":[
20873 {"type":"paragraph","content":[
20874 {"type":"text","text":"Item"},
20875 {"type":"hardBreak"},
20876 {"type":"hardBreak"}
20877 ]}
20878 ]},
20879 {"type":"listItem","content":[
20880 {"type":"paragraph","content":[
20881 {"type":"text","text":"Next"}
20882 ]}
20883 ]}
20884 ]}
20885 ]}"#;
20886 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20887 let md = adf_to_markdown(&doc).unwrap();
20888 let rt = markdown_to_adf(&md).unwrap();
20889
20890 assert_eq!(rt.content.len(), 1);
20892 assert_eq!(rt.content[0].node_type, "bulletList");
20893 let items = rt.content[0].content.as_ref().unwrap();
20894 assert_eq!(items.len(), 2);
20895
20896 let p1 = items[0].content.as_ref().unwrap()[0]
20898 .content
20899 .as_ref()
20900 .unwrap();
20901 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20902 assert_eq!(types1, vec!["text", "hardBreak", "hardBreak"]);
20903 }
20904
20905 #[test]
20906 fn issue_472_hardbreak_mid_and_trailing_roundtrip() {
20907 let adf_json = r#"{"version":1,"type":"doc","content":[
20909 {"type":"bulletList","content":[
20910 {"type":"listItem","content":[
20911 {"type":"paragraph","content":[
20912 {"type":"text","text":"Line one"},
20913 {"type":"hardBreak"},
20914 {"type":"text","text":"Line two"},
20915 {"type":"hardBreak"}
20916 ]}
20917 ]},
20918 {"type":"listItem","content":[
20919 {"type":"paragraph","content":[
20920 {"type":"text","text":"Other item"}
20921 ]}
20922 ]}
20923 ]}
20924 ]}"#;
20925 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20926 let md = adf_to_markdown(&doc).unwrap();
20927 let rt = markdown_to_adf(&md).unwrap();
20928
20929 assert_eq!(rt.content.len(), 1);
20930 assert_eq!(rt.content[0].node_type, "bulletList");
20931 let items = rt.content[0].content.as_ref().unwrap();
20932 assert_eq!(items.len(), 2);
20933
20934 let p1 = items[0].content.as_ref().unwrap()[0]
20935 .content
20936 .as_ref()
20937 .unwrap();
20938 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20939 assert_eq!(types1, vec!["text", "hardBreak", "text", "hardBreak"]);
20940 assert_eq!(p1[0].text.as_deref(), Some("Line one"));
20941 assert_eq!(p1[2].text.as_deref(), Some("Line two"));
20942 }
20943
20944 #[test]
20945 fn issue_472_only_hardbreak_in_listitem_paragraph() {
20946 let adf_json = r#"{"version":1,"type":"doc","content":[
20948 {"type":"bulletList","content":[
20949 {"type":"listItem","content":[
20950 {"type":"paragraph","content":[
20951 {"type":"hardBreak"}
20952 ]}
20953 ]},
20954 {"type":"listItem","content":[
20955 {"type":"paragraph","content":[
20956 {"type":"text","text":"After"}
20957 ]}
20958 ]}
20959 ]}
20960 ]}"#;
20961 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20962 let md = adf_to_markdown(&doc).unwrap();
20963 let rt = markdown_to_adf(&md).unwrap();
20964
20965 assert_eq!(rt.content.len(), 1);
20967 assert_eq!(rt.content[0].node_type, "bulletList");
20968 let items = rt.content[0].content.as_ref().unwrap();
20969 assert_eq!(items.len(), 2);
20970 }
20971
20972 #[test]
20973 fn issue_472_three_items_middle_has_trailing_hardbreak() {
20974 let adf_json = r#"{"version":1,"type":"doc","content":[
20976 {"type":"bulletList","content":[
20977 {"type":"listItem","content":[
20978 {"type":"paragraph","content":[
20979 {"type":"text","text":"First"}
20980 ]}
20981 ]},
20982 {"type":"listItem","content":[
20983 {"type":"paragraph","content":[
20984 {"type":"text","text":"Second"},
20985 {"type":"hardBreak"}
20986 ]}
20987 ]},
20988 {"type":"listItem","content":[
20989 {"type":"paragraph","content":[
20990 {"type":"text","text":"Third"}
20991 ]}
20992 ]}
20993 ]}
20994 ]}"#;
20995 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20996 let md = adf_to_markdown(&doc).unwrap();
20997 let rt = markdown_to_adf(&md).unwrap();
20998
20999 assert_eq!(rt.content.len(), 1);
21000 assert_eq!(rt.content[0].node_type, "bulletList");
21001 let items = rt.content[0].content.as_ref().unwrap();
21002 assert_eq!(items.len(), 3);
21003 assert_eq!(
21004 items[0].content.as_ref().unwrap()[0]
21005 .content
21006 .as_ref()
21007 .unwrap()[0]
21008 .text
21009 .as_deref(),
21010 Some("First")
21011 );
21012 assert_eq!(
21013 items[2].content.as_ref().unwrap()[0]
21014 .content
21015 .as_ref()
21016 .unwrap()[0]
21017 .text
21018 .as_deref(),
21019 Some("Third")
21020 );
21021 }
21022
21023 #[test]
21026 fn issue_494_space_after_hardbreak_roundtrip() {
21027 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21030 {"type":"text","text":"Some text"},
21031 {"type":"hardBreak"},
21032 {"type":"text","text":" "}
21033 ]}]}"#;
21034 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21035 let md = adf_to_markdown(&doc).unwrap();
21036 let rt = markdown_to_adf(&md).unwrap();
21037 let inlines = rt.content[0].content.as_ref().unwrap();
21038 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21039 assert_eq!(
21040 types,
21041 vec!["text", "hardBreak", "text"],
21042 "space-only text node after hardBreak should survive round-trip"
21043 );
21044 assert_eq!(inlines[2].text.as_deref(), Some(" "));
21045 }
21046
21047 #[test]
21048 fn issue_494_multiple_spaces_after_hardbreak_roundtrip() {
21049 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21051 {"type":"text","text":"Hello"},
21052 {"type":"hardBreak"},
21053 {"type":"text","text":" "}
21054 ]}]}"#;
21055 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21056 let md = adf_to_markdown(&doc).unwrap();
21057 let rt = markdown_to_adf(&md).unwrap();
21058 let inlines = rt.content[0].content.as_ref().unwrap();
21059 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21060 assert_eq!(
21061 types,
21062 vec!["text", "hardBreak", "text"],
21063 "multi-space text node after hardBreak should survive round-trip"
21064 );
21065 assert_eq!(inlines[2].text.as_deref(), Some(" "));
21066 }
21067
21068 #[test]
21069 fn issue_494_space_then_text_after_hardbreak_roundtrip() {
21070 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21073 {"type":"text","text":"Before"},
21074 {"type":"hardBreak"},
21075 {"type":"text","text":" After"}
21076 ]}]}"#;
21077 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21078 let md = adf_to_markdown(&doc).unwrap();
21079 let rt = markdown_to_adf(&md).unwrap();
21080 let inlines = rt.content[0].content.as_ref().unwrap();
21081 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21082 assert_eq!(types, vec!["text", "hardBreak", "text"]);
21083 assert_eq!(inlines[2].text.as_deref(), Some(" After"));
21084 }
21085
21086 #[test]
21087 fn issue_494_hardbreak_then_space_then_hardbreak_roundtrip() {
21088 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21090 {"type":"text","text":"A"},
21091 {"type":"hardBreak"},
21092 {"type":"text","text":" "},
21093 {"type":"hardBreak"},
21094 {"type":"text","text":"B"}
21095 ]}]}"#;
21096 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21097 let md = adf_to_markdown(&doc).unwrap();
21098 let rt = markdown_to_adf(&md).unwrap();
21099 let inlines = rt.content[0].content.as_ref().unwrap();
21100 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21101 assert_eq!(
21102 types,
21103 vec!["text", "hardBreak", "text", "hardBreak", "text"],
21104 "space between two hardBreaks should survive round-trip"
21105 );
21106 assert_eq!(inlines[2].text.as_deref(), Some(" "));
21107 assert_eq!(inlines[4].text.as_deref(), Some("B"));
21108 }
21109
21110 #[test]
21111 fn issue_494_trailing_space_hardbreak_style_not_confused() {
21112 let md = "first paragraph\n\nsecond paragraph\n";
21115 let doc = markdown_to_adf(md).unwrap();
21116 assert_eq!(
21117 doc.content.len(),
21118 2,
21119 "blank line should still separate paragraphs"
21120 );
21121 }
21122
21123 #[test]
21124 fn issue_494_space_after_trailing_space_hardbreak_roundtrip() {
21125 let md = "line one \n \n";
21128 let doc = markdown_to_adf(md).unwrap();
21132 let inlines = doc.content[0].content.as_ref().unwrap();
21133 let has_text_after_break = inlines.iter().any(|n| {
21134 n.node_type == "text"
21135 && n.text
21136 .as_deref()
21137 .is_some_and(|t| t.trim().is_empty() && !t.is_empty())
21138 });
21139 assert!(
21140 has_text_after_break || inlines.len() >= 2,
21141 "space-only line after trailing-space hardBreak should be preserved"
21142 );
21143 }
21144
21145 #[test]
21146 fn issue_494_space_after_hardbreak_in_list_item_roundtrip() {
21147 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21149 {"type":"listItem","content":[{"type":"paragraph","content":[
21150 {"type":"text","text":"item"},
21151 {"type":"hardBreak"},
21152 {"type":"text","text":" "}
21153 ]}]}
21154 ]}]}"#;
21155 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21156 let md = adf_to_markdown(&doc).unwrap();
21157 let rt = markdown_to_adf(&md).unwrap();
21158 let list = &rt.content[0];
21159 let item = &list.content.as_ref().unwrap()[0];
21160 let para = &item.content.as_ref().unwrap()[0];
21161 let inlines = para.content.as_ref().unwrap();
21162 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21163 assert_eq!(
21164 types,
21165 vec!["text", "hardBreak", "text"],
21166 "space after hardBreak in list item should survive round-trip"
21167 );
21168 assert_eq!(inlines[2].text.as_deref(), Some(" "));
21169 }
21170
21171 #[test]
21174 fn issue_510_trailing_double_space_paragraph_roundtrip() {
21175 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"}]}]}"#;
21178 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21179 let md = adf_to_markdown(&doc).unwrap();
21180 let rt = markdown_to_adf(&md).unwrap();
21181
21182 assert_eq!(
21184 rt.content.len(),
21185 2,
21186 "should produce two paragraphs, got: {}",
21187 rt.content.len()
21188 );
21189 assert_eq!(rt.content[0].node_type, "paragraph");
21190 assert_eq!(rt.content[1].node_type, "paragraph");
21191
21192 let p1 = rt.content[0].content.as_ref().unwrap();
21194 assert_eq!(
21195 p1[0].text.as_deref(),
21196 Some("first paragraph with trailing spaces "),
21197 "trailing spaces should be preserved in first paragraph"
21198 );
21199
21200 let p2 = rt.content[1].content.as_ref().unwrap();
21202 assert_eq!(p2[0].text.as_deref(), Some("second paragraph"));
21203
21204 let all_types: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21206 assert!(
21207 !all_types.contains(&"hardBreak"),
21208 "trailing spaces should not produce hardBreak, got: {all_types:?}"
21209 );
21210 }
21211
21212 #[test]
21213 fn issue_510_trailing_triple_space_roundtrip() {
21214 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"text "}]},{"type":"paragraph","content":[{"type":"text","text":"next"}]}]}"#;
21216 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21217 let md = adf_to_markdown(&doc).unwrap();
21218 let rt = markdown_to_adf(&md).unwrap();
21219
21220 assert_eq!(rt.content.len(), 2, "should still be two paragraphs");
21221 let p1 = rt.content[0].content.as_ref().unwrap();
21222 assert_eq!(
21223 p1[0].text.as_deref(),
21224 Some("text "),
21225 "three trailing spaces should be preserved"
21226 );
21227 }
21228
21229 #[test]
21230 fn issue_510_trailing_spaces_with_backslash_roundtrip() {
21231 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\ "}]}]}"#;
21233 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21234 let md = adf_to_markdown(&doc).unwrap();
21235 let rt = markdown_to_adf(&md).unwrap();
21236 let p = rt.content[0].content.as_ref().unwrap();
21237 assert_eq!(
21238 p[0].text.as_deref(),
21239 Some("end\\ "),
21240 "backslash + trailing spaces should both survive"
21241 );
21242 }
21243
21244 #[test]
21245 fn issue_510_jfm_contains_escaped_trailing_space() {
21246 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello "}]}]}"#;
21248 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21249 let md = adf_to_markdown(&doc).unwrap();
21250 assert!(
21251 md.contains(r"\ "),
21252 "JFM should contain backslash-space escape for trailing spaces, got: {md:?}"
21253 );
21254 for line in md.lines() {
21256 assert!(
21257 !line.ends_with(" "),
21258 "no JFM line should end with two plain spaces, got: {line:?}"
21259 );
21260 }
21261 }
21262
21263 #[test]
21264 fn issue_510_single_trailing_space_not_escaped() {
21265 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"word "}]}]}"#;
21267 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21268 let md = adf_to_markdown(&doc).unwrap();
21269 assert!(
21270 !md.contains('\\'),
21271 "single trailing space should not be escaped, got: {md:?}"
21272 );
21273 let rt = markdown_to_adf(&md).unwrap();
21274 let p = rt.content[0].content.as_ref().unwrap();
21275 assert_eq!(p[0].text.as_deref(), Some("word "));
21276 }
21277
21278 #[test]
21279 fn issue_510_trailing_spaces_in_heading_roundtrip() {
21280 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"heading "}]}]}"#;
21282 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21283 let md = adf_to_markdown(&doc).unwrap();
21284 let rt = markdown_to_adf(&md).unwrap();
21285 let h = rt.content[0].content.as_ref().unwrap();
21286 assert_eq!(
21287 h[0].text.as_deref(),
21288 Some("heading "),
21289 "trailing spaces in heading should be preserved"
21290 );
21291 }
21292
21293 #[test]
21294 fn issue_510_trailing_spaces_in_list_item_roundtrip() {
21295 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item "}]}]}]}]}"#;
21297 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21298 let md = adf_to_markdown(&doc).unwrap();
21299 let rt = markdown_to_adf(&md).unwrap();
21300 let list = &rt.content[0];
21301 let item = &list.content.as_ref().unwrap()[0];
21302 let para = &item.content.as_ref().unwrap()[0];
21303 let inlines = para.content.as_ref().unwrap();
21304 assert_eq!(
21305 inlines[0].text.as_deref(),
21306 Some("item "),
21307 "trailing spaces in list item should be preserved"
21308 );
21309 }
21310
21311 #[test]
21312 fn issue_510_trailing_spaces_with_bold_mark_roundtrip() {
21313 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold ","marks":[{"type":"strong"}]}]}]}"#;
21317 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21318 let md = adf_to_markdown(&doc).unwrap();
21319 let rt = markdown_to_adf(&md).unwrap();
21320 let p = rt.content[0].content.as_ref().unwrap();
21321 assert_eq!(
21322 p[0].text.as_deref(),
21323 Some("bold "),
21324 "trailing spaces in bold text should be preserved"
21325 );
21326 }
21327
21328 #[test]
21329 fn issue_510_hardbreak_between_paragraphs_still_works() {
21330 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"line one"},{"type":"hardBreak"},{"type":"text","text":"line two"}]}]}"#;
21332 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21333 let md = adf_to_markdown(&doc).unwrap();
21334 let rt = markdown_to_adf(&md).unwrap();
21335 let inlines = rt.content[0].content.as_ref().unwrap();
21336 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21337 assert_eq!(
21338 types,
21339 vec!["text", "hardBreak", "text"],
21340 "explicit hardBreak should still round-trip"
21341 );
21342 }
21343
21344 #[test]
21345 fn issue_510_all_spaces_text_node_roundtrip() {
21346 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":" "}]}]}"#;
21348 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21349 let md = adf_to_markdown(&doc).unwrap();
21350 let rt = markdown_to_adf(&md).unwrap();
21351 let p = rt.content[0].content.as_ref().unwrap();
21352 assert_eq!(
21353 p[0].text.as_deref(),
21354 Some(" "),
21355 "space-only text node should survive round-trip"
21356 );
21357 }
21358
21359 #[test]
21362 fn issue_522_listitem_hardbreak_then_two_paragraphs_roundtrips() {
21363 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"}]}]}]}]}"#;
21366 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21367 let md = adf_to_markdown(&doc).unwrap();
21368 let rt = markdown_to_adf(&md).unwrap();
21369
21370 let items = rt.content[0].content.as_ref().unwrap();
21371 assert_eq!(items.len(), 1);
21372 let children = items[0].content.as_ref().unwrap();
21373 assert_eq!(
21374 children.len(),
21375 3,
21376 "Expected 3 paragraphs in listItem, got {}",
21377 children.len()
21378 );
21379 assert_eq!(children[0].node_type, "paragraph");
21380 assert_eq!(children[1].node_type, "paragraph");
21381 assert_eq!(children[2].node_type, "paragraph");
21382
21383 let text1 = children[1].content.as_ref().unwrap()[0]
21385 .text
21386 .as_deref()
21387 .unwrap();
21388 assert_eq!(text1, "second paragraph");
21389 let text2 = children[2].content.as_ref().unwrap()[0]
21390 .text
21391 .as_deref()
21392 .unwrap();
21393 assert_eq!(text2, "third paragraph");
21394 }
21395
21396 #[test]
21397 fn issue_522_ordered_list_hardbreak_then_paragraphs_roundtrips() {
21398 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"}]}]}]}]}"#;
21400 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21401 let md = adf_to_markdown(&doc).unwrap();
21402 let rt = markdown_to_adf(&md).unwrap();
21403
21404 let items = rt.content[0].content.as_ref().unwrap();
21405 let children = items[0].content.as_ref().unwrap();
21406 assert_eq!(
21407 children.len(),
21408 3,
21409 "Expected 3 paragraphs in ordered listItem, got {}",
21410 children.len()
21411 );
21412 assert_eq!(children[1].node_type, "paragraph");
21413 assert_eq!(children[2].node_type, "paragraph");
21414 assert_eq!(
21415 children[1].content.as_ref().unwrap()[0]
21416 .text
21417 .as_deref()
21418 .unwrap(),
21419 "second para"
21420 );
21421 assert_eq!(
21422 children[2].content.as_ref().unwrap()[0]
21423 .text
21424 .as_deref()
21425 .unwrap(),
21426 "third para"
21427 );
21428 }
21429
21430 #[test]
21431 fn issue_522_two_paragraphs_without_hardbreak_roundtrips() {
21432 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"}]}]}]}]}"#;
21434 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21435 let md = adf_to_markdown(&doc).unwrap();
21436 let rt = markdown_to_adf(&md).unwrap();
21437
21438 let items = rt.content[0].content.as_ref().unwrap();
21439 let children = items[0].content.as_ref().unwrap();
21440 assert_eq!(
21441 children.len(),
21442 2,
21443 "Expected 2 paragraphs in listItem, got {}",
21444 children.len()
21445 );
21446 assert_eq!(children[0].node_type, "paragraph");
21447 assert_eq!(children[1].node_type, "paragraph");
21448 }
21449
21450 #[test]
21451 fn issue_522_paragraph_then_nested_list_no_spurious_blank() {
21452 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"}]}]}]}]}]}]}"#;
21455 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21456 let md = adf_to_markdown(&doc).unwrap();
21457 assert!(
21459 !md.contains(" \n -"),
21460 "No blank separator between paragraph and nested list"
21461 );
21462 let rt = markdown_to_adf(&md).unwrap();
21463
21464 let items = rt.content[0].content.as_ref().unwrap();
21465 let children = items[0].content.as_ref().unwrap();
21466 assert_eq!(children.len(), 2);
21467 assert_eq!(children[0].node_type, "paragraph");
21468 assert_eq!(children[1].node_type, "bulletList");
21469 }
21470
21471 #[test]
21472 fn issue_522_three_paragraphs_no_hardbreak_roundtrips() {
21473 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"}]}]}]}]}"#;
21475 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21476 let md = adf_to_markdown(&doc).unwrap();
21477 let rt = markdown_to_adf(&md).unwrap();
21478
21479 let items = rt.content[0].content.as_ref().unwrap();
21480 let children = items[0].content.as_ref().unwrap();
21481 assert_eq!(
21482 children.len(),
21483 3,
21484 "Expected 3 paragraphs, got {}",
21485 children.len()
21486 );
21487 for (i, child) in children.iter().enumerate() {
21488 assert_eq!(
21489 child.node_type, "paragraph",
21490 "Child {i} should be a paragraph"
21491 );
21492 }
21493 }
21494
21495 #[test]
21496 fn issue_522_multiple_list_items_each_with_paragraphs() {
21497 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"}]}]}]}]}"#;
21499 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21500 let md = adf_to_markdown(&doc).unwrap();
21501 let rt = markdown_to_adf(&md).unwrap();
21502
21503 let items = rt.content[0].content.as_ref().unwrap();
21504 assert_eq!(items.len(), 2, "Expected 2 list items");
21505
21506 let item1 = items[0].content.as_ref().unwrap();
21507 assert_eq!(item1.len(), 2, "Item 1 should have 2 paragraphs");
21508
21509 let item2 = items[1].content.as_ref().unwrap();
21510 assert_eq!(item2.len(), 2, "Item 2 should have 2 paragraphs");
21511 let item2_p1_inlines = item2[0].content.as_ref().unwrap();
21513 let types: Vec<&str> = item2_p1_inlines
21514 .iter()
21515 .map(|n| n.node_type.as_str())
21516 .collect();
21517 assert_eq!(types, vec!["text", "hardBreak", "text"]);
21518 }
21519
21520 #[test]
21521 fn issue_531_blockquote_hardbreak_then_two_paragraphs_roundtrips() {
21522 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"}]}]}]}"#;
21526 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21527 let md = adf_to_markdown(&doc).unwrap();
21528 let rt = markdown_to_adf(&md).unwrap();
21529
21530 let children = rt.content[0].content.as_ref().unwrap();
21531 assert_eq!(
21532 children.len(),
21533 3,
21534 "Expected 3 paragraphs in blockquote, got {}",
21535 children.len()
21536 );
21537 assert_eq!(children[0].node_type, "paragraph");
21538 assert_eq!(children[1].node_type, "paragraph");
21539 assert_eq!(children[2].node_type, "paragraph");
21540
21541 let text1 = children[1].content.as_ref().unwrap()[0]
21542 .text
21543 .as_deref()
21544 .unwrap();
21545 assert_eq!(text1, "second paragraph");
21546 let text2 = children[2].content.as_ref().unwrap()[0]
21547 .text
21548 .as_deref()
21549 .unwrap();
21550 assert_eq!(text2, "third paragraph");
21551 }
21552
21553 #[test]
21554 fn issue_531_blockquote_two_paragraphs_without_hardbreak_roundtrips() {
21555 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"}]}]}]}"#;
21557 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21558 let md = adf_to_markdown(&doc).unwrap();
21559 let rt = markdown_to_adf(&md).unwrap();
21560
21561 let children = rt.content[0].content.as_ref().unwrap();
21562 assert_eq!(
21563 children.len(),
21564 2,
21565 "Expected 2 paragraphs in blockquote, got {}",
21566 children.len()
21567 );
21568 assert_eq!(children[0].node_type, "paragraph");
21569 assert_eq!(children[1].node_type, "paragraph");
21570 }
21571
21572 #[test]
21573 fn issue_531_blockquote_three_paragraphs_no_hardbreak_roundtrips() {
21574 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"}]}]}]}"#;
21576 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21577 let md = adf_to_markdown(&doc).unwrap();
21578 let rt = markdown_to_adf(&md).unwrap();
21579
21580 let children = rt.content[0].content.as_ref().unwrap();
21581 assert_eq!(
21582 children.len(),
21583 3,
21584 "Expected 3 paragraphs in blockquote, got {}",
21585 children.len()
21586 );
21587 for child in children {
21588 assert_eq!(child.node_type, "paragraph");
21589 }
21590 }
21591
21592 #[test]
21593 fn issue_531_blockquote_paragraph_then_list_no_spurious_blank() {
21594 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"}]}]}]}]}]}"#;
21597 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21598 let md = adf_to_markdown(&doc).unwrap();
21599 let rt = markdown_to_adf(&md).unwrap();
21600
21601 let children = rt.content[0].content.as_ref().unwrap();
21602 assert_eq!(children[0].node_type, "paragraph");
21603 assert_eq!(children[1].node_type, "bulletList");
21604 }
21605
21606 #[test]
21607 fn issue_531_blockquote_single_paragraph_unchanged() {
21608 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"solo"}]}]}]}"#;
21610 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21611 let md = adf_to_markdown(&doc).unwrap();
21612 let rt = markdown_to_adf(&md).unwrap();
21613
21614 let children = rt.content[0].content.as_ref().unwrap();
21615 assert_eq!(children.len(), 1);
21616 assert_eq!(children[0].node_type, "paragraph");
21617 let text = children[0].content.as_ref().unwrap()[0]
21618 .text
21619 .as_deref()
21620 .unwrap();
21621 assert_eq!(text, "solo");
21622 }
21623
21624 fn assert_roundtrip_marks(adf_json: &str, expected_marks: &[&str]) {
21629 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21630 let md = adf_to_markdown(&doc).unwrap();
21631 let rt = markdown_to_adf(&md).unwrap();
21632 let node = &rt.content[0].content.as_ref().unwrap()[0];
21633 let mark_types: Vec<&str> = node
21634 .marks
21635 .as_ref()
21636 .expect("should have marks")
21637 .iter()
21638 .map(|m| m.mark_type.as_str())
21639 .collect();
21640 assert_eq!(
21641 mark_types, expected_marks,
21642 "mark order mismatch for md={md}"
21643 );
21644 }
21645
21646 #[test]
21647 fn issue_554_code_and_text_color_preserved() {
21648 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21649 {"type":"text","text":"x","marks":[
21650 {"type":"textColor","attrs":{"color":"#008000"}},
21651 {"type":"code"}
21652 ]}
21653 ]}]}"##;
21654 assert_roundtrip_marks(adf_json, &["textColor", "code"]);
21655 }
21656
21657 #[test]
21658 fn issue_554_code_and_bg_color_preserved() {
21659 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21660 {"type":"text","text":"x","marks":[
21661 {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21662 {"type":"code"}
21663 ]}
21664 ]}]}"##;
21665 assert_roundtrip_marks(adf_json, &["backgroundColor", "code"]);
21666 }
21667
21668 #[test]
21669 fn issue_554_code_and_subsup_preserved() {
21670 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21671 {"type":"text","text":"x","marks":[
21672 {"type":"subsup","attrs":{"type":"sub"}},
21673 {"type":"code"}
21674 ]}
21675 ]}]}"#;
21676 assert_roundtrip_marks(adf_json, &["subsup", "code"]);
21677 }
21678
21679 #[test]
21680 fn issue_554_code_and_underline_preserved() {
21681 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21682 {"type":"text","text":"x","marks":[
21683 {"type":"underline"},
21684 {"type":"code"}
21685 ]}
21686 ]}]}"#;
21687 assert_roundtrip_marks(adf_json, &["underline", "code"]);
21688 }
21689
21690 #[test]
21691 fn issue_554_code_textcolor_and_underline_preserved() {
21692 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21693 {"type":"text","text":"x","marks":[
21694 {"type":"textColor","attrs":{"color":"#008000"}},
21695 {"type":"underline"},
21696 {"type":"code"}
21697 ]}
21698 ]}]}"##;
21699 assert_roundtrip_marks(adf_json, &["textColor", "underline", "code"]);
21700 }
21701
21702 #[test]
21703 fn issue_554_textcolor_and_underline_preserved() {
21704 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21705 {"type":"text","text":"x","marks":[
21706 {"type":"textColor","attrs":{"color":"#008000"}},
21707 {"type":"underline"}
21708 ]}
21709 ]}]}"##;
21710 assert_roundtrip_marks(adf_json, &["textColor", "underline"]);
21711 }
21712
21713 #[test]
21714 fn issue_554_underline_and_textcolor_preserved_order_swapped() {
21715 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21716 {"type":"text","text":"x","marks":[
21717 {"type":"underline"},
21718 {"type":"textColor","attrs":{"color":"#008000"}}
21719 ]}
21720 ]}]}"##;
21721 assert_roundtrip_marks(adf_json, &["underline", "textColor"]);
21723 }
21724
21725 #[test]
21726 fn issue_554_textcolor_and_annotation_preserved() {
21727 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21728 {"type":"text","text":"x","marks":[
21729 {"type":"textColor","attrs":{"color":"#008000"}},
21730 {"type":"annotation","attrs":{"id":"abc-123","annotationType":"inlineComment"}}
21731 ]}
21732 ]}]}"##;
21733 assert_roundtrip_marks(adf_json, &["textColor", "annotation"]);
21734 }
21735
21736 #[test]
21737 fn issue_554_bgcolor_and_underline_preserved() {
21738 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21739 {"type":"text","text":"x","marks":[
21740 {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21741 {"type":"underline"}
21742 ]}
21743 ]}]}"##;
21744 assert_roundtrip_marks(adf_json, &["backgroundColor", "underline"]);
21745 }
21746
21747 #[test]
21748 fn issue_554_subsup_and_underline_preserved() {
21749 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21750 {"type":"text","text":"x","marks":[
21751 {"type":"subsup","attrs":{"type":"sub"}},
21752 {"type":"underline"}
21753 ]}
21754 ]}]}"#;
21755 assert_roundtrip_marks(adf_json, &["subsup", "underline"]);
21756 }
21757
21758 #[test]
21759 fn issue_554_exact_reproducer_full_match() {
21760 let adf_json = r##"{
21763 "version": 1,
21764 "type": "doc",
21765 "content": [
21766 {
21767 "type": "paragraph",
21768 "content": [
21769 {"type":"text","text":"Status: ","marks":[{"type":"strong"}]},
21770 {"type":"text","text":"Approved","marks":[
21771 {"type":"textColor","attrs":{"color":"#008000"}}
21772 ]},
21773 {"type":"text","text":" — ready to proceed"}
21774 ]
21775 }
21776 ]
21777 }"##;
21778 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21779 let md = adf_to_markdown(&doc).unwrap();
21780 assert!(
21781 md.contains(":span[Approved]{color=#008000}"),
21782 "JFM should contain green span: {md}"
21783 );
21784 let rt = markdown_to_adf(&md).unwrap();
21785 let approved = rt.content[0]
21787 .content
21788 .as_ref()
21789 .unwrap()
21790 .iter()
21791 .find(|n| n.text.as_deref() == Some("Approved"))
21792 .expect("Approved text node");
21793 let marks = approved.marks.as_ref().expect("should have marks");
21794 let color_mark = marks
21795 .iter()
21796 .find(|m| m.mark_type == "textColor")
21797 .expect("textColor mark must be preserved");
21798 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
21799 }
21800
21801 #[test]
21802 fn issue_554_textcolor_with_code_renders_span_around_code() {
21803 let doc = AdfDocument {
21806 version: 1,
21807 doc_type: "doc".to_string(),
21808 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
21809 "fn main",
21810 vec![
21811 AdfMark::text_color("#008000"),
21812 AdfMark {
21813 mark_type: "code".to_string(),
21814 attrs: None,
21815 },
21816 ],
21817 )])],
21818 };
21819 let md = adf_to_markdown(&doc).unwrap();
21820 assert!(
21821 md.contains(":span[`fn main`]{color=#008000}"),
21822 "expected span-wrapped code, got: {md}"
21823 );
21824 }
21825
21826 #[test]
21827 fn issue_554_underline_with_code_renders_bracketed_around_code() {
21828 let doc = AdfDocument {
21829 version: 1,
21830 doc_type: "doc".to_string(),
21831 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
21832 "fn main",
21833 vec![
21834 AdfMark::underline(),
21835 AdfMark {
21836 mark_type: "code".to_string(),
21837 attrs: None,
21838 },
21839 ],
21840 )])],
21841 };
21842 let md = adf_to_markdown(&doc).unwrap();
21843 assert!(
21844 md.contains("[`fn main`]{underline}"),
21845 "expected bracketed-span around code, got: {md}"
21846 );
21847 }
21848
21849 #[test]
21852 fn issue_554_underscore_adjacent_to_textcolor_span_roundtrip() {
21853 let adf_json = r##"{
21858 "version": 1,
21859 "type": "doc",
21860 "content": [
21861 {
21862 "type": "paragraph",
21863 "content": [
21864 {"type":"text","text":"_ "},
21865 {"type":"text","text":"_Action:*","marks":[
21866 {"type":"textColor","attrs":{"color":"#008000"}}
21867 ]},
21868 {"type":"text","text":" Complete the setup process."}
21869 ]
21870 }
21871 ]
21872 }"##;
21873 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21874 let md = adf_to_markdown(&doc).unwrap();
21875 assert!(
21878 md.contains(r"\_ ") && md.contains(r":span[\_Action"),
21879 "underscores at node boundaries should be escaped: {md}"
21880 );
21881 let rt = markdown_to_adf(&md).unwrap();
21882 let para_content = rt.content[0].content.as_ref().unwrap();
21883 let colored = para_content
21885 .iter()
21886 .find(|n| {
21887 n.marks
21888 .as_deref()
21889 .is_some_and(|ms| ms.iter().any(|m| m.mark_type == "textColor"))
21890 })
21891 .expect("textColor node must be preserved");
21892 assert_eq!(colored.text.as_deref(), Some("_Action:*"));
21893 let color_mark = colored
21894 .marks
21895 .as_ref()
21896 .unwrap()
21897 .iter()
21898 .find(|m| m.mark_type == "textColor")
21899 .unwrap();
21900 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
21901 for n in para_content {
21903 if let Some(ms) = n.marks.as_deref() {
21904 assert!(
21905 !ms.iter().any(|m| m.mark_type == "em"),
21906 "no em mark should appear, got marks {:?}",
21907 ms.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
21908 );
21909 }
21910 }
21911 }
21912
21913 #[test]
21914 fn issue_554_underscore_intraword_left_unescaped() {
21915 let doc = AdfDocument {
21919 version: 1,
21920 doc_type: "doc".to_string(),
21921 content: vec![AdfNode::paragraph(vec![AdfNode::text(
21922 "call do_something_useful now",
21923 )])],
21924 };
21925 let md = adf_to_markdown(&doc).unwrap();
21926 assert!(
21927 md.contains("do_something_useful") && !md.contains(r"do\_something\_useful"),
21928 "intraword underscores should not be escaped: {md}"
21929 );
21930 }
21931
21932 #[test]
21933 fn issue_554_code_underline_then_textcolor_bracketed_outer() {
21934 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21937 {"type":"text","text":"x","marks":[
21938 {"type":"underline"},
21939 {"type":"textColor","attrs":{"color":"#008000"}},
21940 {"type":"code"}
21941 ]}
21942 ]}]}"##;
21943 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21944 let md = adf_to_markdown(&doc).unwrap();
21945 assert!(
21947 md.starts_with('[') && md.contains("underline}"),
21948 "bracketed-span should wrap the span, got: {md}"
21949 );
21950 let rt = markdown_to_adf(&md).unwrap();
21951 let node = &rt.content[0].content.as_ref().unwrap()[0];
21952 let mark_types: Vec<&str> = node
21953 .marks
21954 .as_ref()
21955 .unwrap()
21956 .iter()
21957 .map(|m| m.mark_type.as_str())
21958 .collect();
21959 assert_eq!(mark_types, vec!["underline", "textColor", "code"]);
21960 }
21961
21962 #[test]
21963 fn issue_554_textcolor_underline_link_all_preserved() {
21964 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21968 {"type":"text","text":"linked","marks":[
21969 {"type":"textColor","attrs":{"color":"#008000"}},
21970 {"type":"underline"},
21971 {"type":"link","attrs":{"href":"https://example.com"}}
21972 ]}
21973 ]}]}"##;
21974 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21975 let md = adf_to_markdown(&doc).unwrap();
21976 let rt = markdown_to_adf(&md).unwrap();
21977 let node = &rt.content[0].content.as_ref().unwrap()[0];
21978 let mark_types: Vec<&str> = node
21979 .marks
21980 .as_ref()
21981 .unwrap()
21982 .iter()
21983 .map(|m| m.mark_type.as_str())
21984 .collect();
21985 assert_eq!(mark_types, vec!["textColor", "underline", "link"]);
21986 }
21987
21988 #[test]
21989 fn issue_554_underline_textcolor_link_bracketed_outer_link_last() {
21990 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21993 {"type":"text","text":"linked","marks":[
21994 {"type":"underline"},
21995 {"type":"textColor","attrs":{"color":"#008000"}},
21996 {"type":"link","attrs":{"href":"https://example.com"}}
21997 ]}
21998 ]}]}"##;
21999 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22000 let md = adf_to_markdown(&doc).unwrap();
22001 let rt = markdown_to_adf(&md).unwrap();
22002 let node = &rt.content[0].content.as_ref().unwrap()[0];
22003 let mark_types: Vec<&str> = node
22004 .marks
22005 .as_ref()
22006 .unwrap()
22007 .iter()
22008 .map(|m| m.mark_type.as_str())
22009 .collect();
22010 assert_eq!(mark_types, vec!["underline", "textColor", "link"]);
22011 }
22012
22013 #[test]
22014 fn issue_554_link_underline_textcolor_link_outer() {
22015 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22019 {"type":"text","text":"linked","marks":[
22020 {"type":"link","attrs":{"href":"https://example.com"}},
22021 {"type":"underline"},
22022 {"type":"textColor","attrs":{"color":"#008000"}}
22023 ]}
22024 ]}]}"##;
22025 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22026 let md = adf_to_markdown(&doc).unwrap();
22027 assert!(
22028 md.starts_with('[') && md.contains("](https://example.com)"),
22029 "link should be outermost, got: {md}"
22030 );
22031 let rt = markdown_to_adf(&md).unwrap();
22032 let node = &rt.content[0].content.as_ref().unwrap()[0];
22033 let mark_types: Vec<&str> = node
22034 .marks
22035 .as_ref()
22036 .unwrap()
22037 .iter()
22038 .map(|m| m.mark_type.as_str())
22039 .collect();
22040 assert_eq!(mark_types, vec!["link", "underline", "textColor"]);
22041 }
22042
22043 #[test]
22044 fn issue_554_trailing_underscore_then_leading_underscore_round_trip() {
22045 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22049 {"type":"text","text":"end_"},
22050 {"type":"text","text":"_start"}
22051 ]}]}"#;
22052 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22053 let md = adf_to_markdown(&doc).unwrap();
22054 let rt = markdown_to_adf(&md).unwrap();
22055 let combined: String = rt.content[0]
22057 .content
22058 .as_ref()
22059 .unwrap()
22060 .iter()
22061 .filter_map(|n| n.text.as_deref())
22062 .collect();
22063 assert_eq!(combined, "end__start");
22064 for n in rt.content[0].content.as_ref().unwrap() {
22066 if let Some(ms) = n.marks.as_deref() {
22067 assert!(!ms.iter().any(|m| m.mark_type == "em"));
22068 }
22069 }
22070 }
22071}