1use anyhow::{bail, 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
18const MAX_NESTING_DEPTH: usize = 64;
31
32pub fn markdown_to_adf(markdown: &str) -> Result<AdfDocument> {
34 debug!(
35 "markdown_to_adf: input {} bytes, {} lines",
36 markdown.len(),
37 markdown.lines().count()
38 );
39 let mut doc = AdfDocument::new();
40 let mut parser = MarkdownParser::new(markdown);
41 doc.content = parser.parse_blocks()?;
42 let split = split_emphasis_code_marks(&mut doc.content);
43 if split > 0 {
44 debug!(
45 "markdown_to_adf: dropped {split} emphasis mark(s) from inline-code runs \
46 (ADF forbids combining them with `code`; issue #1391)"
47 );
48 }
49 debug!(
50 "markdown_to_adf: produced {} top-level ADF nodes",
51 doc.content.len()
52 );
53 Ok(doc)
54}
55
56struct MarkdownParser<'a> {
58 lines: Vec<&'a str>,
59 pos: usize,
60 depth: usize,
65}
66
67impl<'a> MarkdownParser<'a> {
68 fn new(input: &'a str) -> Self {
69 Self::with_depth(input, 0)
70 }
71
72 fn with_depth(input: &'a str, depth: usize) -> Self {
74 Self {
75 lines: input.lines().collect(),
76 pos: 0,
77 depth,
78 }
79 }
80
81 fn at_end(&self) -> bool {
82 self.pos >= self.lines.len()
83 }
84
85 fn current_line(&self) -> &'a str {
86 self.lines[self.pos]
87 }
88
89 fn advance(&mut self) {
90 self.pos += 1;
91 }
92
93 fn collect_hardbreak_continuations(&mut self, full_text: &mut String) {
101 while has_trailing_hard_break(full_text) && !self.at_end() {
102 if !self.try_append_hardbreak_continuation(full_text) {
103 break;
104 }
105 }
106 }
107
108 fn try_append_hardbreak_continuation(&mut self, full_text: &mut String) -> bool {
115 match self
121 .current_line()
122 .strip_prefix(" ")
123 .filter(|s| !is_block_level_continuation_marker(s.trim_start()))
124 {
125 Some(stripped) => {
126 full_text.push('\n');
127 full_text.push_str(stripped);
128 self.advance();
129 true
130 }
131 None => false,
132 }
133 }
134
135 fn parse_blocks(&mut self) -> Result<Vec<AdfNode>> {
136 if self.depth > MAX_NESTING_DEPTH {
137 bail!(
138 "Markdown nesting exceeds the maximum depth of {MAX_NESTING_DEPTH} \
139 (nested blockquotes, lists, panels, expands, layouts, or table \
140 cells); flatten the document structure"
141 );
142 }
143
144 let mut blocks = Vec::new();
145
146 while !self.at_end() {
147 let line = self.current_line();
148
149 if line.trim().is_empty() {
150 self.advance();
151 continue;
152 }
153
154 let mut node = if let Some(node) = self.try_heading() {
155 node
156 } else if let Some(node) = self.try_horizontal_rule() {
157 node
158 } else if let Some(node) = self.try_container_directive()? {
159 node
160 } else if let Some(node) = self.try_code_block()? {
161 node
162 } else if let Some(node) = self.try_table()? {
163 node
164 } else if let Some(node) = self.try_blockquote()? {
165 node
166 } else if let Some(node) = self.try_list()? {
167 node
168 } else if let Some(node) = self.try_leaf_directive() {
169 node
170 } else if let Some(node) = self.try_image() {
171 node
172 } else {
173 self.parse_paragraph()?
174 };
175
176 self.try_apply_block_attrs(&mut node);
178 blocks.push(node);
179 }
180
181 Ok(blocks)
182 }
183
184 fn try_heading(&mut self) -> Option<AdfNode> {
185 let line = self.current_line();
186 let trimmed = line.trim_start();
187
188 if !trimmed.starts_with('#') {
189 return None;
190 }
191
192 let level = trimmed.chars().take_while(|&c| c == '#').count();
193 if !(1..=6).contains(&level) || !trimmed[level..].starts_with(' ') {
194 return None;
195 }
196
197 let mut full_text = trimmed[level + 1..].to_string();
198 self.advance();
199 self.collect_hardbreak_continuations(&mut full_text);
201 let mut inline_nodes = parse_inline(&full_text);
202 if strip_heading_code_marks(&mut inline_nodes) > 0 {
207 warn!(
208 "Stripped inline `code` from heading (ADF forbids code marks on \
209 headings; kept the text as plain): {}",
210 full_text.trim()
211 );
212 }
213
214 #[allow(clippy::cast_possible_truncation)]
215 Some(AdfNode::heading(level as u8, inline_nodes))
216 }
217
218 fn try_horizontal_rule(&mut self) -> Option<AdfNode> {
219 let line = self.current_line().trim();
220 let is_rule = (line.starts_with("---") && line.chars().all(|c| c == '-'))
221 || (line.starts_with("***") && line.chars().all(|c| c == '*'))
222 || (line.starts_with("___") && line.chars().all(|c| c == '_'));
223
224 if is_rule && line.len() >= 3 {
225 self.advance();
226 Some(AdfNode::rule())
227 } else {
228 None
229 }
230 }
231
232 fn try_code_block(&mut self) -> Result<Option<AdfNode>> {
233 let line = self.current_line();
234 if !is_code_fence_opener(line) {
235 return Ok(None);
236 }
237
238 let language = line[3..].trim();
239 let language = if language == "\"\"" {
240 Some(String::new())
242 } else if language.is_empty() {
243 None
244 } else {
245 Some(language.to_string())
246 };
247
248 self.advance();
249 let mut code_lines = Vec::new();
250
251 while !self.at_end() {
252 let line = self.current_line();
253 if line.starts_with("```") {
254 self.advance();
255 break;
256 }
257 code_lines.push(line);
258 self.advance();
259 }
260
261 let code_text = code_lines.join("\n");
262
263 if language.as_deref() == Some("adf-unsupported") {
265 if let Ok(node) = serde_json::from_str::<AdfNode>(&code_text) {
266 return Ok(Some(node));
267 }
268 }
269
270 Ok(Some(AdfNode::code_block(language.as_deref(), &code_text)))
271 }
272
273 fn try_blockquote(&mut self) -> Result<Option<AdfNode>> {
274 let line = self.current_line();
275 if !line.starts_with('>') {
276 return Ok(None);
277 }
278
279 let mut quote_lines = Vec::new();
280 while !self.at_end() {
281 let line = self.current_line();
282 if let Some(rest) = line.strip_prefix("> ") {
283 quote_lines.push(rest);
284 self.advance();
285 } else if let Some(rest) = line.strip_prefix('>') {
286 quote_lines.push(rest);
287 self.advance();
288 } else {
289 break;
290 }
291 }
292
293 let quote_text = quote_lines.join("\n");
294 let mut inner_parser = MarkdownParser::with_depth("e_text, self.depth + 1);
295 let inner_blocks = inner_parser.parse_blocks()?;
296
297 Ok(Some(AdfNode::blockquote(inner_blocks)))
298 }
299
300 fn try_list(&mut self) -> Result<Option<AdfNode>> {
301 let line = self.current_line();
302 let trimmed = line.trim_start();
303
304 let is_bullet =
305 trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ");
306 let ordered_match = parse_ordered_list_marker(trimmed);
307
308 if !is_bullet && ordered_match.is_none() {
309 return Ok(None);
310 }
311
312 if is_bullet {
313 self.parse_bullet_list()
314 } else {
315 let start = ordered_match.map_or(1, |(n, _)| n);
316 self.parse_ordered_list(start)
317 }
318 }
319
320 fn parse_bullet_list(&mut self) -> Result<Option<AdfNode>> {
321 let mut items = Vec::new();
322 let mut is_task_list = false;
323
324 while !self.at_end() {
325 let line = self.current_line();
326 let trimmed = line.trim_start();
327
328 if !(trimmed.starts_with("- ")
329 || trimmed.starts_with("* ")
330 || trimmed.starts_with("+ "))
331 {
332 break;
333 }
334
335 let after_marker = trimmed[2..].trim_start();
336
337 if let Some((state, text)) = try_parse_task_marker(after_marker) {
339 is_task_list = true;
340 self.advance();
341 let mut full_text = text.to_string();
345 self.collect_hardbreak_continuations(&mut full_text);
346 let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
347 let inline_nodes = parse_inline(item_text);
348 let content = if let Some(ref plid) = para_local_id {
352 let mut para = AdfNode::paragraph(inline_nodes);
353 if plid != "_" {
354 para.attrs = Some(serde_json::json!({"localId": plid}));
355 }
356 vec![para]
357 } else {
358 inline_nodes
359 };
360 let mut task = AdfNode::task_item(state, content);
361 if let Some(id) = local_id {
363 if let Some(ref mut attrs) = task.attrs {
364 attrs["localId"] = serde_json::Value::String(id);
365 }
366 }
367 let mut sub_lines: Vec<String> = Vec::new();
371 while !self.at_end() && self.current_line().starts_with(" ") {
372 let stripped = &self.current_line()[2..];
373 sub_lines.push(stripped.to_string());
374 self.advance();
375 }
376 if !sub_lines.is_empty() {
377 let sub_text = sub_lines.join("\n");
378 let mut nested =
379 MarkdownParser::with_depth(&sub_text, self.depth + 1).parse_blocks()?;
380 let is_empty = task.content.as_ref().map_or(true, Vec::is_empty);
388 if is_empty && nested.len() == 1 && nested[0].node_type == "taskList" {
389 if let Some(task_items) = nested.remove(0).content {
390 task.content = Some(task_items);
391 }
392 if let Some(ref mut attrs) = task.attrs {
393 if let Some(obj) = attrs.as_object_mut() {
394 obj.remove("state");
395 }
396 }
397 items.push(task);
398 } else {
399 let mut sibling_task_lists = Vec::new();
405 let mut child_nodes = Vec::new();
406 for n in nested {
407 if n.node_type == "taskList" {
408 sibling_task_lists.push(n);
409 } else {
410 child_nodes.push(n);
411 }
412 }
413 if !child_nodes.is_empty() {
414 match task.content {
415 Some(ref mut content) => content.append(&mut child_nodes),
416 None => task.content = Some(child_nodes),
417 }
418 }
419 items.push(task);
420 items.append(&mut sibling_task_lists);
421 }
422 } else {
423 items.push(task);
424 }
425 } else {
426 let first_line = &trimmed[2..];
427 self.advance();
428 let mut full_text = first_line.to_string();
429 self.collect_hardbreak_continuations(&mut full_text);
430 let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
431 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 = parse_list_item_first_line(
445 item_text,
446 sub_lines,
447 local_id,
448 para_local_id,
449 self.depth + 1,
450 )?;
451 items.push(item_content);
452 }
453 }
454
455 if items.is_empty() {
456 Ok(None)
457 } else if is_task_list {
458 Ok(Some(AdfNode::task_list(items)))
459 } else {
460 Ok(Some(AdfNode::bullet_list(items)))
461 }
462 }
463
464 fn parse_ordered_list(&mut self, start: u32) -> Result<Option<AdfNode>> {
465 let mut items = Vec::new();
466
467 while !self.at_end() {
468 let line = self.current_line();
469 let trimmed = line.trim_start();
470
471 if let Some((_, rest)) = parse_ordered_list_marker(trimmed) {
472 let first_line = rest.trim_start_matches(|c: char| c.is_ascii_whitespace());
473 self.advance();
474 let mut full_text = first_line.to_string();
475 self.collect_hardbreak_continuations(&mut full_text);
476 let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
477 let mut sub_lines: Vec<String> = Vec::new();
479 while !self.at_end() {
480 let next = self.current_line();
481 if let Some(stripped) = next.strip_prefix(" ") {
482 sub_lines.push(stripped.to_string());
483 self.advance();
484 continue;
485 }
486 break;
487 }
488 let item_content = parse_list_item_first_line(
489 item_text,
490 sub_lines,
491 local_id,
492 para_local_id,
493 self.depth + 1,
494 )?;
495 items.push(item_content);
496 } else {
497 break;
498 }
499 }
500
501 if items.is_empty() {
502 Ok(None)
503 } else {
504 let order = if start == 1 { None } else { Some(start) };
505 Ok(Some(AdfNode::ordered_list(items, order)))
506 }
507 }
508
509 fn try_apply_block_attrs(&mut self, node: &mut AdfNode) {
510 if self.at_end() {
511 return;
512 }
513 let line = self.current_line().trim();
514 if !line.starts_with('{') {
515 return;
516 }
517 let Some((_, attrs)) = parse_attrs(line, 0) else {
518 return;
519 };
520
521 let mut marks = Vec::new();
522 if let Some(align) = attrs.get("align") {
523 marks.push(AdfMark::alignment(align));
524 }
525 if let Some(indent) = attrs.get("indent") {
526 if let Ok(level) = indent.parse::<u32>() {
527 marks.push(AdfMark::indentation(level));
528 }
529 }
530 if let Some(mode) = attrs.get("breakout") {
531 let width = attrs
532 .get("breakoutWidth")
533 .and_then(|w| w.parse::<u32>().ok());
534 marks.push(AdfMark::breakout(mode, width));
535 }
536
537 let local_id = attrs.get("localId").map(str::to_string);
539
540 let order = if node.node_type == "orderedList" {
542 attrs.get("order").and_then(|v| v.parse::<u32>().ok())
543 } else {
544 None
545 };
546
547 let has_attrs = !marks.is_empty() || local_id.is_some() || order.is_some();
548 if has_attrs {
549 if !marks.is_empty() {
550 let existing = node.marks.get_or_insert_with(Vec::new);
551 existing.extend(marks);
552 }
553 if let Some(id) = local_id {
554 let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
555 node_attrs["localId"] = serde_json::Value::String(id);
556 }
557 if let Some(n) = order {
558 let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
559 node_attrs["order"] = serde_json::json!(n);
560 }
561 self.advance(); }
563 }
564
565 fn try_container_directive(&mut self) -> Result<Option<AdfNode>> {
566 let line = self.current_line();
567 let Some((d, colon_count)) = try_parse_container_open(line) else {
568 return Ok(None);
569 };
570 self.advance(); let mut inner_lines = Vec::new();
574 let mut depth: usize = 0;
575 while !self.at_end() {
576 let current = self.current_line();
577 if try_parse_container_open(current).is_some() {
578 depth += 1;
579 } else if depth == 0 && is_container_close(current, colon_count) {
580 self.advance(); break;
582 } else if depth > 0 && is_container_close(current, 3) {
583 depth -= 1;
584 }
585 inner_lines.push(current.to_string());
586 self.advance();
587 }
588
589 let inner_text = inner_lines.join("\n");
590
591 let node = match d.name.as_str() {
592 "panel" => {
593 let panel_type = d
594 .attrs
595 .as_ref()
596 .and_then(|a| a.get("type"))
597 .unwrap_or("info");
598 let inner_blocks =
599 MarkdownParser::with_depth(&inner_text, self.depth + 1).parse_blocks()?;
600 let mut node = AdfNode::panel(panel_type, inner_blocks);
601 if let Some(ref attrs) = d.attrs {
603 if let Some(ref mut node_attrs) = node.attrs {
604 if let Some(icon) = attrs.get("icon") {
605 node_attrs["panelIcon"] = serde_json::Value::String(icon.to_string());
606 }
607 if let Some(color) = attrs.get("color") {
608 node_attrs["panelColor"] = serde_json::Value::String(color.to_string());
609 }
610 }
611 }
612 node
613 }
614 "expand" => {
615 let title = d.attrs.as_ref().and_then(|a| a.get("title"));
616 let inner_blocks =
617 MarkdownParser::with_depth(&inner_text, self.depth + 1).parse_blocks()?;
618 let mut node = AdfNode::expand(title, inner_blocks);
619 pass_through_expand_params(&d.attrs, &mut node);
620 node
621 }
622 "nested-expand" => {
623 let title = d.attrs.as_ref().and_then(|a| a.get("title"));
624 let inner_blocks =
625 MarkdownParser::with_depth(&inner_text, self.depth + 1).parse_blocks()?;
626 let mut node = AdfNode::nested_expand(title, inner_blocks);
627 pass_through_expand_params(&d.attrs, &mut node);
628 node
629 }
630 "layout" => {
631 let columns = self.parse_layout_columns(&inner_text)?;
633 AdfNode::layout_section(columns)
634 }
635 "decisions" => {
636 let items = parse_decision_items(&inner_text);
637 AdfNode::decision_list(items)
638 }
639 "table" => {
640 let rows = self.parse_directive_table_rows(&inner_text)?;
641 let mut table_attrs = serde_json::json!({});
642 if let Some(ref attrs) = d.attrs {
643 if let Some(layout) = attrs.get("layout") {
644 table_attrs["layout"] = serde_json::Value::String(layout.to_string());
645 }
646 if attrs.has_flag("numbered") {
647 table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
648 } else if attrs.get("numbered") == Some("false") {
649 table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
650 }
651 if let Some(tw) = attrs.get("width") {
652 if let Some(w) = parse_numeric_attr(tw) {
653 table_attrs["width"] = w;
654 }
655 }
656 if let Some(local_id) = attrs.get("localId") {
657 table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
658 }
659 }
660 if table_attrs == serde_json::json!({}) {
661 AdfNode::table(rows)
662 } else {
663 AdfNode::table_with_attrs(rows, table_attrs)
664 }
665 }
666 "extension" => {
667 let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
668 let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
669 let inner_blocks =
670 MarkdownParser::with_depth(&inner_text, self.depth + 1).parse_blocks()?;
671 let mut node = AdfNode::bodied_extension(ext_type, ext_key, inner_blocks);
672 if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
673 {
674 if let Some(layout) = dir_attrs.get("layout") {
675 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
676 }
677 if let Some(local_id) = dir_attrs.get("localId") {
678 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
679 }
680 if let Some(params_str) = dir_attrs.get("params") {
681 if let Ok(params_val) =
682 serde_json::from_str::<serde_json::Value>(params_str)
683 {
684 node_attrs["parameters"] = params_val;
685 }
686 }
687 }
688 node
689 }
690 _ => return Ok(None),
691 };
692
693 Ok(Some(node))
694 }
695
696 fn parse_layout_columns(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
697 let mut columns = Vec::new();
698 let mut current_column_lines: Vec<String> = Vec::new();
699 let mut current_width: serde_json::Value = serde_json::json!(50);
700 let mut current_dir_attrs: Option<crate::atlassian::attrs::Attrs> = None;
701 let mut in_column = false;
702 let mut depth: usize = 0;
703
704 let lines: Vec<&str> = inner_text.lines().collect();
705 let mut i = 0;
706
707 while i < lines.len() {
708 let line = lines[i];
709 if let Some((col_d, _)) = try_parse_container_open(line) {
710 if col_d.name == "column" && depth == 0 {
711 if in_column && !current_column_lines.is_empty() {
713 let col_text = current_column_lines.join("\n");
714 let blocks =
715 MarkdownParser::with_depth(&col_text, self.depth + 1).parse_blocks()?;
716 let mut col = AdfNode::layout_column(current_width.clone(), blocks);
717 pass_through_local_id(¤t_dir_attrs, &mut col);
718 columns.push(col);
719 current_column_lines.clear();
720 }
721 current_width = col_d
722 .attrs
723 .as_ref()
724 .and_then(|a| a.get("width"))
725 .and_then(parse_numeric_attr)
726 .unwrap_or_else(|| serde_json::json!(50));
727 current_dir_attrs = col_d.attrs;
728 in_column = true;
729 i += 1;
730 continue;
731 }
732 if in_column {
733 depth += 1;
734 }
735 }
736 if in_column && is_container_close(line, 3) {
737 if depth > 0 {
738 depth -= 1;
739 current_column_lines.push(line.to_string());
740 i += 1;
741 continue;
742 }
743 let col_text = current_column_lines.join("\n");
745 let blocks =
746 MarkdownParser::with_depth(&col_text, self.depth + 1).parse_blocks()?;
747 let mut col = AdfNode::layout_column(current_width.clone(), blocks);
748 pass_through_local_id(¤t_dir_attrs, &mut col);
749 columns.push(col);
750 current_column_lines.clear();
751 current_dir_attrs = None;
752 in_column = false;
753 i += 1;
754 continue;
755 }
756 if in_column {
757 current_column_lines.push(line.to_string());
758 }
759 i += 1;
760 }
761
762 if in_column && !current_column_lines.is_empty() {
764 let col_text = current_column_lines.join("\n");
765 let blocks = MarkdownParser::with_depth(&col_text, self.depth + 1).parse_blocks()?;
766 let mut col = AdfNode::layout_column(current_width, blocks);
767 pass_through_local_id(¤t_dir_attrs, &mut col);
768 columns.push(col);
769 }
770
771 Ok(columns)
772 }
773
774 fn parse_directive_table_rows(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
776 debug!(
777 "parse_directive_table_rows: {} lines of inner text",
778 inner_text.lines().count()
779 );
780 let mut rows = Vec::new();
781 let lines: Vec<&str> = inner_text.lines().collect();
782 let mut i = 0;
783
784 while i < lines.len() {
785 let line = lines[i];
786 if let Some((d, _)) = try_parse_container_open(line) {
787 if d.name == "tr" {
788 let tr_attrs = d.attrs.clone();
789 i += 1;
790 let (mut row, next_i) = self.parse_directive_table_row(&lines, i)?;
791 if let Some(ref attrs) = tr_attrs {
793 if let Some(local_id) = attrs.get("localId") {
794 let row_attrs = row.attrs.get_or_insert_with(|| serde_json::json!({}));
795 row_attrs["localId"] = serde_json::Value::String(local_id.to_string());
796 }
797 }
798 rows.push(row);
799 i = next_i;
800 continue;
801 }
802 if d.name == "caption" {
803 let dir_attrs = d.attrs.clone();
804 i += 1;
805 let mut caption_lines = Vec::new();
806 while i < lines.len() {
807 if is_container_close(lines[i], 3) {
808 i += 1;
809 break;
810 }
811 caption_lines.push(lines[i]);
812 i += 1;
813 }
814 let caption_text = caption_lines.join("\n");
815 let inline_nodes = parse_inline(&caption_text);
816 let mut caption = AdfNode::caption(inline_nodes);
817 pass_through_local_id(&dir_attrs, &mut caption);
818 rows.push(caption);
819 continue;
820 }
821 }
822 i += 1;
823 }
824
825 Ok(rows)
826 }
827
828 fn parse_directive_table_row(&self, lines: &[&str], start: usize) -> Result<(AdfNode, usize)> {
830 let mut cells = Vec::new();
831 let mut i = start;
832 let mut depth: usize = 0;
833
834 while i < lines.len() {
835 let line = lines[i];
836 if is_container_close(line, 3) {
837 if depth == 0 {
838 i += 1;
840 break;
841 }
842 depth -= 1;
843 i += 1;
844 continue;
845 }
846 if let Some((d, _)) = try_parse_container_open(line) {
847 if depth == 0 && (d.name == "th" || d.name == "td") {
848 let is_header = d.name == "th";
849 let cell_attrs = d.attrs.clone();
850 i += 1;
851 let (cell, next_i) =
852 self.parse_directive_table_cell(lines, i, is_header, cell_attrs)?;
853 cells.push(cell);
854 i = next_i;
855 continue;
856 }
857 depth += 1;
858 }
859 i += 1;
860 }
861
862 if cells.is_empty() {
863 let context = lines[start.saturating_sub(1)..lines.len().min(start + 3)].to_vec();
864 warn!(
865 "Directive table row at line {start} has no cells — \
866 Confluence requires at least one. Nearby lines: {context:?}"
867 );
868 }
869 debug!("Parsed directive table row: {} cells", cells.len());
870
871 Ok((AdfNode::table_row(cells), i))
872 }
873
874 fn parse_directive_table_cell(
876 &self,
877 lines: &[&str],
878 start: usize,
879 is_header: bool,
880 cell_attrs: Option<crate::atlassian::attrs::Attrs>,
881 ) -> Result<(AdfNode, usize)> {
882 let mut cell_lines = Vec::new();
883 let mut i = start;
884 let mut depth: usize = 0;
885
886 while i < lines.len() {
887 let line = lines[i];
888 if try_parse_container_open(line).is_some() {
889 depth += 1;
890 } else if is_container_close(line, 3) {
891 if depth == 0 {
892 i += 1;
893 break;
894 }
895 depth -= 1;
896 }
897 cell_lines.push(line.to_string());
898 i += 1;
899 }
900
901 let cell_text = cell_lines.join("\n");
902 let blocks = MarkdownParser::with_depth(&cell_text, self.depth + 1).parse_blocks()?;
903
904 let adf_attrs = cell_attrs.as_ref().map(build_cell_attrs);
905 let cell_marks = cell_attrs
906 .as_ref()
907 .map(build_border_marks)
908 .unwrap_or_default();
909
910 let cell = if cell_marks.is_empty() {
911 if is_header {
912 if let Some(attrs) = adf_attrs {
913 AdfNode::table_header_with_attrs(blocks, attrs)
914 } else {
915 AdfNode::table_header(blocks)
916 }
917 } else if let Some(attrs) = adf_attrs {
918 AdfNode::table_cell_with_attrs(blocks, attrs)
919 } else {
920 AdfNode::table_cell(blocks)
921 }
922 } else if is_header {
923 AdfNode::table_header_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
924 } else {
925 AdfNode::table_cell_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
926 };
927
928 Ok((cell, i))
929 }
930
931 fn try_leaf_directive(&mut self) -> Option<AdfNode> {
932 let line = self.current_line();
933 let d = try_parse_leaf_directive(line)?;
934
935 let node = match d.name.as_str() {
936 "card" => {
937 let content = d.content.as_deref().unwrap_or("");
938 let url = match d.attrs.as_ref().and_then(|a| a.get("url")) {
943 Some(u) => u,
944 None => content,
945 };
946 let mut node = AdfNode::block_card(url);
947 if let Some(ref attrs) = d.attrs {
949 if let Some(ref mut node_attrs) = node.attrs {
950 if let Some(layout) = attrs.get("layout") {
951 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
952 }
953 if let Some(width) = attrs.get("width") {
954 if let Ok(w) = width.parse::<u64>() {
955 node_attrs["width"] = serde_json::json!(w);
956 }
957 }
958 }
959 }
960 node
961 }
962 "embed" => {
963 let url = d.content.as_deref().unwrap_or("");
964 let layout = d.attrs.as_ref().and_then(|a| a.get("layout"));
965 let original_height = d
966 .attrs
967 .as_ref()
968 .and_then(|a| a.get("originalHeight"))
969 .and_then(|v| v.parse::<f64>().ok());
970 let width = d
971 .attrs
972 .as_ref()
973 .and_then(|a| a.get("width"))
974 .and_then(|w| w.parse::<f64>().ok());
975 AdfNode::embed_card(url, layout, original_height, width)
976 }
977 "extension" => {
978 let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
979 let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
980 let params = d
981 .attrs
982 .as_ref()
983 .and_then(|a| a.get("params"))
984 .and_then(|p| serde_json::from_str(p).ok());
985 let mut node = AdfNode::extension(ext_type, ext_key, params);
986 if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
987 {
988 if let Some(layout) = dir_attrs.get("layout") {
989 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
990 }
991 if let Some(local_id) = dir_attrs.get("localId") {
992 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
993 }
994 }
995 node
996 }
997 "paragraph" => {
998 let mut node = if let Some(ref text) = d.content {
999 AdfNode::paragraph(parse_inline(text))
1000 } else {
1001 AdfNode::paragraph(vec![])
1002 };
1003 pass_through_local_id(&d.attrs, &mut node);
1004 node
1005 }
1006 _ => return None,
1007 };
1008
1009 self.advance();
1010 Some(node)
1011 }
1012
1013 fn try_image(&mut self) -> Option<AdfNode> {
1014 let line = self.current_line().trim();
1015 let mut node = try_parse_media_single_from_line(line)?;
1016 self.advance();
1017
1018 if !self.at_end() {
1020 if let Some((d, _)) = try_parse_container_open(self.current_line()) {
1021 if d.name == "caption" {
1022 let dir_attrs = d.attrs;
1023 self.advance(); let mut caption_lines = Vec::new();
1025 while !self.at_end() {
1026 if is_container_close(self.current_line(), 3) {
1027 self.advance(); break;
1029 }
1030 caption_lines.push(self.current_line());
1031 self.advance();
1032 }
1033 let caption_text = caption_lines.join("\n");
1034 let inline_nodes = parse_inline(&caption_text);
1035 let mut caption = AdfNode::caption(inline_nodes);
1036 pass_through_local_id(&dir_attrs, &mut caption);
1037 if let Some(ref mut content) = node.content {
1038 content.push(caption);
1039 }
1040 }
1041 }
1042 }
1043
1044 Some(node)
1045 }
1046
1047 fn try_table(&mut self) -> Result<Option<AdfNode>> {
1048 let line = self.current_line();
1049 if !line.contains('|') || !line.trim_start().starts_with('|') {
1050 return Ok(None);
1051 }
1052
1053 if self.pos + 1 >= self.lines.len() {
1055 return Ok(None);
1056 }
1057 let next_line = self.lines[self.pos + 1];
1058 if !is_table_separator(next_line) {
1059 return Ok(None);
1060 }
1061
1062 let header_cells = parse_table_row(line);
1064 self.advance(); let sep_line = self.current_line();
1068 let alignments = parse_table_alignments(sep_line);
1069 self.advance(); let mut rows = Vec::new();
1072
1073 let header_adf_cells: Vec<AdfNode> = header_cells
1075 .iter()
1076 .enumerate()
1077 .map(|(col_idx, cell)| {
1078 let (cell_text, cell_attrs) = extract_cell_attrs(cell);
1079 let mut para = AdfNode::paragraph(parse_inline(&cell_text));
1080 apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
1081 if let Some(attrs) = cell_attrs {
1082 AdfNode::table_header_with_attrs(vec![para], attrs)
1083 } else {
1084 AdfNode::table_header(vec![para])
1085 }
1086 })
1087 .collect();
1088 if header_adf_cells.is_empty() {
1089 warn!(
1090 "Pipe table header row at line {} has no cells",
1091 self.pos - 1
1092 );
1093 }
1094 rows.push(AdfNode::table_row(header_adf_cells));
1095
1096 while !self.at_end() {
1098 let line = self.current_line();
1099 if !line.contains('|') || line.trim().is_empty() {
1100 break;
1101 }
1102
1103 let cells = parse_table_row(line);
1104 let adf_cells: Vec<AdfNode> = cells
1105 .iter()
1106 .enumerate()
1107 .map(|(col_idx, cell)| {
1108 let (cell_text, cell_attrs) = extract_cell_attrs(cell);
1109 let mut para = AdfNode::paragraph(parse_inline(&cell_text));
1110 apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
1111 if let Some(attrs) = cell_attrs {
1112 AdfNode::table_cell_with_attrs(vec![para], attrs)
1113 } else {
1114 AdfNode::table_cell(vec![para])
1115 }
1116 })
1117 .collect();
1118 if adf_cells.is_empty() {
1119 warn!("Pipe table body row at line {} has no cells", self.pos);
1120 }
1121 rows.push(AdfNode::table_row(adf_cells));
1122 self.advance();
1123 }
1124
1125 debug!("Parsed pipe table with {} rows", rows.len());
1126 let mut table = AdfNode::table(rows);
1127
1128 if !self.at_end() {
1130 let next = self.current_line().trim();
1131 if next.starts_with('{') {
1132 if let Some((_, attrs)) = parse_attrs(next, 0) {
1133 let mut table_attrs = serde_json::json!({});
1134 if let Some(layout) = attrs.get("layout") {
1135 table_attrs["layout"] = serde_json::Value::String(layout.to_string());
1136 }
1137 if attrs.has_flag("numbered") {
1138 table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
1139 } else if attrs.get("numbered") == Some("false") {
1140 table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
1141 }
1142 if let Some(tw) = attrs.get("width") {
1143 if let Some(w) = parse_numeric_attr(tw) {
1144 table_attrs["width"] = w;
1145 }
1146 }
1147 if let Some(local_id) = attrs.get("localId") {
1148 table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1149 }
1150 if table_attrs != serde_json::json!({}) {
1151 table.attrs = Some(table_attrs);
1152 self.advance(); }
1154 }
1155 }
1156 }
1157
1158 Ok(Some(table))
1159 }
1160
1161 fn parse_paragraph(&mut self) -> Result<AdfNode> {
1162 let mut lines: Vec<&str> = Vec::new();
1163
1164 while !self.at_end() {
1165 let line = self.current_line();
1166 if (line.trim().is_empty()
1175 && !lines
1176 .last()
1177 .is_some_and(|prev| has_trailing_hard_break(prev)))
1178 || is_code_fence_opener(line)
1179 || (is_horizontal_rule(line) && !lines.is_empty())
1180 {
1181 break;
1182 }
1183 let is_hardbreak_cont = !lines.is_empty()
1186 && line.starts_with(" ")
1187 && lines
1188 .last()
1189 .is_some_and(|prev| has_trailing_hard_break(prev));
1190 if is_hardbreak_cont {
1191 lines.push(&line[2..]);
1192 self.advance();
1193 continue;
1194 }
1195 if !lines.is_empty()
1196 && (line.starts_with('#') || line.starts_with('>') || is_list_start(line))
1197 {
1198 break;
1199 }
1200 if !lines.is_empty() && is_block_attrs_line(line) {
1202 break;
1203 }
1204 lines.push(line);
1205 self.advance();
1206 }
1207
1208 let text = lines.join("\n");
1209 let inline_nodes = parse_inline(&text);
1210 Ok(AdfNode::paragraph(inline_nodes))
1211 }
1212}
1213
1214fn build_cell_attrs(attrs: &crate::atlassian::attrs::Attrs) -> serde_json::Value {
1217 let mut adf = serde_json::json!({});
1218 if let Some(bg) = attrs.get("bg") {
1219 adf["background"] = serde_json::Value::String(bg.to_string());
1220 }
1221 if let Some(colspan) = attrs.get("colspan") {
1222 if let Ok(n) = colspan.parse::<u32>() {
1223 adf["colspan"] = serde_json::json!(n);
1224 }
1225 }
1226 if let Some(rowspan) = attrs.get("rowspan") {
1227 if let Ok(n) = rowspan.parse::<u32>() {
1228 adf["rowspan"] = serde_json::json!(n);
1229 }
1230 }
1231 if let Some(colwidth) = attrs.get("colwidth") {
1232 let widths: Vec<serde_json::Value> = colwidth
1233 .split(',')
1234 .filter_map(|s| parse_numeric_attr(s.trim()))
1235 .collect();
1236 if !widths.is_empty() {
1237 adf["colwidth"] = serde_json::Value::Array(widths);
1238 }
1239 }
1240 if let Some(local_id) = attrs.get("localId") {
1241 adf["localId"] = serde_json::Value::String(local_id.to_string());
1242 }
1243 adf
1244}
1245
1246fn build_border_marks(attrs: &crate::atlassian::attrs::Attrs) -> Vec<AdfMark> {
1248 let mut marks = Vec::new();
1249 let border_color = attrs.get("border-color");
1250 let border_size = attrs.get("border-size");
1251 if border_color.is_some() || border_size.is_some() {
1252 let color = border_color.unwrap_or("#000000");
1253 let size = border_size.and_then(|s| s.parse::<u32>().ok()).unwrap_or(1);
1254 marks.push(AdfMark::border(color, size));
1255 }
1256 marks
1257}
1258
1259fn iso_date_to_epoch_ms(date_str: &str) -> String {
1262 if date_str.chars().all(|c| c.is_ascii_digit()) {
1264 return date_str.to_string();
1265 }
1266 if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
1267 let epoch_ms = date
1268 .and_hms_opt(0, 0, 0)
1269 .map_or(0, |dt| dt.and_utc().timestamp_millis());
1270 epoch_ms.to_string()
1271 } else {
1272 date_str.to_string()
1274 }
1275}
1276
1277fn epoch_ms_to_iso_date(timestamp: &str) -> String {
1280 if timestamp.contains('-') {
1282 return timestamp.to_string();
1283 }
1284 if let Ok(ms) = timestamp.parse::<i64>() {
1285 let secs = ms / 1000;
1286 if let Some(dt) = chrono::DateTime::from_timestamp(secs, 0) {
1287 return dt.format("%Y-%m-%d").to_string();
1288 }
1289 }
1290 timestamp.to_string()
1292}
1293
1294fn is_block_attrs_line(line: &str) -> bool {
1296 let trimmed = line.trim();
1297 if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
1298 return false;
1299 }
1300 if let Some((_, attrs)) = parse_attrs(trimmed, 0) {
1301 attrs.get("align").is_some()
1303 || attrs.get("indent").is_some()
1304 || attrs.get("breakout").is_some()
1305 || attrs.get("breakoutWidth").is_some()
1306 || attrs.get("localId").is_some()
1307 } else {
1308 false
1309 }
1310}
1311
1312fn parse_decision_items(text: &str) -> Vec<AdfNode> {
1315 let mut items = Vec::new();
1316 for line in text.lines() {
1317 let trimmed = line.trim();
1318 if let Some(rest) = trimmed.strip_prefix("- <> ") {
1319 let inline_nodes = parse_inline(rest);
1320 items.push(AdfNode::decision_item("DECIDED", inline_nodes));
1321 }
1322 }
1323 items
1324}
1325
1326fn try_parse_task_marker(text: &str) -> Option<(&str, &str)> {
1334 if let Some(rest) = strip_task_checkbox(text, "[ ]") {
1335 Some(("TODO", rest))
1336 } else if let Some(rest) =
1337 strip_task_checkbox(text, "[x]").or_else(|| strip_task_checkbox(text, "[X]"))
1338 {
1339 Some(("DONE", rest))
1340 } else {
1341 None
1342 }
1343}
1344
1345fn strip_task_checkbox<'a>(text: &'a str, checkbox: &str) -> Option<&'a str> {
1349 let rest = text.strip_prefix(checkbox)?;
1350 if rest.is_empty() {
1351 Some(rest)
1352 } else {
1353 rest.strip_prefix(' ')
1354 }
1355}
1356
1357fn starts_with_task_marker(s: &str) -> bool {
1365 let after = if let Some(rest) = s.strip_prefix("[ ]") {
1366 rest
1367 } else if let Some(rest) = s.strip_prefix("[x]").or_else(|| s.strip_prefix("[X]")) {
1368 rest
1369 } else {
1370 return false;
1371 };
1372 after.is_empty() || after.starts_with(' ') || after.starts_with('\n')
1373}
1374
1375fn parse_ordered_list_marker(line: &str) -> Option<(u32, &str)> {
1377 let digit_end = line.find(|c: char| !c.is_ascii_digit())?;
1378 if digit_end == 0 {
1379 return None;
1380 }
1381 let rest = &line[digit_end..];
1382 let after_marker = rest.strip_prefix(". ")?;
1383 let num: u32 = line[..digit_end].parse().ok()?;
1384 Some((num, after_marker))
1385}
1386
1387fn has_trailing_hard_break(line: &str) -> bool {
1390 line.ends_with('\\') || line.ends_with(" ")
1391}
1392
1393fn is_block_level_continuation_marker(trimmed: &str) -> bool {
1401 trimmed.starts_with("![") || trimmed.starts_with("```") || trimmed.starts_with(":::")
1402}
1403
1404fn is_list_start(line: &str) -> bool {
1406 let trimmed = line.trim_start();
1407 trimmed.starts_with("- ")
1408 || trimmed.starts_with("* ")
1409 || trimmed.starts_with("+ ")
1410 || parse_ordered_list_marker(trimmed).is_some()
1411}
1412
1413fn escape_emphasis_markers(text: &str) -> String {
1426 escape_emphasis_with(text, false)
1427}
1428
1429fn escape_emphasis_markers_with_underscore(text: &str) -> String {
1437 escape_emphasis_with(text, true)
1438}
1439
1440fn escape_emphasis_with(text: &str, escape_underscore_always: bool) -> String {
1446 let chars: Vec<char> = text.chars().collect();
1447 let mut out = String::with_capacity(text.len());
1448 let mut idx = 0;
1449 while idx < chars.len() {
1450 let ch = chars[idx];
1451 if ch == '*' {
1452 out.push('\\');
1453 out.push(ch);
1454 idx += 1;
1455 } else if ch == '_' {
1456 let run_start = idx;
1460 let mut run_end = idx;
1461 while run_end < chars.len() && chars[run_end] == '_' {
1462 run_end += 1;
1463 }
1464 let escape_run = if escape_underscore_always {
1465 true
1466 } else {
1467 let before_alnum = run_start > 0 && chars[run_start - 1].is_alphanumeric();
1468 let after_alnum = chars.get(run_end).is_some_and(|c| c.is_alphanumeric());
1469 !(before_alnum && after_alnum)
1470 };
1471 for _ in run_start..run_end {
1472 if escape_run {
1473 out.push('\\');
1474 }
1475 out.push('_');
1476 }
1477 idx = run_end;
1478 } else {
1479 out.push(ch);
1480 idx += 1;
1481 }
1482 }
1483 out
1484}
1485
1486fn escape_backticks(text: &str) -> String {
1492 let mut out = String::with_capacity(text.len());
1493 for ch in text.chars() {
1494 if ch == '`' {
1495 out.push('\\');
1496 }
1497 out.push(ch);
1498 }
1499 out
1500}
1501
1502fn inline_code_delimiter(text: &str) -> (usize, bool) {
1510 let mut max_run = 0usize;
1511 let mut current = 0usize;
1512 for ch in text.chars() {
1513 if ch == '`' {
1514 current += 1;
1515 if current > max_run {
1516 max_run = current;
1517 }
1518 } else {
1519 current = 0;
1520 }
1521 }
1522 let n = max_run + 1;
1523 let starts_bt = text.starts_with('`');
1524 let ends_bt = text.ends_with('`');
1525 let starts_sp = text.starts_with(' ');
1526 let ends_sp = text.ends_with(' ');
1527 let all_sp = !text.is_empty() && text.chars().all(|c| c == ' ');
1528 let needs_pad = starts_bt || ends_bt || (starts_sp && ends_sp && !all_sp);
1529 (n, needs_pad)
1530}
1531
1532fn render_inline_code(text: &str, output: &mut String) {
1535 let (n, pad) = inline_code_delimiter(text);
1536 for _ in 0..n {
1537 output.push('`');
1538 }
1539 if pad {
1540 output.push(' ');
1541 }
1542 output.push_str(text);
1543 if pad {
1544 output.push(' ');
1545 }
1546 for _ in 0..n {
1547 output.push('`');
1548 }
1549}
1550
1551fn escape_pipes_in_cell(text: &str) -> String {
1558 let mut out = String::with_capacity(text.len());
1559 for ch in text.chars() {
1560 if ch == '|' {
1561 out.push('\\');
1562 }
1563 out.push(ch);
1564 }
1565 out
1566}
1567
1568fn escape_link_brackets(text: &str) -> String {
1573 let mut out = String::with_capacity(text.len());
1574 for ch in text.chars() {
1575 if ch == '[' || ch == ']' {
1576 out.push('\\');
1577 }
1578 out.push(ch);
1579 }
1580 out
1581}
1582
1583fn escape_bare_urls(text: &str) -> String {
1589 let mut result = String::with_capacity(text.len());
1590 for (i, ch) in text.char_indices() {
1591 if ch == 'h' {
1592 let rest = &text[i..];
1593 if rest.starts_with("http://") || rest.starts_with("https://") {
1594 result.push('\\');
1595 }
1596 }
1597 result.push(ch);
1598 }
1599 result
1600}
1601
1602fn url_safe_in_bracket_content(s: &str) -> bool {
1611 if s.contains('\n') {
1612 return false;
1613 }
1614 let mut depth: i32 = 1;
1615 for ch in s.chars() {
1616 match ch {
1617 '[' => depth += 1,
1618 ']' => {
1619 depth -= 1;
1620 if depth == 0 {
1621 return false;
1622 }
1623 }
1624 _ => {}
1625 }
1626 }
1627 true
1628}
1629
1630fn escape_emoji_shortcodes(text: &str) -> String {
1641 let mut result = String::with_capacity(text.len());
1642
1643 for (i, ch) in text.char_indices() {
1644 if ch == ':' {
1645 let after = i + 1;
1648 if after < text.len() {
1649 let name_end = text[after..]
1650 .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')
1651 .map_or(text[after..].len(), |pos| pos);
1652 if name_end > 0
1653 && after + name_end < text.len()
1654 && text.as_bytes()[after + name_end] == b':'
1655 {
1656 result.push('\\');
1658 }
1659 }
1660 }
1661 result.push(ch);
1662 }
1663
1664 result
1665}
1666
1667fn escape_list_marker(line: &str) -> String {
1671 if let Some(dot_pos) = line.find(". ") {
1672 if parse_ordered_list_marker(line).is_some() {
1673 let mut s = String::with_capacity(line.len() + 1);
1674 s.push_str(&line[..dot_pos]);
1675 s.push('\\');
1676 s.push_str(&line[dot_pos..]);
1677 return s;
1678 }
1679 }
1680 for prefix in &["- ", "* ", "+ "] {
1681 if line.starts_with(prefix) {
1682 let mut s = String::with_capacity(line.len() + 1);
1683 s.push('\\');
1684 s.push_str(line);
1685 return s;
1686 }
1687 }
1688 line.to_string()
1689}
1690
1691fn is_code_fence_opener(line: &str) -> bool {
1698 if !line.starts_with("```") {
1699 return false;
1700 }
1701 !line[3..].contains('`')
1702}
1703
1704fn is_horizontal_rule(line: &str) -> bool {
1706 let trimmed = line.trim();
1707 trimmed.len() >= 3
1708 && ((trimmed.starts_with("---") && trimmed.chars().all(|c| c == '-'))
1709 || (trimmed.starts_with("***") && trimmed.chars().all(|c| c == '*'))
1710 || (trimmed.starts_with("___") && trimmed.chars().all(|c| c == '_')))
1711}
1712
1713fn is_table_separator(line: &str) -> bool {
1715 let trimmed = line.trim();
1716 trimmed.contains('|')
1717 && trimmed
1718 .chars()
1719 .all(|c| c == '|' || c == '-' || c == ':' || c == ' ')
1720}
1721
1722fn parse_table_row(line: &str) -> Vec<String> {
1729 let trimmed = line.trim();
1730 let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1731 let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1732
1733 let mut cells: Vec<String> = Vec::new();
1734 let mut current = String::new();
1735 let mut chars = trimmed.chars().peekable();
1736 while let Some(ch) = chars.next() {
1737 if ch == '\\' && chars.peek() == Some(&'|') {
1738 current.push('|');
1739 chars.next();
1740 } else if ch == '|' {
1741 cells.push(std::mem::take(&mut current));
1742 } else {
1743 current.push(ch);
1744 }
1745 }
1746 cells.push(current);
1747
1748 cells
1749 .iter()
1750 .map(|s| {
1751 let stripped = s.strip_prefix(' ').unwrap_or(s.as_str());
1754 let stripped = stripped.strip_suffix(' ').unwrap_or(stripped);
1755 stripped.to_string()
1756 })
1757 .collect()
1758}
1759
1760fn parse_table_alignments(separator_line: &str) -> Vec<Option<&'static str>> {
1763 let trimmed = separator_line.trim();
1764 let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1765 let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1766
1767 trimmed
1768 .split('|')
1769 .map(|cell| {
1770 let cell = cell.trim();
1771 let starts_colon = cell.starts_with(':');
1772 let ends_colon = cell.ends_with(':');
1773 match (starts_colon, ends_colon) {
1774 (true, true) => Some("center"),
1775 (false, true) => Some("end"),
1776 _ => None, }
1778 })
1779 .collect()
1780}
1781
1782fn apply_column_alignment(para: &mut AdfNode, alignment: Option<&str>) {
1784 if let Some(align) = alignment {
1785 para.marks = Some(vec![AdfMark::alignment(align)]);
1786 }
1787}
1788
1789fn extract_cell_attrs(cell_text: &str) -> (String, Option<serde_json::Value>) {
1792 let trimmed = cell_text.trim_start();
1793 if !trimmed.starts_with('{') {
1794 return (cell_text.to_string(), None);
1795 }
1796 if let Some((end_pos, attrs)) = parse_attrs(trimmed, 0) {
1797 let remaining = trimmed[end_pos..].trim_start().to_string();
1798 let adf_attrs = build_cell_attrs(&attrs);
1799 (remaining, Some(adf_attrs))
1800 } else {
1801 (cell_text.to_string(), None)
1802 }
1803}
1804
1805fn try_parse_media_single_from_line(line: &str) -> Option<AdfNode> {
1808 let line = line.trim();
1809 if !line.starts_with("? + 1; let img_end = find_closing_paren(line, paren_open)? + 1;
1818 let after_img = line[img_end..].trim_start();
1819
1820 if after_img.starts_with('{') {
1821 if let Some((_, attrs)) = parse_attrs(after_img, 0) {
1822 if attrs.get("type") == Some("file") || attrs.get("id").is_some() {
1824 let mut media_attrs = serde_json::json!({"type": "file"});
1825 if let Some(id) = attrs.get("id") {
1826 media_attrs["id"] = serde_json::Value::String(id.to_string());
1827 }
1828 if let Some(collection) = attrs.get("collection") {
1829 media_attrs["collection"] = serde_json::Value::String(collection.to_string());
1830 }
1831 if let Some(occurrence_key) = attrs.get("occurrenceKey") {
1832 media_attrs["occurrenceKey"] =
1833 serde_json::Value::String(occurrence_key.to_string());
1834 }
1835 if let Some(height) = attrs.get("height") {
1836 if let Some(h) = parse_numeric_attr(height) {
1837 media_attrs["height"] = h;
1838 }
1839 }
1840 if let Some(width) = attrs.get("width") {
1841 if let Some(w) = parse_numeric_attr(width) {
1842 media_attrs["width"] = w;
1843 }
1844 }
1845 if let Some(alt_text) = alt_opt {
1846 media_attrs["alt"] = serde_json::Value::String(alt_text.to_string());
1847 }
1848 if let Some(local_id) = attrs.get("localId") {
1849 media_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1850 }
1851 let mut ms_attrs = serde_json::json!({"layout": "center"});
1852 if let Some(layout) = attrs.get("layout") {
1853 ms_attrs["layout"] = serde_json::Value::String(layout.to_string());
1854 }
1855 if let Some(ms_width) = attrs.get("mediaWidth") {
1856 if let Some(w) = parse_numeric_attr(ms_width) {
1857 ms_attrs["width"] = w;
1858 }
1859 }
1860 if let Some(wt) = attrs.get("widthType") {
1861 ms_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1862 }
1863 if let Some(mode) = attrs.get("mode") {
1864 ms_attrs["mode"] = serde_json::Value::String(mode.to_string());
1865 }
1866 let border_marks = build_border_marks(&attrs);
1867 let media_marks = if border_marks.is_empty() {
1868 None
1869 } else {
1870 Some(border_marks)
1871 };
1872 return Some(AdfNode {
1873 node_type: "mediaSingle".to_string(),
1874 attrs: Some(ms_attrs),
1875 content: Some(vec![AdfNode {
1876 node_type: "media".to_string(),
1877 attrs: Some(media_attrs),
1878 content: None,
1879 text: None,
1880 marks: media_marks,
1881 local_id: None,
1882 parameters: None,
1883 }]),
1884 text: None,
1885 marks: None,
1886 local_id: None,
1887 parameters: None,
1888 });
1889 }
1890
1891 let mut node = AdfNode::media_single(url, alt_opt);
1893 if let Some(ref mut node_attrs) = node.attrs {
1894 if let Some(layout) = attrs.get("layout") {
1895 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
1896 }
1897 if let Some(width) = attrs.get("width") {
1898 if let Some(w) = parse_numeric_attr(width) {
1899 node_attrs["width"] = w;
1900 }
1901 }
1902 if let Some(wt) = attrs.get("widthType") {
1903 node_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1904 }
1905 if let Some(mode) = attrs.get("mode") {
1906 node_attrs["mode"] = serde_json::Value::String(mode.to_string());
1907 }
1908 }
1909 if let Some(ref mut content) = node.content {
1910 if let Some(media) = content.first_mut() {
1911 if let Some(local_id) = attrs.get("localId") {
1912 if let Some(ref mut media_attrs) = media.attrs {
1913 media_attrs["localId"] =
1914 serde_json::Value::String(local_id.to_string());
1915 }
1916 }
1917 let border_marks = build_border_marks(&attrs);
1918 if !border_marks.is_empty() {
1919 media.marks = Some(border_marks);
1920 }
1921 }
1922 }
1923 return Some(node);
1924 }
1925 }
1926
1927 Some(AdfNode::media_single(url, alt_opt))
1928}
1929
1930fn parse_image_syntax(line: &str) -> Option<(&str, &str)> {
1932 let line = line.trim();
1933 if !line.starts_with("?;
1938 let alt = &line[2..alt_end];
1939 let paren_start = alt_end + 1; let url_end = find_closing_paren(line, paren_start)?;
1941 let url = &line[paren_start + 1..url_end];
1942
1943 Some((alt, url))
1944}
1945
1946fn parse_inline(text: &str) -> Vec<AdfNode> {
1954 parse_inline_impl(text, true, 0)
1955}
1956
1957fn parse_inline_impl(text: &str, auto_inline_card: bool, depth: usize) -> Vec<AdfNode> {
1972 if depth > MAX_NESTING_DEPTH {
1973 return if text.is_empty() {
1974 Vec::new()
1975 } else {
1976 vec![AdfNode::text(text)]
1977 };
1978 }
1979
1980 let mut nodes = Vec::new();
1981 let mut chars = text.char_indices().peekable();
1982 let mut plain_start = 0;
1983
1984 while let Some(&(i, ch)) = chars.peek() {
1985 match ch {
1986 '*' | '_' => {
1987 if let Some((end, content, is_bold)) = try_parse_emphasis(text, i) {
1988 flush_plain(text, plain_start, i, &mut nodes);
1989 let mark = if is_bold {
1990 AdfMark::strong()
1991 } else {
1992 AdfMark::em()
1993 };
1994 let inner = parse_inline_impl(content, false, depth + 1);
1995 for mut node in inner {
1996 prepend_mark(&mut node, mark.clone());
1997 nodes.push(node);
1998 }
1999 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2001 chars.next();
2002 }
2003 plain_start = end;
2004 continue;
2005 }
2006 if ch == '_' {
2011 while chars.peek().is_some_and(|&(_, c)| c == '_') {
2012 chars.next();
2013 }
2014 } else {
2015 chars.next();
2016 }
2017 }
2018 '~' => {
2019 if let Some((end, content)) = try_parse_strikethrough(text, i) {
2020 flush_plain(text, plain_start, i, &mut nodes);
2021 let inner = parse_inline_impl(content, false, depth + 1);
2022 for mut node in inner {
2023 prepend_mark(&mut node, AdfMark::strike());
2024 nodes.push(node);
2025 }
2026 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2027 chars.next();
2028 }
2029 plain_start = end;
2030 continue;
2031 }
2032 chars.next();
2033 }
2034 '`' => {
2035 if let Some((end, content)) = try_parse_inline_code(text, i) {
2036 flush_plain(text, plain_start, i, &mut nodes);
2037 nodes.push(AdfNode::text_with_marks(content, vec![AdfMark::code()]));
2038 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2039 chars.next();
2040 }
2041 plain_start = end;
2042 continue;
2043 }
2044 while chars.peek().is_some_and(|&(_, c)| c == '`') {
2047 chars.next();
2048 }
2049 }
2050 '[' => {
2051 if let Some((end, link_text, href)) = try_parse_link(text, i) {
2052 flush_plain(text, plain_start, i, &mut nodes);
2053 if link_text.starts_with("http://") || link_text.starts_with("https://") {
2054 nodes.push(AdfNode::text_with_marks(
2059 link_text,
2060 vec![AdfMark::link(href)],
2061 ));
2062 } else {
2063 let inner = parse_inline_impl(link_text, false, depth + 1);
2064 for mut node in inner {
2065 prepend_mark(&mut node, AdfMark::link(href));
2066 nodes.push(node);
2067 }
2068 }
2069 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2070 chars.next();
2071 }
2072 plain_start = end;
2073 continue;
2074 }
2075 if let Some((end, span_nodes)) = try_parse_bracketed_span(text, i, depth) {
2077 flush_plain(text, plain_start, i, &mut nodes);
2078 nodes.extend(span_nodes);
2079 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2080 chars.next();
2081 }
2082 plain_start = end;
2083 continue;
2084 }
2085 chars.next();
2086 }
2087 ':' => {
2088 if let Some(node) = try_dispatch_inline_directive(text, i, depth) {
2090 flush_plain(text, plain_start, i, &mut nodes);
2091 let end = node.1;
2092 nodes.push(node.0);
2093 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2094 chars.next();
2095 }
2096 plain_start = end;
2097 continue;
2098 }
2099 if let Some((end, short_name)) = try_parse_emoji_shortcode(text, i) {
2101 flush_plain(text, plain_start, i, &mut nodes);
2102 let (final_end, emoji_node) = parse_emoji_with_attrs(text, end, short_name);
2103 nodes.push(emoji_node);
2104 while chars.peek().is_some_and(|&(idx, _)| idx < final_end) {
2105 chars.next();
2106 }
2107 plain_start = final_end;
2108 continue;
2109 }
2110 chars.next();
2111 }
2112 ' ' if text[i..].starts_with(" \n") => {
2113 flush_plain(text, plain_start, i, &mut nodes);
2116 nodes.push(AdfNode::hard_break());
2117 while chars.peek().is_some_and(|&(_, c)| c == ' ') {
2119 chars.next();
2120 }
2121 if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2123 chars.next();
2124 }
2125 plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2126 }
2127 '!' if text[i..].starts_with("![") => {
2128 chars.next();
2132 }
2133 'h' if auto_inline_card
2134 && (text[i..].starts_with("http://") || text[i..].starts_with("https://")) =>
2135 {
2136 if let Some((end, url)) = try_parse_bare_url(text, i) {
2137 flush_plain(text, plain_start, i, &mut nodes);
2138 nodes.push(AdfNode::inline_card(url));
2139 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2140 chars.next();
2141 }
2142 plain_start = end;
2143 continue;
2144 }
2145 chars.next();
2146 }
2147 '\\' if text.as_bytes().get(i + 1) == Some(&b'n')
2148 && text.as_bytes().get(i + 2) != Some(&b'\n') =>
2149 {
2150 flush_plain(text, plain_start, i, &mut nodes);
2154 nodes.push(AdfNode::text("\n"));
2155 chars.next(); chars.next(); plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2158 }
2159 '\\' if i + 1 < text.len() && !text[i..].starts_with("\\\n") => {
2160 flush_plain(text, plain_start, i, &mut nodes);
2165 chars.next(); plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2170 chars.next(); }
2172 '\\' if text[i..].starts_with("\\\n") => {
2173 flush_plain(text, plain_start, i, &mut nodes);
2175 nodes.push(AdfNode::hard_break());
2176 chars.next(); if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2179 chars.next();
2180 }
2181 plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2182 }
2183 '\\' if i + 1 == text.len() => {
2184 flush_plain(text, plain_start, i, &mut nodes);
2186 nodes.push(AdfNode::hard_break());
2187 chars.next(); plain_start = text.len();
2189 }
2190 _ => {
2191 chars.next();
2192 }
2193 }
2194 }
2195
2196 if plain_start < text.len() {
2198 let remaining = &text[plain_start..];
2199 if !remaining.is_empty() {
2200 nodes.push(AdfNode::text(remaining));
2201 }
2202 }
2203
2204 merge_adjacent_text(&mut nodes);
2207
2208 nodes
2209}
2210
2211fn merge_adjacent_text(nodes: &mut Vec<AdfNode>) {
2213 let mut i = 0;
2214 while i + 1 < nodes.len() {
2215 if nodes[i].node_type == "text"
2216 && nodes[i + 1].node_type == "text"
2217 && nodes[i].marks.is_none()
2218 && nodes[i + 1].marks.is_none()
2219 {
2220 let next_text = nodes[i + 1].text.clone().unwrap_or_default();
2221 if let Some(ref mut t) = nodes[i].text {
2222 t.push_str(&next_text);
2223 }
2224 nodes.remove(i + 1);
2225 } else {
2226 i += 1;
2227 }
2228 }
2229}
2230
2231fn flush_plain(text: &str, start: usize, end: usize, nodes: &mut Vec<AdfNode>) {
2233 if start < end {
2234 let plain = &text[start..end];
2235 if !plain.is_empty() {
2236 nodes.push(AdfNode::text(plain));
2237 }
2238 }
2239}
2240
2241#[cfg(test)]
2243fn add_mark(node: &mut AdfNode, mark: AdfMark) {
2244 if let Some(ref mut marks) = node.marks {
2245 marks.push(mark);
2246 } else {
2247 node.marks = Some(vec![mark]);
2248 }
2249}
2250
2251fn strip_heading_code_marks(nodes: &mut [AdfNode]) -> usize {
2262 let mut removed = 0;
2263 for node in nodes.iter_mut() {
2264 if let Some(marks) = node.marks.as_mut() {
2265 let before = marks.len();
2266 marks.retain(|m| m.mark_type != "code");
2267 removed += before - marks.len();
2268 if marks.is_empty() {
2269 node.marks = None;
2270 }
2271 }
2272 if let Some(children) = node.content.as_mut() {
2273 removed += strip_heading_code_marks(children);
2274 }
2275 }
2276 removed
2277}
2278
2279const EMPHASIS_MARKS_SPLIT_FROM_CODE: [&str; 3] = ["strong", "em", "strike"];
2285
2286fn split_emphasis_code_marks(nodes: &mut [AdfNode]) -> usize {
2299 let mut dropped = 0;
2300 for node in nodes.iter_mut() {
2301 if let Some(marks) = node.marks.as_mut() {
2302 if node.text.is_some() && marks.iter().any(|m| m.mark_type == "code") {
2303 let before = marks.len();
2304 marks.retain(|m| !EMPHASIS_MARKS_SPLIT_FROM_CODE.contains(&m.mark_type.as_str()));
2305 dropped += before - marks.len();
2306 }
2307 }
2308 if let Some(children) = node.content.as_mut() {
2309 dropped += split_emphasis_code_marks(children);
2310 }
2311 }
2312 dropped
2313}
2314
2315fn prepend_mark(node: &mut AdfNode, mark: AdfMark) {
2317 if let Some(ref mut marks) = node.marks {
2318 marks.insert(0, mark);
2319 } else {
2320 node.marks = Some(vec![mark]);
2321 }
2322}
2323
2324fn is_intraword_underscore(text: &str, delim_pos: usize, len: usize) -> bool {
2329 let before = text[..delim_pos]
2330 .chars()
2331 .next_back()
2332 .is_some_and(char::is_alphanumeric);
2333 let after = text[delim_pos + len..]
2334 .chars()
2335 .next()
2336 .is_some_and(char::is_alphanumeric);
2337 before && after
2338}
2339
2340fn find_unescaped(haystack: &str, needle: &str) -> Option<usize> {
2344 let needle_bytes = needle.as_bytes();
2345 let hay_bytes = haystack.as_bytes();
2346 let mut i = 0;
2347 while i < hay_bytes.len() {
2348 if hay_bytes[i] == b'\\' {
2349 i += 2; continue;
2351 }
2352 if hay_bytes[i..].starts_with(needle_bytes) {
2353 return Some(i);
2354 }
2355 i += 1;
2356 }
2357 None
2358}
2359
2360fn find_unescaped_char(haystack: &str, ch: u8) -> Option<usize> {
2363 let hay_bytes = haystack.as_bytes();
2364 let mut i = 0;
2365 while i < hay_bytes.len() {
2366 if hay_bytes[i] == b'\\' {
2367 i += 2;
2368 continue;
2369 }
2370 if hay_bytes[i] == ch {
2371 return Some(i);
2372 }
2373 i += 1;
2374 }
2375 None
2376}
2377
2378fn try_parse_emphasis(text: &str, i: usize) -> Option<(usize, &str, bool)> {
2388 let rest = &text[i..];
2389
2390 if rest.starts_with("***") || rest.starts_with("___") {
2394 let is_underscore = rest.starts_with("___");
2395 if is_underscore && is_intraword_underscore(text, i, 3) {
2396 return None;
2397 }
2398 let triple = &rest[..3];
2399 let after = &rest[3..];
2400 if let Some(close) = find_unescaped(after, triple) {
2401 if close > 0 {
2402 let close_pos = i + 3 + close;
2403 if is_underscore && is_intraword_underscore(text, close_pos, 3) {
2404 return None;
2405 }
2406 let content = &rest[2..=3 + close];
2410 let end = i + 3 + close + 3;
2411 return Some((end, content, true));
2412 }
2413 }
2414 }
2415
2416 if rest.starts_with("**") || rest.starts_with("__") {
2418 let is_underscore = rest.starts_with("__");
2419 if is_underscore && is_intraword_underscore(text, i, 2) {
2420 return None;
2421 }
2422 let delimiter = &rest[..2];
2423 let after = &rest[2..];
2424 let close = find_unescaped(after, delimiter)?;
2425 if close == 0 {
2426 return None;
2427 }
2428 let close_pos = i + 2 + close;
2429 if is_underscore && is_intraword_underscore(text, close_pos, 2) {
2430 return None;
2431 }
2432 let content = &after[..close];
2433 let end = i + 2 + close + 2;
2434 return Some((end, content, true));
2435 }
2436
2437 if rest.starts_with('*') || rest.starts_with('_') {
2439 let delim_char = rest.as_bytes()[0];
2440 let is_underscore = delim_char == b'_';
2441 if is_underscore && is_intraword_underscore(text, i, 1) {
2442 return None;
2443 }
2444 let after = &rest[1..];
2445 let close = find_unescaped_char(after, delim_char)?;
2446 if close == 0 {
2447 return None;
2448 }
2449 let close_pos = i + 1 + close;
2450 if is_underscore && is_intraword_underscore(text, close_pos, 1) {
2451 return None;
2452 }
2453 let content = &after[..close];
2454 let end = i + 1 + close + 1;
2455 return Some((end, content, false));
2456 }
2457
2458 None
2459}
2460
2461fn try_parse_strikethrough(text: &str, i: usize) -> Option<(usize, &str)> {
2463 let rest = &text[i..];
2464 if !rest.starts_with("~~") {
2465 return None;
2466 }
2467 let after = &rest[2..];
2468 let close = after.find("~~")?;
2469 if close == 0 {
2470 return None;
2471 }
2472 let content = &after[..close];
2473 Some((i + 2 + close + 2, content))
2474}
2475
2476fn try_parse_inline_code(text: &str, i: usize) -> Option<(usize, &str)> {
2483 let rest = &text[i..];
2484 let bytes = rest.as_bytes();
2485 if bytes.is_empty() || bytes[0] != b'`' {
2486 return None;
2487 }
2488 let mut opening = 0usize;
2489 while opening < bytes.len() && bytes[opening] == b'`' {
2490 opening += 1;
2491 }
2492
2493 let mut j = opening;
2494 while j < bytes.len() {
2495 if bytes[j] == b'`' {
2496 let run_start = j;
2497 while j < bytes.len() && bytes[j] == b'`' {
2498 j += 1;
2499 }
2500 if j - run_start == opening {
2501 let content = &rest[opening..run_start];
2502 let content = strip_code_span_padding(content);
2503 return Some((i + j, content));
2504 }
2505 } else {
2506 j += 1;
2507 }
2508 }
2509 None
2510}
2511
2512fn strip_code_span_padding(content: &str) -> &str {
2516 let bytes = content.as_bytes();
2517 if bytes.len() >= 2
2518 && bytes[0] == b' '
2519 && bytes[bytes.len() - 1] == b' '
2520 && content.bytes().any(|b| b != b' ')
2521 {
2522 &content[1..content.len() - 1]
2523 } else {
2524 content
2525 }
2526}
2527
2528fn try_parse_bracketed_span(text: &str, i: usize, depth: usize) -> Option<(usize, Vec<AdfNode>)> {
2532 let rest = &text[i..];
2533 if !rest.starts_with('[') {
2534 return None;
2535 }
2536
2537 let mut bracket_depth: usize = 0;
2541 let mut bracket_close = None;
2542 let bs_bytes = rest.as_bytes();
2543 for (j, ch) in rest.char_indices() {
2544 match ch {
2545 '\\' if j + 1 < bs_bytes.len()
2546 && (bs_bytes[j + 1] == b'[' || bs_bytes[j + 1] == b']') => {}
2547 '[' if j == 0 || bs_bytes[j - 1] != b'\\' => bracket_depth += 1,
2548 ']' if j == 0 || bs_bytes[j - 1] != b'\\' => {
2549 bracket_depth -= 1;
2550 if bracket_depth == 0 {
2551 bracket_close = Some(j);
2552 break;
2553 }
2554 }
2555 _ => {}
2556 }
2557 }
2558 let bracket_close = bracket_close?;
2559 let after_bracket = &rest[bracket_close + 1..];
2561 if !after_bracket.starts_with('{') {
2562 return None;
2563 }
2564
2565 let span_text = &rest[1..bracket_close];
2566 let attrs_start = i + bracket_close + 1;
2567 let (attrs_end, attrs) = parse_attrs(text, attrs_start)?;
2568
2569 let mut marks = Vec::new();
2570 if attrs.has_flag("underline") {
2571 marks.push(AdfMark::underline());
2572 }
2573 let ann_ids = attrs.get_all("annotation-id");
2574 let ann_types = attrs.get_all("annotation-type");
2575 for (idx, ann_id) in ann_ids.iter().enumerate() {
2576 let ann_type = ann_types.get(idx).copied().unwrap_or("inlineComment");
2577 marks.push(AdfMark::annotation(ann_id, ann_type));
2578 }
2579
2580 if marks.is_empty() {
2581 return None; }
2583
2584 let inner = parse_inline_impl(span_text, false, depth + 1);
2585 let result: Vec<AdfNode> = inner
2586 .into_iter()
2587 .map(|mut node| {
2588 let mut combined = marks.clone();
2591 if let Some(ref existing) = node.marks {
2592 combined.extend(existing.iter().cloned());
2593 }
2594 node.marks = Some(combined);
2595 node
2596 })
2597 .collect();
2598
2599 Some((attrs_end, result))
2600}
2601
2602fn try_dispatch_inline_directive(text: &str, pos: usize, depth: usize) -> Option<(AdfNode, usize)> {
2606 let d = try_parse_inline_directive(text, pos)?;
2607 let content = d.content.as_deref().unwrap_or("");
2608
2609 let node = match d.name.as_str() {
2610 "card" => {
2611 let url = d
2616 .attrs
2617 .as_ref()
2618 .and_then(|a| a.get("url"))
2619 .unwrap_or(content);
2620 let mut node = AdfNode::inline_card(url);
2621 pass_through_local_id(&d.attrs, &mut node);
2622 node
2623 }
2624 "status" => {
2625 let color = d
2626 .attrs
2627 .as_ref()
2628 .and_then(|a| a.get("color"))
2629 .unwrap_or("neutral");
2630 let mut node = AdfNode::status(content, color);
2631 if let Some(ref attrs) = d.attrs {
2633 if let Some(ref mut node_attrs) = node.attrs {
2634 if let Some(style) = attrs.get("style") {
2635 node_attrs["style"] = serde_json::Value::String(style.to_string());
2636 }
2637 if let Some(local_id) = attrs.get("localId") {
2638 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2639 }
2640 }
2641 }
2642 node
2643 }
2644 "date" => {
2645 let timestamp = d
2646 .attrs
2647 .as_ref()
2648 .and_then(|a| a.get("timestamp"))
2649 .map_or_else(|| iso_date_to_epoch_ms(content), ToString::to_string);
2650 let mut node = AdfNode::date(×tamp);
2651 pass_through_local_id(&d.attrs, &mut node);
2652 node
2653 }
2654 "mention" => {
2655 let id = d.attrs.as_ref().and_then(|a| a.get("id")).unwrap_or("");
2656 let mut node = AdfNode::mention(id, content);
2657 if let Some(ref attrs) = d.attrs {
2659 if let (Some(ref mut node_attrs), true) = (
2660 &mut node.attrs,
2661 attrs.get("userType").is_some() || attrs.get("accessLevel").is_some(),
2662 ) {
2663 if let Some(ut) = attrs.get("userType") {
2664 node_attrs["userType"] = serde_json::Value::String(ut.to_string());
2665 }
2666 if let Some(al) = attrs.get("accessLevel") {
2667 node_attrs["accessLevel"] = serde_json::Value::String(al.to_string());
2668 }
2669 }
2670 }
2671 pass_through_local_id(&d.attrs, &mut node);
2672 node
2673 }
2674 "span" => {
2675 let mut marks = Vec::new();
2676 if let Some(ref attrs) = d.attrs {
2677 if let Some(color) = attrs.get("color") {
2678 marks.push(AdfMark::text_color(color));
2679 }
2680 if let Some(bg) = attrs.get("bg") {
2681 marks.push(AdfMark::background_color(bg));
2682 }
2683 if attrs.has_flag("sub") {
2684 marks.push(AdfMark::subsup("sub"));
2685 }
2686 if attrs.has_flag("sup") {
2687 marks.push(AdfMark::subsup("sup"));
2688 }
2689 }
2690 if marks.is_empty() {
2691 AdfNode::text(content)
2692 } else {
2693 let inner = parse_inline_impl(content, false, depth + 1);
2696 let mut nodes: Vec<AdfNode> = inner
2697 .into_iter()
2698 .map(|mut node| {
2699 let mut combined = marks.clone();
2700 if let Some(ref existing) = node.marks {
2701 combined.extend(existing.iter().cloned());
2702 }
2703 node.marks = Some(combined);
2704 node
2705 })
2706 .collect();
2707 nodes.remove(0)
2709 }
2710 }
2711 "placeholder" => AdfNode::placeholder(content),
2712 "media-inline" => {
2713 let mut json_attrs = serde_json::Map::new();
2714 if let Some(ref attrs) = d.attrs {
2715 for key in &["type", "id", "collection", "url", "alt", "width", "height"] {
2716 if let Some(val) = attrs.get(key) {
2717 if *key == "width" || *key == "height" {
2718 if let Ok(n) = val.parse::<u64>() {
2719 json_attrs.insert(
2720 (*key).to_string(),
2721 serde_json::Value::Number(n.into()),
2722 );
2723 continue;
2724 }
2725 }
2726 json_attrs.insert(
2727 (*key).to_string(),
2728 serde_json::Value::String(val.to_string()),
2729 );
2730 }
2731 }
2732 if let Some(local_id) = attrs.get("localId") {
2733 json_attrs.insert(
2734 "localId".to_string(),
2735 serde_json::Value::String(local_id.to_string()),
2736 );
2737 }
2738 }
2739 AdfNode::media_inline(serde_json::Value::Object(json_attrs))
2740 }
2741 "extension" => {
2742 let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
2743 let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
2744 AdfNode::inline_extension(ext_type, ext_key, Some(content))
2745 }
2746 "adf-unsupported" => {
2747 let json = d.attrs.as_ref().and_then(|a| a.get("json"))?;
2755 serde_json::from_str::<AdfNode>(json).ok()?
2756 }
2757 _ => return None, };
2759
2760 Some((node, d.end_pos))
2761}
2762
2763fn try_parse_bare_url(text: &str, i: usize) -> Option<(usize, &str)> {
2766 let rest = &text[i..];
2767 if !rest.starts_with("http://") && !rest.starts_with("https://") {
2768 return None;
2769 }
2770 let end = rest
2772 .find(|c: char| c.is_whitespace() || c == ')' || c == ']' || c == '>')
2773 .unwrap_or(rest.len());
2774 let url = rest[..end].trim_end_matches(['.', ',', ';', '!', '?']);
2776 if url.len() <= "https://".len() {
2777 return None; }
2779 Some((i + url.len(), url))
2780}
2781
2782fn try_parse_emoji_shortcode(text: &str, i: usize) -> Option<(usize, &str)> {
2785 let rest = &text[i..];
2786 if !rest.starts_with(':') {
2787 return None;
2788 }
2789 let after = &rest[1..];
2790 let name_end =
2791 after.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')?;
2792 if name_end == 0 {
2793 return None;
2794 }
2795 if after.as_bytes().get(name_end) != Some(&b':') {
2796 return None;
2797 }
2798 let name = &after[..name_end];
2799 Some((i + 1 + name_end + 1, name))
2800}
2801
2802fn parse_emoji_with_attrs(text: &str, shortcode_end: usize, short_name: &str) -> (usize, AdfNode) {
2805 let mut chain_end = shortcode_end;
2810 while let Some((next_end, _)) = try_parse_emoji_shortcode(text, chain_end) {
2811 chain_end = next_end;
2812 }
2813 if chain_end > shortcode_end {
2814 if let Some((attr_end, attrs)) = parse_attrs(text, chain_end) {
2815 return (attr_end, build_emoji_node(&attrs, short_name));
2816 }
2817 }
2818
2819 if let Some((attr_end, attrs)) = parse_attrs(text, shortcode_end) {
2820 (attr_end, build_emoji_node(&attrs, short_name))
2821 } else {
2822 (shortcode_end, AdfNode::emoji(&format!(":{short_name}:")))
2823 }
2824}
2825
2826fn build_emoji_node(attrs: &Attrs, short_name: &str) -> AdfNode {
2829 let resolved_name = attrs
2830 .get("shortName")
2831 .map_or_else(|| format!(":{short_name}:"), str::to_string);
2832 let mut emoji_attrs = serde_json::json!({ "shortName": resolved_name });
2833 if let Some(id) = attrs.get("id") {
2834 emoji_attrs["id"] = serde_json::Value::String(id.to_string());
2835 }
2836 if let Some(t) = attrs.get("text") {
2837 emoji_attrs["text"] = serde_json::Value::String(t.to_string());
2838 }
2839 if let Some(lid) = attrs.get("localId") {
2840 emoji_attrs["localId"] = serde_json::Value::String(lid.to_string());
2841 }
2842 AdfNode {
2843 node_type: "emoji".to_string(),
2844 attrs: Some(emoji_attrs),
2845 content: None,
2846 text: None,
2847 marks: None,
2848 local_id: None,
2849 parameters: None,
2850 }
2851}
2852
2853fn find_closing_paren(s: &str, open: usize) -> Option<usize> {
2858 let mut depth: usize = 0;
2859 for (j, ch) in s[open..].char_indices() {
2860 match ch {
2861 '(' => depth += 1,
2862 ')' => {
2863 depth -= 1;
2864 if depth == 0 {
2865 return Some(open + j);
2866 }
2867 }
2868 _ => {}
2869 }
2870 }
2871 None
2872}
2873
2874fn try_parse_link(text: &str, i: usize) -> Option<(usize, &str, &str)> {
2880 let rest = &text[i..];
2881 if !rest.starts_with('[') {
2882 return None;
2883 }
2884
2885 let mut depth: usize = 0;
2887 let mut text_end = None;
2888 let bytes = rest.as_bytes();
2889 for (j, ch) in rest.char_indices() {
2890 match ch {
2891 '\\' if j + 1 < bytes.len() && (bytes[j + 1] == b'[' || bytes[j + 1] == b']') => {
2892 }
2894 '[' if j == 0 || bytes[j - 1] != b'\\' => depth += 1,
2895 ']' if j == 0 || bytes[j - 1] != b'\\' => {
2896 depth -= 1;
2897 if depth == 0 {
2898 text_end = Some(j);
2899 break;
2900 }
2901 }
2902 _ => {}
2903 }
2904 }
2905
2906 let text_end = text_end?;
2907 let link_text = &rest[1..text_end];
2908 let after_bracket = &rest[text_end + 1..];
2910 if !after_bracket.starts_with('(') {
2911 return None;
2912 }
2913 let url_start = text_end + 1; let url_end = find_closing_paren(rest, url_start)?;
2915 let href = &rest[url_start + 1..url_end];
2916
2917 Some((i + url_end + 1, link_text, href))
2918}
2919
2920#[derive(Debug, Clone, Default)]
2924pub struct RenderOptions {
2925 pub strip_local_ids: bool,
2927}
2928
2929pub fn adf_to_markdown(doc: &AdfDocument) -> Result<String> {
2931 adf_to_markdown_with_options(doc, &RenderOptions::default())
2932}
2933
2934pub fn adf_to_markdown_with_options(doc: &AdfDocument, opts: &RenderOptions) -> Result<String> {
2936 let mut output = String::new();
2937
2938 for (i, node) in doc.content.iter().enumerate() {
2939 if i > 0 {
2940 output.push('\n');
2941 }
2942 render_block_node(node, &mut output, opts);
2943 }
2944
2945 Ok(output)
2946}
2947
2948#[must_use]
2957pub fn adf_to_plain_text(doc: &AdfDocument) -> String {
2958 let mut out = String::new();
2959 for node in &doc.content {
2960 collect_plain_text(node, &mut out);
2961 if !out.is_empty() && !out.ends_with(' ') {
2962 out.push(' ');
2963 }
2964 }
2965 out.truncate(out.trim_end().len());
2966 out
2967}
2968
2969fn collect_plain_text(node: &AdfNode, out: &mut String) {
2970 if let Some(text) = &node.text {
2971 out.push_str(text);
2972 }
2973 if let Some(children) = &node.content {
2974 for child in children {
2975 collect_plain_text(child, out);
2976 }
2977 }
2978}
2979
2980fn pass_through_local_id(dir_attrs: &Option<crate::atlassian::attrs::Attrs>, node: &mut AdfNode) {
2984 if let Some(ref attrs) = dir_attrs {
2985 if let Some(local_id) = attrs.get("localId") {
2986 if let Some(ref mut node_attrs) = node.attrs {
2987 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2988 } else {
2989 node.attrs = Some(serde_json::json!({"localId": local_id}));
2990 }
2991 }
2992 }
2993}
2994
2995fn pass_through_expand_params(
2998 dir_attrs: &Option<crate::atlassian::attrs::Attrs>,
2999 node: &mut AdfNode,
3000) {
3001 if let Some(ref attrs) = dir_attrs {
3002 if let Some(local_id) = attrs.get("localId") {
3003 node.local_id = Some(local_id.to_string());
3004 }
3005 if let Some(params_str) = attrs.get("params") {
3006 if let Ok(params) = serde_json::from_str(params_str) {
3007 node.parameters = Some(params);
3008 }
3009 }
3010 }
3011}
3012
3013fn extract_trailing_local_id(text: &str) -> (&str, Option<String>, Option<String>) {
3023 let trimmed = text.trim_end();
3024 if !trimmed.ends_with('}') {
3025 return (text, None, None);
3026 }
3027 if let Some(brace_pos) = trimmed.rfind('{') {
3032 if brace_pos > 0 && !trimmed.as_bytes()[brace_pos - 1].is_ascii_whitespace() {
3033 return (text, None, None);
3034 }
3035 let attr_str = &trimmed[brace_pos..];
3036 if let Some((_, attrs)) = parse_attrs(attr_str, 0) {
3037 let local_id = attrs.get("localId").map(str::to_string);
3038 let para_local_id = attrs.get("paraLocalId").map(str::to_string);
3039 if local_id.is_some() || para_local_id.is_some() {
3040 let before = trimmed[..brace_pos]
3041 .strip_suffix(' ')
3042 .unwrap_or(&trimmed[..brace_pos]);
3043 return (before, local_id, para_local_id);
3044 }
3045 }
3046 }
3047 (text, None, None)
3048}
3049
3050fn parse_list_item_first_line(
3060 item_text: &str,
3061 sub_lines: Vec<String>,
3062 local_id: Option<String>,
3063 para_local_id: Option<String>,
3064 depth: usize,
3065) -> Result<AdfNode> {
3066 if item_text.starts_with("```") {
3067 let mut all_lines = vec![item_text.to_string()];
3069 all_lines.extend(sub_lines);
3070 let combined = all_lines.join("\n");
3071 let nested = MarkdownParser::with_depth(&combined, depth).parse_blocks()?;
3072 Ok(list_item_with_local_id(nested, local_id, para_local_id))
3073 } else if let Some(media) = try_parse_media_single_from_line(item_text) {
3074 if sub_lines.is_empty() {
3076 Ok(list_item_with_local_id(
3077 vec![media],
3078 local_id,
3079 para_local_id,
3080 ))
3081 } else {
3082 let sub_text = sub_lines.join("\n");
3083 let mut nested = MarkdownParser::with_depth(&sub_text, depth).parse_blocks()?;
3084 let mut content = vec![media];
3085 content.append(&mut nested);
3086 Ok(list_item_with_local_id(content, local_id, para_local_id))
3087 }
3088 } else {
3089 let first_node = AdfNode::paragraph(parse_inline(item_text));
3090 if sub_lines.is_empty() {
3091 Ok(list_item_with_local_id(
3092 vec![first_node],
3093 local_id,
3094 para_local_id,
3095 ))
3096 } else {
3097 let sub_text = sub_lines.join("\n");
3098 let mut nested = MarkdownParser::with_depth(&sub_text, depth).parse_blocks()?;
3099 let mut content = vec![first_node];
3100 content.append(&mut nested);
3101 Ok(list_item_with_local_id(content, local_id, para_local_id))
3102 }
3103 }
3104}
3105
3106fn list_item_with_local_id(
3107 mut content: Vec<AdfNode>,
3108 local_id: Option<String>,
3109 para_local_id: Option<String>,
3110) -> AdfNode {
3111 if let Some(id) = ¶_local_id {
3112 if let Some(first) = content.first_mut() {
3113 if first.node_type == "paragraph" {
3114 let node_attrs = first.attrs.get_or_insert_with(|| serde_json::json!({}));
3115 node_attrs["localId"] = serde_json::Value::String(id.clone());
3116 }
3117 }
3118 }
3119 let mut item = AdfNode::list_item(content);
3120 if let Some(id) = local_id {
3121 item.attrs = Some(serde_json::json!({"localId": id}));
3122 }
3123 item
3124}
3125
3126fn maybe_push_local_id(attrs: &serde_json::Value, parts: &mut Vec<String>, opts: &RenderOptions) {
3127 if opts.strip_local_ids {
3128 return;
3129 }
3130 if let Some(local_id) = attrs.get("localId").and_then(serde_json::Value::as_str) {
3131 if !local_id.is_empty() && local_id != "00000000-0000-0000-0000-000000000000" {
3132 parts.push(format_kv("localId", local_id));
3133 }
3134 }
3135}
3136
3137fn render_block_children(children: &[AdfNode], output: &mut String, opts: &RenderOptions) {
3139 for (i, child) in children.iter().enumerate() {
3140 if i > 0 {
3141 output.push('\n');
3142 }
3143 render_block_node(child, output, opts);
3144 }
3145}
3146
3147fn fmt_f64_attr(v: f64) -> String {
3150 if v.fract() == 0.0 {
3151 format!("{}", v as i64)
3152 } else {
3153 v.to_string()
3154 }
3155}
3156
3157fn parse_numeric_attr(s: &str) -> Option<serde_json::Value> {
3164 if s.contains('.') || s.contains('e') || s.contains('E') {
3165 s.parse::<f64>().ok().map(serde_json::Value::from)
3166 } else {
3167 s.parse::<i64>().ok().map(serde_json::Value::from)
3168 }
3169}
3170
3171fn fmt_numeric_attr(v: &serde_json::Value) -> Option<String> {
3179 if let Some(n) = v.as_i64() {
3180 return Some(n.to_string());
3181 }
3182 if let Some(n) = v.as_u64() {
3183 return Some(n.to_string());
3184 }
3185 if let Some(n) = v.as_f64() {
3186 if n.fract() == 0.0 && n.is_finite() {
3187 return Some(format!("{n:.1}"));
3188 }
3189 return Some(n.to_string());
3190 }
3191 None
3192}
3193
3194fn render_block_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3196 match node.node_type.as_str() {
3197 "paragraph" => {
3198 let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3199 let dir_attrs = {
3201 let mut parts = Vec::new();
3202 if let Some(ref attrs) = node.attrs {
3203 maybe_push_local_id(attrs, &mut parts, opts);
3204 }
3205 if parts.is_empty() {
3206 String::new()
3207 } else {
3208 format!("{{{}}}", parts.join(" "))
3209 }
3210 };
3211 if is_empty {
3212 output.push_str(&format!("::paragraph{dir_attrs}\n"));
3213 } else {
3214 let mut buf = String::new();
3216 render_inline_content(node, &mut buf, opts);
3217 if buf.trim().is_empty() && !buf.is_empty() {
3218 output.push_str(&format!("::paragraph[{buf}]{dir_attrs}\n"));
3221 } else {
3222 let mut is_first_line = true;
3227 for line in buf.split('\n') {
3228 if is_first_line {
3229 if is_list_start(line) {
3230 output.push_str(&escape_list_marker(line));
3231 } else {
3232 output.push_str(line);
3233 }
3234 is_first_line = false;
3235 } else {
3236 output.push('\n');
3237 if !line.is_empty() {
3238 output.push_str(" ");
3239 }
3240 output.push_str(line);
3241 }
3242 }
3243 output.push('\n');
3244 }
3245 }
3246 }
3247 "heading" => {
3248 let level = node
3249 .attrs
3250 .as_ref()
3251 .and_then(|a| a.get("level"))
3252 .and_then(serde_json::Value::as_u64)
3253 .unwrap_or(1);
3254 for _ in 0..level {
3255 output.push('#');
3256 }
3257 output.push(' ');
3258 let mut buf = String::new();
3259 render_inline_content(node, &mut buf, opts);
3260 let mut is_first_line = true;
3263 for line in buf.split('\n') {
3264 if is_first_line {
3265 output.push_str(line);
3266 is_first_line = false;
3267 } else {
3268 output.push('\n');
3269 if !line.is_empty() {
3270 output.push_str(" ");
3271 }
3272 output.push_str(line);
3273 }
3274 }
3275 output.push('\n');
3276 }
3277 "codeBlock" => {
3278 let language_value = node.attrs.as_ref().and_then(|a| a.get("language"));
3279 let language = language_value
3280 .and_then(serde_json::Value::as_str)
3281 .unwrap_or("");
3282 output.push_str("```");
3283 if language.is_empty() && language_value.is_some() {
3284 output.push_str("\"\"");
3287 } else {
3288 output.push_str(language);
3289 }
3290 output.push('\n');
3291 if let Some(ref content) = node.content {
3292 for child in content {
3293 if let Some(ref text) = child.text {
3294 output.push_str(text);
3295 }
3296 }
3297 }
3298 output.push_str("\n```\n");
3299 }
3300 "blockquote" => {
3301 if let Some(ref content) = node.content {
3302 for (i, child) in content.iter().enumerate() {
3303 if i > 0
3307 && child.node_type == "paragraph"
3308 && content[i - 1].node_type == "paragraph"
3309 {
3310 output.push_str(">\n");
3311 }
3312 let mut inner = String::new();
3313 render_block_node(child, &mut inner, opts);
3314 for line in inner.lines() {
3315 output.push_str("> ");
3316 output.push_str(line);
3317 output.push('\n');
3318 }
3319 }
3320 }
3321 }
3322 "bulletList" => {
3323 if let Some(ref items) = node.content {
3324 for item in items {
3325 output.push_str("- ");
3326 let content_start = output.len();
3327 render_list_item_content(item, output, opts);
3328 if starts_with_task_marker(&output[content_start..]) {
3334 output.insert(content_start, '\\');
3335 }
3336 }
3337 }
3338 }
3339 "orderedList" => {
3340 let start = node
3341 .attrs
3342 .as_ref()
3343 .and_then(|a| a.get("order"))
3344 .and_then(serde_json::Value::as_u64)
3345 .unwrap_or(1);
3346 if let Some(ref items) = node.content {
3347 for (i, item) in items.iter().enumerate() {
3348 let num = start + i as u64;
3349 output.push_str(&format!("{num}. "));
3350 render_list_item_content(item, output, opts);
3351 }
3352 }
3353 }
3354 "taskList" => {
3355 if let Some(ref items) = node.content {
3356 for item in items {
3357 if item.node_type == "taskList" {
3358 let mut nested = String::new();
3362 render_block_node(item, &mut nested, opts);
3363 for line in nested.lines() {
3364 output.push_str(" ");
3365 output.push_str(line);
3366 output.push('\n');
3367 }
3368 } else {
3369 let state = item
3370 .attrs
3371 .as_ref()
3372 .and_then(|a| a.get("state"))
3373 .and_then(serde_json::Value::as_str)
3374 .unwrap_or("TODO");
3375 if state == "DONE" {
3376 output.push_str("- [x] ");
3377 } else {
3378 output.push_str("- [ ] ");
3379 }
3380 render_list_item_content(item, output, opts);
3381 }
3382 }
3383 }
3384 }
3385 "rule" => {
3386 output.push_str("---\n");
3387 }
3388 "table" => {
3389 render_table(node, output, opts);
3390 }
3391 "mediaSingle" => {
3392 if let Some(ref content) = node.content {
3393 for child in content {
3394 if child.node_type == "media" {
3395 render_media(child, node.attrs.as_ref(), output, opts);
3396 }
3397 }
3398 for child in content {
3399 if child.node_type == "caption" {
3400 let mut cap_parts = Vec::new();
3401 if let Some(ref attrs) = child.attrs {
3402 maybe_push_local_id(attrs, &mut cap_parts, opts);
3403 }
3404 if cap_parts.is_empty() {
3405 output.push_str(":::caption\n");
3406 } else {
3407 output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3408 }
3409 if let Some(ref caption_content) = child.content {
3410 for inline in caption_content {
3411 render_inline_node(inline, output, opts);
3412 }
3413 output.push('\n');
3414 }
3415 output.push_str(":::\n");
3416 }
3417 }
3418 }
3419 }
3420 "blockCard" => {
3421 if let Some(ref attrs) = node.attrs {
3422 let url = attrs
3423 .get("url")
3424 .and_then(serde_json::Value::as_str)
3425 .unwrap_or("");
3426 let mut attr_parts = Vec::new();
3427 if url_safe_in_bracket_content(url) {
3428 output.push_str(&format!("::card[{url}]"));
3429 } else {
3430 output.push_str("::card[]");
3432 let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
3433 attr_parts.push(format!("url=\"{escaped}\""));
3434 }
3435 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3436 attr_parts.push(format!("layout={layout}"));
3437 }
3438 if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
3439 attr_parts.push(format!("width={width}"));
3440 }
3441 if !attr_parts.is_empty() {
3442 output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3443 }
3444 output.push('\n');
3445 }
3446 }
3447 "embedCard" => {
3448 if let Some(ref attrs) = node.attrs {
3449 let url = attrs
3450 .get("url")
3451 .and_then(serde_json::Value::as_str)
3452 .unwrap_or("");
3453 output.push_str(&format!("::embed[{url}]"));
3454 let mut attr_parts = Vec::new();
3455 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3456 attr_parts.push(format!("layout={layout}"));
3457 }
3458 if let Some(h) = attrs
3459 .get("originalHeight")
3460 .and_then(serde_json::Value::as_f64)
3461 {
3462 attr_parts.push(format!("originalHeight={}", fmt_f64_attr(h)));
3463 }
3464 if let Some(w) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3465 attr_parts.push(format!("width={}", fmt_f64_attr(w)));
3466 }
3467 if !attr_parts.is_empty() {
3468 output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3469 }
3470 output.push('\n');
3471 }
3472 }
3473 "extension" => {
3474 if let Some(ref attrs) = node.attrs {
3475 let ext_type = attrs
3476 .get("extensionType")
3477 .and_then(serde_json::Value::as_str)
3478 .unwrap_or("");
3479 let ext_key = attrs
3480 .get("extensionKey")
3481 .and_then(serde_json::Value::as_str)
3482 .unwrap_or("");
3483 let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3484 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3485 attr_parts.push(format!("layout={layout}"));
3486 }
3487 if let Some(params) = attrs.get("parameters") {
3488 if let Ok(json_str) = serde_json::to_string(params) {
3489 attr_parts.push(format!("params='{json_str}'"));
3490 }
3491 }
3492 maybe_push_local_id(attrs, &mut attr_parts, opts);
3493 output.push_str(&format!("::extension{{{}}}\n", attr_parts.join(" ")));
3494 }
3495 }
3496 "panel" => {
3497 let panel_type = node
3498 .attrs
3499 .as_ref()
3500 .and_then(|a| a.get("panelType"))
3501 .and_then(serde_json::Value::as_str)
3502 .unwrap_or("info");
3503 let mut attr_parts = vec![format!("type={panel_type}")];
3504 if let Some(ref attrs) = node.attrs {
3505 if let Some(icon) = attrs.get("panelIcon").and_then(serde_json::Value::as_str) {
3506 attr_parts.push(format!("icon=\"{icon}\""));
3507 }
3508 if let Some(color) = attrs.get("panelColor").and_then(serde_json::Value::as_str) {
3509 attr_parts.push(format!("color=\"{color}\""));
3510 }
3511 }
3512 output.push_str(&format!(":::panel{{{}}}\n", attr_parts.join(" ")));
3513 if let Some(ref content) = node.content {
3514 render_block_children(content, output, opts);
3515 }
3516 output.push_str(":::\n");
3517 }
3518 "expand" | "nestedExpand" => {
3519 let directive_name = if node.node_type == "nestedExpand" {
3520 "nested-expand"
3521 } else {
3522 "expand"
3523 };
3524 let mut attr_parts = Vec::new();
3525 if let Some(t) = node
3526 .attrs
3527 .as_ref()
3528 .and_then(|a| a.get("title"))
3529 .and_then(serde_json::Value::as_str)
3530 {
3531 attr_parts.push(format!("title=\"{t}\""));
3532 }
3533 if let Some(ref lid) = node.local_id {
3535 if !opts.strip_local_ids && lid != "00000000-0000-0000-0000-000000000000" {
3536 attr_parts.push(format!("localId={lid}"));
3537 }
3538 } else if let Some(ref attrs) = node.attrs {
3539 maybe_push_local_id(attrs, &mut attr_parts, opts);
3540 }
3541 if let Some(ref params) = node.parameters {
3543 if let Ok(json_str) = serde_json::to_string(params) {
3544 attr_parts.push(format!("params='{json_str}'"));
3545 }
3546 }
3547 if attr_parts.is_empty() {
3548 output.push_str(&format!(":::{directive_name}\n"));
3549 } else {
3550 output.push_str(&format!(
3551 ":::{directive_name}{{{}}}\n",
3552 attr_parts.join(" ")
3553 ));
3554 }
3555 if let Some(ref content) = node.content {
3556 render_block_children(content, output, opts);
3557 }
3558 output.push_str(":::\n");
3559 }
3560 "layoutSection" => {
3561 output.push_str("::::layout\n");
3562 if let Some(ref content) = node.content {
3563 for child in content {
3564 if child.node_type == "layoutColumn" {
3565 let width_str = child
3566 .attrs
3567 .as_ref()
3568 .and_then(|a| a.get("width"))
3569 .and_then(fmt_numeric_attr)
3570 .unwrap_or_else(|| "50".to_string());
3571 let mut parts = vec![format!("width={width_str}")];
3572 if let Some(ref attrs) = child.attrs {
3573 maybe_push_local_id(attrs, &mut parts, opts);
3574 }
3575 output.push_str(&format!(":::column{{{}}}\n", parts.join(" ")));
3576 if let Some(ref col_content) = child.content {
3577 render_block_children(col_content, output, opts);
3578 }
3579 output.push_str(":::\n");
3580 }
3581 }
3582 }
3583 output.push_str("::::\n");
3584 }
3585 "decisionList" => {
3586 output.push_str(":::decisions\n");
3587 if let Some(ref content) = node.content {
3588 for item in content {
3589 output.push_str("- <> ");
3590 render_list_item_content(item, output, opts);
3591 }
3592 }
3593 output.push_str(":::\n");
3594 }
3595 "bodiedExtension" => {
3596 if let Some(ref attrs) = node.attrs {
3597 let ext_type = attrs
3598 .get("extensionType")
3599 .and_then(serde_json::Value::as_str)
3600 .unwrap_or("");
3601 let ext_key = attrs
3602 .get("extensionKey")
3603 .and_then(serde_json::Value::as_str)
3604 .unwrap_or("");
3605 let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3606 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3607 attr_parts.push(format!("layout={layout}"));
3608 }
3609 if let Some(params) = attrs.get("parameters") {
3610 if let Ok(json_str) = serde_json::to_string(params) {
3611 attr_parts.push(format!("params='{json_str}'"));
3612 }
3613 }
3614 maybe_push_local_id(attrs, &mut attr_parts, opts);
3615 output.push_str(&format!(":::extension{{{}}}\n", attr_parts.join(" ")));
3616 if let Some(ref content) = node.content {
3617 render_block_children(content, output, opts);
3618 }
3619 output.push_str(":::\n");
3620 }
3621 }
3622 _ => {
3623 if let Ok(json) = serde_json::to_string_pretty(node) {
3625 output.push_str("```adf-unsupported\n");
3626 output.push_str(&json);
3627 output.push_str("\n```\n");
3628 }
3629 }
3630 }
3631
3632 let mut parts = Vec::new();
3634 if let Some(ref marks) = node.marks {
3635 for mark in marks {
3636 match mark.mark_type.as_str() {
3637 "alignment" => {
3638 if let Some(align) = mark
3639 .attrs
3640 .as_ref()
3641 .and_then(|a| a.get("align"))
3642 .and_then(serde_json::Value::as_str)
3643 {
3644 parts.push(format!("align={align}"));
3645 }
3646 }
3647 "indentation" => {
3648 if let Some(level) = mark
3649 .attrs
3650 .as_ref()
3651 .and_then(|a| a.get("level"))
3652 .and_then(serde_json::Value::as_u64)
3653 {
3654 parts.push(format!("indent={level}"));
3655 }
3656 }
3657 "breakout" => {
3658 if let Some(mode) = mark
3659 .attrs
3660 .as_ref()
3661 .and_then(|a| a.get("mode"))
3662 .and_then(serde_json::Value::as_str)
3663 {
3664 parts.push(format!("breakout={mode}"));
3665 }
3666 if let Some(width) = mark
3667 .attrs
3668 .as_ref()
3669 .and_then(|a| a.get("width"))
3670 .and_then(serde_json::Value::as_u64)
3671 {
3672 parts.push(format!("breakoutWidth={width}"));
3673 }
3674 }
3675 _ => {}
3676 }
3677 }
3678 }
3679 let para_used_directive = node.node_type == "paragraph" && {
3683 let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3684 if is_empty {
3685 true
3686 } else {
3687 let mut buf = String::new();
3688 render_inline_content(node, &mut buf, opts);
3689 buf.trim().is_empty() && !buf.is_empty()
3690 }
3691 };
3692 if !matches!(node.node_type.as_str(), "expand" | "nestedExpand") && !para_used_directive {
3693 if let Some(ref attrs) = node.attrs {
3694 maybe_push_local_id(attrs, &mut parts, opts);
3695 }
3696 }
3697 if node.node_type == "orderedList" {
3702 if let Some(ref attrs) = node.attrs {
3703 if attrs.get("order").and_then(serde_json::Value::as_u64) == Some(1) {
3704 parts.push("order=1".to_string());
3705 }
3706 }
3707 }
3708 if !parts.is_empty() {
3709 output.push_str(&format!("{{{}}}\n", parts.join(" ")));
3710 }
3711}
3712
3713fn render_list_item_content(item: &AdfNode, output: &mut String, opts: &RenderOptions) {
3721 let Some(ref content) = item.content else {
3722 let bare = AdfNode::text("");
3724 emit_list_item_local_ids(item, &bare, output, opts);
3725 output.push('\n');
3726 return;
3727 };
3728 if content.is_empty() {
3729 let bare = AdfNode::text("");
3730 emit_list_item_local_ids(item, &bare, output, opts);
3731 output.push('\n');
3732 return;
3733 }
3734 let first = &content[0];
3735 let rest_start;
3736 if first.node_type == "paragraph" {
3737 let mut buf = String::new();
3738 render_inline_content(first, &mut buf, opts);
3739 let buf = buf.trim_end_matches('\n');
3745 let mut is_first_line = true;
3748 for line in buf.split('\n') {
3749 if is_first_line {
3750 output.push_str(line);
3751 is_first_line = false;
3752 } else {
3753 output.push('\n');
3754 if !line.is_empty() {
3755 output.push_str(" ");
3756 }
3757 output.push_str(line);
3758 }
3759 }
3760 emit_list_item_local_ids(item, first, output, opts);
3762 output.push('\n');
3763 rest_start = 1;
3764 } else if is_inline_node_type(&first.node_type) {
3765 rest_start = content
3767 .iter()
3768 .position(|c| !is_inline_node_type(&c.node_type))
3769 .unwrap_or(content.len());
3770 let mut buf = String::new();
3771 for child in &content[..rest_start] {
3772 render_inline_node(child, &mut buf, opts);
3773 }
3774 let buf = buf.trim_end_matches('\n');
3777 let mut is_first_line = true;
3778 for line in buf.split('\n') {
3779 if is_first_line {
3780 output.push_str(line);
3781 is_first_line = false;
3782 } else {
3783 output.push('\n');
3784 if !line.is_empty() {
3785 output.push_str(" ");
3786 }
3787 output.push_str(line);
3788 }
3789 }
3790 let bare = AdfNode::text("");
3792 emit_list_item_local_ids(item, &bare, output, opts);
3793 output.push('\n');
3794 } else if first.node_type == "taskItem" {
3797 let bare = AdfNode::text("");
3802 emit_list_item_local_ids(item, &bare, output, opts);
3803 output.push('\n');
3804 for child in content {
3805 if child.node_type == "taskItem" {
3806 let state = child
3807 .attrs
3808 .as_ref()
3809 .and_then(|a| a.get("state"))
3810 .and_then(serde_json::Value::as_str)
3811 .unwrap_or("TODO");
3812 let marker = if state == "DONE" { "- [x] " } else { "- [ ] " };
3813 output.push_str(" ");
3814 output.push_str(marker);
3815 render_list_item_content(child, output, opts);
3816 } else {
3817 let mut nested = String::new();
3818 render_block_node(child, &mut nested, opts);
3819 for line in nested.lines() {
3820 output.push_str(" ");
3821 output.push_str(line);
3822 output.push('\n');
3823 }
3824 }
3825 }
3826 return;
3827 } else {
3828 let mut buf = String::new();
3834 render_block_node(first, &mut buf, opts);
3835 let bare = AdfNode::text("");
3836 let mut is_first = true;
3837 for line in buf.lines() {
3838 if is_first {
3839 output.push_str(line);
3840 emit_list_item_local_ids(item, &bare, output, opts);
3841 output.push('\n');
3842 is_first = false;
3843 } else {
3844 output.push_str(" ");
3845 output.push_str(line);
3846 output.push('\n');
3847 }
3848 }
3849 rest_start = 1;
3850 }
3851 let rest = &content[rest_start..];
3852 for (i, child) in rest.iter().enumerate() {
3853 if child.node_type == "paragraph" {
3857 let prev_is_para = if i == 0 {
3858 first.node_type == "paragraph"
3861 } else {
3862 rest[i - 1].node_type == "paragraph"
3863 };
3864 if prev_is_para {
3865 output.push_str(" \n");
3866 }
3867 }
3868 let mut nested = String::new();
3869 render_block_node(child, &mut nested, opts);
3870 for line in nested.lines() {
3871 output.push_str(" ");
3872 output.push_str(line);
3873 output.push('\n');
3874 }
3875 }
3876}
3877
3878fn is_inline_node_type(node_type: &str) -> bool {
3880 matches!(
3881 node_type,
3882 "text"
3883 | "hardBreak"
3884 | "inlineCard"
3885 | "emoji"
3886 | "mention"
3887 | "status"
3888 | "date"
3889 | "placeholder"
3890 | "mediaInline"
3891 )
3892}
3893
3894fn emit_list_item_local_ids(
3897 item: &AdfNode,
3898 paragraph: &AdfNode,
3899 output: &mut String,
3900 opts: &RenderOptions,
3901) {
3902 if opts.strip_local_ids {
3903 return;
3904 }
3905 let mut parts = Vec::new();
3906 if let Some(ref attrs) = item.attrs {
3907 maybe_push_local_id(attrs, &mut parts, opts);
3908 }
3909 if paragraph.node_type == "paragraph" {
3910 let has_real_id = paragraph
3911 .attrs
3912 .as_ref()
3913 .and_then(|a| a.get("localId"))
3914 .and_then(serde_json::Value::as_str)
3915 .filter(|id| !id.is_empty() && *id != "00000000-0000-0000-0000-000000000000");
3916 if let Some(local_id) = has_real_id {
3917 parts.push(format!("paraLocalId={local_id}"));
3918 } else if item.node_type == "taskItem" {
3919 parts.push("paraLocalId=_".to_string());
3923 }
3924 }
3925 if !parts.is_empty() {
3926 output.push_str(&format!(" {{{}}}", parts.join(" ")));
3927 }
3928}
3929
3930fn render_table(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3932 let Some(ref rows) = node.content else {
3933 return;
3934 };
3935
3936 if table_qualifies_for_pipe_syntax(rows) {
3937 render_pipe_table(node, rows, output, opts);
3938 } else {
3939 render_directive_table(node, rows, output, opts);
3940 }
3941}
3942
3943fn table_qualifies_for_pipe_syntax(rows: &[AdfNode]) -> bool {
3950 if rows.iter().any(|n| n.node_type == "caption") {
3952 return false;
3953 }
3954 let mut first_row_has_header = false;
3955 for (row_idx, row) in rows.iter().enumerate() {
3956 let Some(ref cells) = row.content else {
3957 continue;
3958 };
3959 for cell in cells {
3960 if row_idx > 0 && cell.node_type == "tableHeader" {
3962 return false;
3963 }
3964 if row_idx == 0 && cell.node_type == "tableHeader" {
3965 first_row_has_header = true;
3966 }
3967 let Some(ref content) = cell.content else {
3969 continue;
3970 };
3971 if content.len() != 1 || content[0].node_type != "paragraph" {
3972 return false;
3973 }
3974 if cell_contains_hard_break(&content[0]) {
3977 return false;
3978 }
3979 if cell.marks.as_ref().is_some_and(|m| !m.is_empty()) {
3982 return false;
3983 }
3984 if content[0]
3987 .attrs
3988 .as_ref()
3989 .and_then(|a| a.get("localId"))
3990 .is_some()
3991 {
3992 return false;
3993 }
3994 }
3995 }
3996 first_row_has_header
3999}
4000
4001fn cell_contains_hard_break(paragraph: &AdfNode) -> bool {
4003 paragraph
4004 .content
4005 .as_ref()
4006 .is_some_and(|nodes| nodes.iter().any(|n| n.node_type == "hardBreak"))
4007}
4008
4009fn render_pipe_table(node: &AdfNode, rows: &[AdfNode], output: &mut String, opts: &RenderOptions) {
4011 for (row_idx, row) in rows.iter().enumerate() {
4012 let Some(ref cells) = row.content else {
4013 continue;
4014 };
4015
4016 output.push('|');
4017 for cell in cells {
4018 output.push(' ');
4019 let mut cell_buf = String::new();
4020 render_cell_attrs_prefix(cell, &mut cell_buf);
4021 render_inline_content_from_first_paragraph(cell, &mut cell_buf, opts);
4022 output.push_str(&escape_pipes_in_cell(&cell_buf));
4023 output.push_str(" |");
4024 }
4025 output.push('\n');
4026
4027 if row_idx == 0 {
4029 output.push('|');
4030 for cell in cells {
4031 let align = get_cell_paragraph_alignment(cell);
4032 match align {
4033 Some("center") => output.push_str(" :---: |"),
4034 Some("end") => output.push_str(" ---: |"),
4035 _ => output.push_str(" --- |"),
4036 }
4037 }
4038 output.push('\n');
4039 }
4040 }
4041
4042 render_table_level_attrs(node, output, opts);
4044}
4045
4046fn render_directive_table(
4048 node: &AdfNode,
4049 rows: &[AdfNode],
4050 output: &mut String,
4051 opts: &RenderOptions,
4052) {
4053 let mut attr_parts = Vec::new();
4055 if let Some(ref attrs) = node.attrs {
4056 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
4057 attr_parts.push(format!("layout={layout}"));
4058 }
4059 if let Some(numbered) = attrs
4060 .get("isNumberColumnEnabled")
4061 .and_then(serde_json::Value::as_bool)
4062 {
4063 if numbered {
4064 attr_parts.push("numbered".to_string());
4065 } else {
4066 attr_parts.push("numbered=false".to_string());
4067 }
4068 }
4069 if let Some(tw) = attrs.get("width").and_then(serde_json::Value::as_f64) {
4070 let tw_str = if tw.fract() == 0.0 {
4071 (tw as u64).to_string()
4072 } else {
4073 tw.to_string()
4074 };
4075 attr_parts.push(format!("width={tw_str}"));
4076 }
4077 maybe_push_local_id(attrs, &mut attr_parts, opts);
4078 }
4079 if attr_parts.is_empty() {
4080 output.push_str("::::table\n");
4081 } else {
4082 output.push_str(&format!("::::table{{{}}}\n", attr_parts.join(" ")));
4083 }
4084
4085 for row in rows {
4086 if row.node_type == "caption" {
4087 let mut cap_parts = Vec::new();
4088 if let Some(ref attrs) = row.attrs {
4089 maybe_push_local_id(attrs, &mut cap_parts, opts);
4090 }
4091 if cap_parts.is_empty() {
4092 output.push_str(":::caption\n");
4093 } else {
4094 output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
4095 }
4096 if let Some(ref content) = row.content {
4097 for child in content {
4098 render_inline_node(child, output, opts);
4099 }
4100 output.push('\n');
4101 }
4102 output.push_str(":::\n");
4103 continue;
4104 }
4105 let Some(ref cells) = row.content else {
4106 continue;
4107 };
4108 let mut tr_attrs = Vec::new();
4110 if let Some(ref attrs) = row.attrs {
4111 maybe_push_local_id(attrs, &mut tr_attrs, opts);
4112 }
4113 if tr_attrs.is_empty() {
4114 output.push_str(":::tr\n");
4115 } else {
4116 output.push_str(&format!(":::tr{{{}}}\n", tr_attrs.join(" ")));
4117 }
4118 for cell in cells {
4119 let directive_name = if cell.node_type == "tableHeader" {
4120 "th"
4121 } else {
4122 "td"
4123 };
4124 let mut cell_attr_str = build_cell_attrs_string(cell);
4125 if let Some(ref attrs) = cell.attrs {
4127 let mut lid_parts = Vec::new();
4128 maybe_push_local_id(attrs, &mut lid_parts, opts);
4129 if !lid_parts.is_empty() {
4130 if !cell_attr_str.is_empty() {
4131 cell_attr_str.push(' ');
4132 }
4133 cell_attr_str.push_str(&lid_parts.join(" "));
4134 }
4135 }
4136 if let Some(ref marks) = cell.marks {
4138 for mark in marks {
4139 if mark.mark_type == "border" {
4140 if let Some(ref attrs) = mark.attrs {
4141 if let Some(color) =
4142 attrs.get("color").and_then(serde_json::Value::as_str)
4143 {
4144 if !cell_attr_str.is_empty() {
4145 cell_attr_str.push(' ');
4146 }
4147 cell_attr_str.push_str(&format!("border-color={color}"));
4148 }
4149 if let Some(size) =
4150 attrs.get("size").and_then(serde_json::Value::as_u64)
4151 {
4152 if !cell_attr_str.is_empty() {
4153 cell_attr_str.push(' ');
4154 }
4155 cell_attr_str.push_str(&format!("border-size={size}"));
4156 }
4157 }
4158 }
4159 }
4160 }
4161 let has_marks = cell.marks.as_ref().is_some_and(|m| !m.is_empty());
4162 if cell_attr_str.is_empty() && cell.attrs.is_none() && !has_marks {
4163 output.push_str(&format!(":::{directive_name}\n"));
4164 } else {
4165 output.push_str(&format!(":::{directive_name}{{{cell_attr_str}}}\n"));
4166 }
4167 if let Some(ref content) = cell.content {
4168 render_block_children(content, output, opts);
4169 }
4170 output.push_str(":::\n");
4171 }
4172 output.push_str(":::\n");
4173 }
4174
4175 output.push_str("::::\n");
4176}
4177
4178fn needs_attr_quoting(value: &str) -> bool {
4182 value.contains(|c: char| c.is_whitespace() || c == '}' || c == '(' || c == ')' || c == ',')
4183}
4184
4185fn build_cell_attrs_string(cell: &AdfNode) -> String {
4187 let Some(ref attrs) = cell.attrs else {
4188 return String::new();
4189 };
4190 let mut parts = Vec::new();
4191 if let Some(colspan) = attrs.get("colspan").and_then(serde_json::Value::as_u64) {
4192 parts.push(format!("colspan={colspan}"));
4193 }
4194 if let Some(rowspan) = attrs.get("rowspan").and_then(serde_json::Value::as_u64) {
4195 parts.push(format!("rowspan={rowspan}"));
4196 }
4197 if let Some(bg) = attrs.get("background").and_then(serde_json::Value::as_str) {
4198 if needs_attr_quoting(bg) {
4199 let escaped = bg.replace('\\', "\\\\").replace('"', "\\\"");
4200 parts.push(format!("bg=\"{escaped}\""));
4201 } else {
4202 parts.push(format!("bg={bg}"));
4203 }
4204 }
4205 if let Some(colwidth) = attrs.get("colwidth").and_then(serde_json::Value::as_array) {
4206 let widths: Vec<String> = colwidth
4207 .iter()
4208 .filter_map(|v| {
4209 if let Some(n) = v.as_u64() {
4212 Some(n.to_string())
4213 } else if let Some(n) = v.as_f64() {
4214 if n.fract() == 0.0 {
4215 format!("{n:.1}")
4216 } else {
4217 n.to_string()
4218 }
4219 .into()
4220 } else {
4221 None
4222 }
4223 })
4224 .collect();
4225 if !widths.is_empty() {
4226 parts.push(format!("colwidth={}", widths.join(",")));
4227 }
4228 }
4229 parts.join(" ")
4230}
4231
4232fn render_cell_attrs_prefix(cell: &AdfNode, output: &mut String) {
4234 let Some(ref _attrs) = cell.attrs else {
4235 return;
4236 };
4237 let attr_str = build_cell_attrs_string(cell);
4238 if attr_str.is_empty() {
4239 output.push_str("{} ");
4240 } else {
4241 output.push_str(&format!("{{{attr_str}}} "));
4242 }
4243}
4244
4245fn get_cell_paragraph_alignment(cell: &AdfNode) -> Option<&str> {
4247 let content = cell.content.as_ref()?;
4248 let para = content.first()?;
4249 let marks = para.marks.as_ref()?;
4250 marks.iter().find_map(|m| {
4251 if m.mark_type == "alignment" {
4252 m.attrs
4253 .as_ref()
4254 .and_then(|a| a.get("align"))
4255 .and_then(serde_json::Value::as_str)
4256 } else {
4257 None
4258 }
4259 })
4260}
4261
4262fn render_table_level_attrs(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4264 if let Some(ref attrs) = node.attrs {
4265 let mut parts = Vec::new();
4266 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
4267 parts.push(format!("layout={layout}"));
4268 }
4269 if let Some(numbered) = attrs
4270 .get("isNumberColumnEnabled")
4271 .and_then(serde_json::Value::as_bool)
4272 {
4273 if numbered {
4274 parts.push("numbered".to_string());
4275 } else {
4276 parts.push("numbered=false".to_string());
4277 }
4278 }
4279 if let Some(tw_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4280 parts.push(format!("width={tw_str}"));
4281 }
4282 maybe_push_local_id(attrs, &mut parts, opts);
4283 if !parts.is_empty() {
4284 output.push_str(&format!("{{{}}}\n", parts.join(" ")));
4285 }
4286 }
4287}
4288
4289fn render_inline_content_from_first_paragraph(
4291 cell: &AdfNode,
4292 output: &mut String,
4293 opts: &RenderOptions,
4294) {
4295 if let Some(ref content) = cell.content {
4296 if let Some(first) = content.first() {
4297 if first.node_type == "paragraph" {
4298 render_inline_content(first, output, opts);
4299 }
4300 }
4301 }
4302}
4303
4304fn push_border_mark_attrs(marks: &Option<Vec<AdfMark>>, parts: &mut Vec<String>) {
4306 if let Some(ref marks) = marks {
4307 for mark in marks {
4308 if mark.mark_type == "border" {
4309 if let Some(ref attrs) = mark.attrs {
4310 if let Some(color) = attrs.get("color").and_then(serde_json::Value::as_str) {
4311 parts.push(format!("border-color={color}"));
4312 }
4313 if let Some(size) = attrs.get("size").and_then(serde_json::Value::as_u64) {
4314 parts.push(format!("border-size={size}"));
4315 }
4316 }
4317 }
4318 }
4319 }
4320}
4321
4322fn render_media(
4324 node: &AdfNode,
4325 parent_attrs: Option<&serde_json::Value>,
4326 output: &mut String,
4327 opts: &RenderOptions,
4328) {
4329 if let Some(ref attrs) = node.attrs {
4330 let media_type = attrs
4331 .get("type")
4332 .and_then(serde_json::Value::as_str)
4333 .unwrap_or("external");
4334 let alt = attrs
4335 .get("alt")
4336 .and_then(serde_json::Value::as_str)
4337 .unwrap_or("");
4338
4339 if media_type == "file" {
4340 output.push_str(&format!("![{alt}]()"));
4342 let mut parts = vec!["type=file".to_string()];
4343 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4344 parts.push(format_kv("id", id));
4345 }
4346 if let Some(collection) = attrs.get("collection").and_then(serde_json::Value::as_str) {
4347 parts.push(format_kv("collection", collection));
4348 }
4349 if let Some(occurrence_key) = attrs
4350 .get("occurrenceKey")
4351 .and_then(serde_json::Value::as_str)
4352 {
4353 parts.push(format_kv("occurrenceKey", occurrence_key));
4354 }
4355 if let Some(height_str) = attrs.get("height").and_then(fmt_numeric_attr) {
4356 parts.push(format!("height={height_str}"));
4357 }
4358 if let Some(width_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4359 parts.push(format!("width={width_str}"));
4360 }
4361 maybe_push_local_id(attrs, &mut parts, opts);
4362 if let Some(p_attrs) = parent_attrs {
4364 if let Some(layout) = p_attrs.get("layout").and_then(serde_json::Value::as_str) {
4365 if layout != "center" {
4366 parts.push(format!("layout={layout}"));
4367 }
4368 }
4369 if let Some(ms_width_str) = p_attrs.get("width").and_then(fmt_numeric_attr) {
4370 parts.push(format!("mediaWidth={ms_width_str}"));
4371 }
4372 if let Some(wt) = p_attrs.get("widthType").and_then(serde_json::Value::as_str) {
4373 parts.push(format!("widthType={wt}"));
4374 }
4375 if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4376 parts.push(format!("mode={mode}"));
4377 }
4378 }
4379 push_border_mark_attrs(&node.marks, &mut parts);
4380 output.push_str(&format!("{{{}}}", parts.join(" ")));
4381 } else {
4382 let url = attrs
4384 .get("url")
4385 .and_then(serde_json::Value::as_str)
4386 .unwrap_or("");
4387 output.push_str(&format!(""));
4388
4389 {
4391 let mut parts = Vec::new();
4392 if let Some(p_attrs) = parent_attrs {
4393 let layout = p_attrs.get("layout").and_then(serde_json::Value::as_str);
4394 let width_str = p_attrs.get("width").and_then(fmt_numeric_attr);
4395 let width_type = p_attrs.get("widthType").and_then(serde_json::Value::as_str);
4396 if let Some(l) = layout {
4397 if l != "center" {
4398 parts.push(format!("layout={l}"));
4399 }
4400 }
4401 if let Some(w) = width_str {
4402 parts.push(format!("width={w}"));
4403 }
4404 if let Some(wt) = width_type {
4405 parts.push(format!("widthType={wt}"));
4406 }
4407 if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4408 parts.push(format!("mode={mode}"));
4409 }
4410 }
4411 maybe_push_local_id(attrs, &mut parts, opts);
4412 push_border_mark_attrs(&node.marks, &mut parts);
4413 if !parts.is_empty() {
4414 output.push_str(&format!("{{{}}}", parts.join(" ")));
4415 }
4416 }
4417 }
4418
4419 output.push('\n');
4420 }
4421}
4422
4423fn render_inline_content(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4425 if let Some(ref content) = node.content {
4426 for child in content {
4427 render_inline_node(child, output, opts);
4428 }
4429 }
4430}
4431
4432fn render_inline_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4434 match node.node_type.as_str() {
4435 "text" => {
4436 let text = node.text.as_deref().unwrap_or("");
4437 let marks = node.marks.as_deref().unwrap_or(&[]);
4438 let has_code = marks.iter().any(|m| m.mark_type == "code");
4439 let owned;
4444 let text = if !has_code {
4445 owned = text.replace('\\', "\\\\");
4446 owned.as_str()
4447 } else {
4448 text
4449 };
4450 let owned_nl;
4455 let text = if text.contains('\n') {
4456 owned_nl = text.replace('\n', "\\n");
4457 owned_nl.as_str()
4458 } else {
4459 text
4460 };
4461 let owned_ts;
4467 let text = if !has_code && text.ends_with(" ") {
4468 let mut s = text.to_string();
4469 s.insert(s.len() - 1, '\\');
4471 owned_ts = s;
4472 owned_ts.as_str()
4473 } else {
4474 text
4475 };
4476 render_marked_text(text, marks, output);
4477 }
4478 "hardBreak" => {
4479 output.push_str("\\\n");
4480 }
4481 other => {
4482 let mut body = String::new();
4486 render_non_text_inline_body(other, node, &mut body, opts);
4487
4488 let annotations: Vec<&AdfMark> = node
4489 .marks
4490 .as_deref()
4491 .unwrap_or(&[])
4492 .iter()
4493 .filter(|m| m.mark_type == "annotation")
4494 .collect();
4495
4496 if annotations.is_empty() {
4497 output.push_str(&body);
4498 } else {
4499 let mut attr_parts = Vec::new();
4500 for ann in &annotations {
4501 if let Some(ref attrs) = ann.attrs {
4502 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4503 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4504 attr_parts.push(format!("annotation-id=\"{escaped}\""));
4505 }
4506 if let Some(at) = attrs
4507 .get("annotationType")
4508 .and_then(serde_json::Value::as_str)
4509 {
4510 attr_parts.push(format!("annotation-type={at}"));
4511 }
4512 }
4513 }
4514 output.push('[');
4515 output.push_str(&body);
4516 output.push_str("]{");
4517 output.push_str(&attr_parts.join(" "));
4518 output.push('}');
4519 }
4520 }
4521 }
4522}
4523
4524fn render_non_text_inline_body(
4526 node_type: &str,
4527 node: &AdfNode,
4528 output: &mut String,
4529 opts: &RenderOptions,
4530) {
4531 match node_type {
4532 "inlineCard" => {
4533 if let Some(ref attrs) = node.attrs {
4534 if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4535 let mut attr_parts = Vec::new();
4536 if url_safe_in_bracket_content(url) {
4537 output.push_str(":card[");
4538 output.push_str(url);
4539 output.push(']');
4540 } else {
4541 output.push_str(":card[]");
4545 let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
4546 attr_parts.push(format!("url=\"{escaped}\""));
4547 }
4548 maybe_push_local_id(attrs, &mut attr_parts, opts);
4549 if !attr_parts.is_empty() {
4550 output.push('{');
4551 output.push_str(&attr_parts.join(" "));
4552 output.push('}');
4553 }
4554 }
4555 }
4556 }
4557 "emoji" => {
4558 if let Some(ref attrs) = node.attrs {
4559 if let Some(short_name) = attrs.get("shortName").and_then(serde_json::Value::as_str)
4560 {
4561 output.push(':');
4562 let name = short_name.strip_prefix(':').unwrap_or(short_name);
4563 let name = name.strip_suffix(':').unwrap_or(name);
4564 output.push_str(name);
4565 output.push(':');
4566
4567 let mut parts = Vec::new();
4568 let escaped_sn = short_name.replace('\\', "\\\\").replace('"', "\\\"");
4569 parts.push(format!("shortName=\"{escaped_sn}\""));
4570 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4571 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4572 parts.push(format!("id=\"{escaped}\""));
4573 }
4574 if let Some(text) = attrs.get("text").and_then(serde_json::Value::as_str) {
4575 let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
4576 parts.push(format!("text=\"{escaped}\""));
4577 }
4578 maybe_push_local_id(attrs, &mut parts, opts);
4579 output.push('{');
4580 output.push_str(&parts.join(" "));
4581 output.push('}');
4582 }
4583 }
4584 }
4585 "status" => {
4586 if let Some(ref attrs) = node.attrs {
4587 let text = attrs
4588 .get("text")
4589 .and_then(serde_json::Value::as_str)
4590 .unwrap_or("");
4591 let color = attrs
4592 .get("color")
4593 .and_then(serde_json::Value::as_str)
4594 .unwrap_or("neutral");
4595 let mut attr_parts = vec![format!("color={color}")];
4596 if let Some(style) = attrs.get("style").and_then(serde_json::Value::as_str) {
4597 attr_parts.push(format!("style={style}"));
4598 }
4599 maybe_push_local_id(attrs, &mut attr_parts, opts);
4600 output.push_str(&format!(":status[{text}]{{{}}}", attr_parts.join(" ")));
4601 }
4602 }
4603 "date" => {
4604 if let Some(ref attrs) = node.attrs {
4605 if let Some(timestamp) = attrs.get("timestamp").and_then(serde_json::Value::as_str)
4606 {
4607 let display = epoch_ms_to_iso_date(timestamp);
4608 let mut attr_parts = vec![format!("timestamp={timestamp}")];
4609 maybe_push_local_id(attrs, &mut attr_parts, opts);
4610 output.push_str(&format!(":date[{display}]{{{}}}", attr_parts.join(" ")));
4611 }
4612 }
4613 }
4614 "mention" => {
4615 if let Some(ref attrs) = node.attrs {
4616 let id = attrs
4617 .get("id")
4618 .and_then(serde_json::Value::as_str)
4619 .unwrap_or("");
4620 let text = attrs
4621 .get("text")
4622 .and_then(serde_json::Value::as_str)
4623 .unwrap_or("");
4624 let mut attr_parts = vec![format!("id={id}")];
4625 if let Some(ut) = attrs.get("userType").and_then(serde_json::Value::as_str) {
4626 attr_parts.push(format!("userType={ut}"));
4627 }
4628 if let Some(al) = attrs.get("accessLevel").and_then(serde_json::Value::as_str) {
4629 attr_parts.push(format!("accessLevel={al}"));
4630 }
4631 maybe_push_local_id(attrs, &mut attr_parts, opts);
4632 output.push_str(&format!(":mention[{text}]{{{}}}", attr_parts.join(" ")));
4633 }
4634 }
4635 "placeholder" => {
4636 if let Some(ref attrs) = node.attrs {
4637 let text = attrs
4638 .get("text")
4639 .and_then(serde_json::Value::as_str)
4640 .unwrap_or("");
4641 output.push_str(&format!(":placeholder[{text}]"));
4642 }
4643 }
4644 "inlineExtension" => {
4645 if let Some(ref attrs) = node.attrs {
4646 let ext_type = attrs
4647 .get("extensionType")
4648 .and_then(serde_json::Value::as_str)
4649 .unwrap_or("");
4650 let ext_key = attrs
4651 .get("extensionKey")
4652 .and_then(serde_json::Value::as_str)
4653 .unwrap_or("");
4654 let fallback = node.text.as_deref().unwrap_or("");
4655 output.push_str(&format!(
4656 ":extension[{fallback}]{{type={ext_type} key={ext_key}}}"
4657 ));
4658 }
4659 }
4660 "mediaInline" => {
4661 if let Some(ref attrs) = node.attrs {
4662 let mut attr_parts = Vec::new();
4663 if let Some(media_type) = attrs.get("type").and_then(serde_json::Value::as_str) {
4664 attr_parts.push(format_kv("type", media_type));
4665 }
4666 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4667 attr_parts.push(format_kv("id", id));
4668 }
4669 if let Some(collection) =
4670 attrs.get("collection").and_then(serde_json::Value::as_str)
4671 {
4672 attr_parts.push(format_kv("collection", collection));
4673 }
4674 if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4675 attr_parts.push(format_kv("url", url));
4676 }
4677 if let Some(alt) = attrs.get("alt").and_then(serde_json::Value::as_str) {
4678 attr_parts.push(format_kv("alt", alt));
4679 }
4680 if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
4681 attr_parts.push(format!("width={width}"));
4682 }
4683 if let Some(height) = attrs.get("height").and_then(serde_json::Value::as_u64) {
4684 attr_parts.push(format!("height={height}"));
4685 }
4686 maybe_push_local_id(attrs, &mut attr_parts, opts);
4687 output.push_str(&format!(":media-inline[]{{{}}}", attr_parts.join(" ")));
4688 }
4689 }
4690 _ => {
4691 if let Ok(json) = serde_json::to_string(node) {
4698 output.push_str(":adf-unsupported[]{");
4699 output.push_str(&format_kv("json", &json));
4700 output.push('}');
4701 }
4702 }
4703 }
4704}
4705
4706fn render_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4719 if marks.iter().any(|m| m.mark_type == "code") {
4720 render_code_marked_text(text, marks, output);
4721 return;
4722 }
4723
4724 let has_link = marks.iter().any(|m| m.mark_type == "link");
4725 let has_strong = marks.iter().any(|m| m.mark_type == "strong");
4726 let has_em = marks.iter().any(|m| m.mark_type == "em");
4727
4728 if marks.len() == 2 && marks[0].mark_type == "strong" && marks[1].mark_type == "em" {
4732 let escaped = escape_emphasis_markers(text);
4733 let escaped = escape_emoji_shortcodes(&escaped);
4734 let escaped = escape_backticks(&escaped);
4735 let escaped = escape_bare_urls(&escaped);
4736 output.push_str("***");
4737 output.push_str(&escaped);
4738 output.push_str("***");
4739 return;
4740 }
4741
4742 let em_delim = if has_strong && has_em { "_" } else { "*" };
4746
4747 let escaped = if em_delim == "_" {
4750 escape_emphasis_markers_with_underscore(text)
4751 } else {
4752 escape_emphasis_markers(text)
4753 };
4754 let escaped = escape_emoji_shortcodes(&escaped);
4755 let escaped = escape_backticks(&escaped);
4756 let escaped = escape_bare_urls(&escaped);
4764 let escaped = if has_link {
4765 escape_link_brackets(&escaped)
4766 } else {
4767 escaped
4768 };
4769
4770 let mut wrappers: Vec<(String, String)> = Vec::new();
4776 let mut i = 0;
4777 while i < marks.len() {
4778 match marks[i].mark_type.as_str() {
4779 "em" => {
4780 wrappers.push((em_delim.to_string(), em_delim.to_string()));
4781 i += 1;
4782 }
4783 "strong" => {
4784 wrappers.push(("**".to_string(), "**".to_string()));
4785 i += 1;
4786 }
4787 "strike" => {
4788 wrappers.push(("~~".to_string(), "~~".to_string()));
4789 i += 1;
4790 }
4791 "link" => {
4792 let href = link_href(&marks[i]);
4793 wrappers.push(("[".to_string(), format!("]({href})")));
4794 i += 1;
4795 }
4796 "textColor" | "backgroundColor" | "subsup" => {
4797 let start = i;
4798 while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4799 i += 1;
4800 }
4801 emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4802 }
4803 "underline" | "annotation" => {
4804 let start = i;
4805 while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4806 i += 1;
4807 }
4808 emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4809 }
4810 _ => {
4811 i += 1;
4812 }
4813 }
4814 }
4815
4816 let mut result = escaped;
4818 for (open, close) in wrappers.iter().rev() {
4819 result.insert_str(0, open);
4820 result.push_str(close);
4821 }
4822 output.push_str(&result);
4823}
4824
4825fn render_code_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4833 let link_mark = marks.iter().find(|m| m.mark_type == "link");
4834
4835 let mut code_str = String::new();
4836 if let Some(link_mark) = link_mark {
4837 let href = link_href(link_mark);
4838 code_str.push('[');
4839 render_inline_code(text, &mut code_str);
4840 code_str.push_str("](");
4841 code_str.push_str(href);
4842 code_str.push(')');
4843 } else {
4844 render_inline_code(text, &mut code_str);
4845 }
4846
4847 let mut wrappers: Vec<(String, String)> = Vec::new();
4850 let mut i = 0;
4851 while i < marks.len() {
4852 match marks[i].mark_type.as_str() {
4853 "textColor" | "backgroundColor" | "subsup" => {
4854 let start = i;
4855 while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4856 i += 1;
4857 }
4858 emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4859 }
4860 "underline" | "annotation" => {
4861 let start = i;
4862 while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4863 i += 1;
4864 }
4865 emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4866 }
4867 _ => {
4868 i += 1;
4869 }
4870 }
4871 }
4872
4873 let mut result = code_str;
4875 for (open, close) in wrappers.iter().rev() {
4876 result.insert_str(0, open);
4877 result.push_str(close);
4878 }
4879 output.push_str(&result);
4880}
4881
4882fn collect_span_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4884 match mark.mark_type.as_str() {
4885 "textColor" => {
4886 if let Some(c) = mark
4887 .attrs
4888 .as_ref()
4889 .and_then(|a| a.get("color"))
4890 .and_then(serde_json::Value::as_str)
4891 {
4892 attrs.push(format!("color={c}"));
4893 }
4894 }
4895 "backgroundColor" => {
4896 if let Some(c) = mark
4897 .attrs
4898 .as_ref()
4899 .and_then(|a| a.get("color"))
4900 .and_then(serde_json::Value::as_str)
4901 {
4902 attrs.push(format!("bg={c}"));
4903 }
4904 }
4905 "subsup" => {
4906 if let Some(kind) = mark
4907 .attrs
4908 .as_ref()
4909 .and_then(|a| a.get("type"))
4910 .and_then(serde_json::Value::as_str)
4911 {
4912 attrs.push(kind.to_string());
4913 }
4914 }
4915 _ => {}
4916 }
4917}
4918
4919fn collect_bracketed_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4921 match mark.mark_type.as_str() {
4922 "underline" => attrs.push("underline".to_string()),
4923 "annotation" => {
4924 if let Some(ref a) = mark.attrs {
4925 if let Some(id) = a.get("id").and_then(serde_json::Value::as_str) {
4926 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4927 attrs.push(format!("annotation-id=\"{escaped}\""));
4928 }
4929 if let Some(at) = a.get("annotationType").and_then(serde_json::Value::as_str) {
4930 attrs.push(format!("annotation-type={at}"));
4931 }
4932 }
4933 }
4934 _ => {}
4935 }
4936}
4937
4938fn is_span_attr_mark(mark_type: &str) -> bool {
4939 matches!(mark_type, "textColor" | "backgroundColor" | "subsup")
4940}
4941
4942fn is_bracketed_span_mark(mark_type: &str) -> bool {
4943 matches!(mark_type, "underline" | "annotation")
4944}
4945
4946fn span_attr_order(mark_type: &str) -> u8 {
4950 match mark_type {
4951 "textColor" => 0,
4952 "backgroundColor" => 1,
4953 "subsup" => 2,
4954 _ => u8::MAX,
4955 }
4956}
4957
4958fn span_run_is_canonical(run: &[AdfMark]) -> bool {
4963 let mut prev = 0;
4964 for m in run {
4965 let order = span_attr_order(&m.mark_type);
4966 if order == u8::MAX || order < prev {
4967 return false;
4968 }
4969 prev = order;
4970 }
4971 true
4972}
4973
4974fn bracketed_run_is_canonical(run: &[AdfMark]) -> bool {
4979 let mut seen_annotation = false;
4980 for m in run {
4981 match m.mark_type.as_str() {
4982 "underline" => {
4983 if seen_annotation {
4984 return false;
4985 }
4986 }
4987 "annotation" => seen_annotation = true,
4988 _ => return false,
4989 }
4990 }
4991 true
4992}
4993
4994fn emit_span_attr_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4998 if span_run_is_canonical(run) {
4999 let mut attrs = Vec::new();
5000 for m in run {
5001 collect_span_attr(m, &mut attrs);
5002 }
5003 wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
5004 return;
5005 }
5006 for m in run {
5007 let mut attrs = Vec::new();
5008 collect_span_attr(m, &mut attrs);
5009 wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
5010 }
5011}
5012
5013fn emit_bracketed_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
5017 if bracketed_run_is_canonical(run) {
5018 let mut attrs = Vec::new();
5019 for m in run {
5020 collect_bracketed_attr(m, &mut attrs);
5021 }
5022 wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
5023 return;
5024 }
5025 for m in run {
5026 let mut attrs = Vec::new();
5027 collect_bracketed_attr(m, &mut attrs);
5028 wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
5029 }
5030}
5031
5032fn link_href(mark: &AdfMark) -> &str {
5034 mark.attrs
5035 .as_ref()
5036 .and_then(|a| a.get("href"))
5037 .and_then(serde_json::Value::as_str)
5038 .unwrap_or("")
5039}
5040
5041#[cfg(test)]
5042#[allow(
5043 clippy::unwrap_used,
5044 clippy::expect_used,
5045 clippy::needless_update,
5046 clippy::needless_collect,
5047 duplicate_macro_attributes
5048)]
5049mod tests {
5050 use super::*;
5051
5052 #[test]
5055 fn adf_to_plain_text_single_paragraph() {
5056 let doc = markdown_to_adf("Hello world").unwrap();
5057 assert_eq!(adf_to_plain_text(&doc), "Hello world");
5058 }
5059
5060 #[test]
5061 fn adf_to_plain_text_multiple_paragraphs_space_separated() {
5062 let doc = markdown_to_adf("Alpha\n\nBeta").unwrap();
5063 let plain = adf_to_plain_text(&doc);
5064 assert!(plain.contains("Alpha"));
5066 assert!(plain.contains("Beta"));
5067 assert_eq!(plain, "Alpha Beta");
5068 }
5069
5070 #[test]
5071 fn adf_to_plain_text_drops_marks_but_keeps_text() {
5072 let doc = markdown_to_adf("Hello **bold** world").unwrap();
5073 assert_eq!(adf_to_plain_text(&doc), "Hello bold world");
5074 }
5075
5076 #[test]
5077 fn adf_to_plain_text_empty_doc() {
5078 let doc = AdfDocument::new();
5079 assert_eq!(adf_to_plain_text(&doc), "");
5080 }
5081
5082 #[test]
5083 fn adf_to_plain_text_leading_empty_block_emits_no_extra_space() {
5084 let doc = AdfDocument {
5087 version: 1,
5088 doc_type: "doc".to_string(),
5089 content: vec![
5090 AdfNode {
5091 node_type: "paragraph".to_string(),
5092 attrs: None,
5093 content: Some(vec![]),
5094 text: None,
5095 marks: None,
5096 local_id: None,
5097 parameters: None,
5098 },
5099 AdfNode {
5100 node_type: "paragraph".to_string(),
5101 attrs: None,
5102 content: Some(vec![AdfNode::text("Hello")]),
5103 text: None,
5104 marks: None,
5105 local_id: None,
5106 parameters: None,
5107 },
5108 ],
5109 };
5110 assert_eq!(adf_to_plain_text(&doc), "Hello");
5111 }
5112
5113 #[test]
5116 fn paragraph() {
5117 let doc = markdown_to_adf("Hello world").unwrap();
5118 assert_eq!(doc.content.len(), 1);
5119 assert_eq!(doc.content[0].node_type, "paragraph");
5120 }
5121
5122 #[test]
5123 fn heading_levels() {
5124 for level in 1..=6 {
5125 let hashes = "#".repeat(level);
5126 let md = format!("{hashes} Title");
5127 let doc = markdown_to_adf(&md).unwrap();
5128 assert_eq!(doc.content[0].node_type, "heading");
5129 let attrs = doc.content[0].attrs.as_ref().unwrap();
5130 assert_eq!(attrs["level"], level as u64);
5131 }
5132 }
5133
5134 #[test]
5137 fn heading_inline_code_mark_stripped() {
5138 let doc = markdown_to_adf("### `GET /api`").unwrap();
5141 let heading = &doc.content[0];
5142 assert_eq!(heading.node_type, "heading");
5143 let content = heading.content.as_ref().unwrap();
5144 assert_eq!(content.len(), 1);
5145 assert_eq!(content[0].text.as_deref(), Some("GET /api"));
5146 assert!(
5147 content[0].marks.is_none(),
5148 "expected no marks, got: {:?}",
5149 content[0].marks
5150 );
5151 }
5152
5153 #[test]
5154 fn heading_code_strip_preserves_sibling_strong() {
5155 let doc = markdown_to_adf("### **`x`**").unwrap();
5157 let content = doc.content[0].content.as_ref().unwrap();
5158 let marks = content[0].marks.as_ref().unwrap();
5159 assert!(marks.iter().any(|m| m.mark_type == "strong"));
5160 assert!(!marks.iter().any(|m| m.mark_type == "code"));
5161 }
5162
5163 #[test]
5164 fn heading_code_strip_preserves_link() {
5165 let doc = markdown_to_adf("### [`x`](https://e.com)").unwrap();
5167 let content = doc.content[0].content.as_ref().unwrap();
5168 let marks = content[0].marks.as_ref().unwrap();
5169 assert!(marks.iter().any(|m| m.mark_type == "link"));
5170 assert!(!marks.iter().any(|m| m.mark_type == "code"));
5171 }
5172
5173 #[test]
5174 fn heading_with_code_now_passes_validation() {
5175 let doc = markdown_to_adf("### `GET /api`").unwrap();
5178 let violations = crate::atlassian::adf_schema::validate_document(&doc);
5179 assert!(violations.is_empty(), "got: {violations:?}");
5180 }
5181
5182 #[test]
5183 fn paragraph_inline_code_mark_preserved() {
5184 let doc = markdown_to_adf("`x`").unwrap();
5187 let content = doc.content[0].content.as_ref().unwrap();
5188 let marks = content[0].marks.as_ref().unwrap();
5189 assert!(marks.iter().any(|m| m.mark_type == "code"));
5190 }
5191
5192 fn mark_types(node: &AdfNode) -> Vec<&str> {
5196 node.marks
5197 .as_ref()
5198 .map(|marks| marks.iter().map(|m| m.mark_type.as_str()).collect())
5199 .unwrap_or_default()
5200 }
5201
5202 #[test]
5203 fn split_strong_code_single_run() {
5204 let doc = markdown_to_adf("**`x`**").unwrap();
5207 let content = doc.content[0].content.as_ref().unwrap();
5208 assert_eq!(content.len(), 1);
5209 assert_eq!(content[0].text.as_deref(), Some("x"));
5210 assert_eq!(mark_types(&content[0]), vec!["code"]);
5211 }
5212
5213 #[test]
5214 fn split_em_code_single_run() {
5215 let doc = markdown_to_adf("*`x`*").unwrap();
5216 let content = doc.content[0].content.as_ref().unwrap();
5217 assert_eq!(mark_types(&content[0]), vec!["code"]);
5218 }
5219
5220 #[test]
5221 fn split_strike_code_single_run() {
5222 let doc = markdown_to_adf("~~`x`~~").unwrap();
5223 let content = doc.content[0].content.as_ref().unwrap();
5224 assert_eq!(mark_types(&content[0]), vec!["code"]);
5225 }
5226
5227 #[test]
5228 fn split_strong_em_code_drops_both() {
5229 let doc = markdown_to_adf("***`x`***").unwrap();
5230 let content = doc.content[0].content.as_ref().unwrap();
5231 assert_eq!(mark_types(&content[0]), vec!["code"]);
5232 }
5233
5234 #[test]
5235 fn split_nested_code_keeps_sibling_strong() {
5236 let doc = markdown_to_adf("**foo `bar` baz**").unwrap();
5239 let content = doc.content[0].content.as_ref().unwrap();
5240 assert_eq!(content.len(), 3);
5241 assert_eq!(content[0].text.as_deref(), Some("foo "));
5242 assert_eq!(mark_types(&content[0]), vec!["strong"]);
5243 assert_eq!(content[1].text.as_deref(), Some("bar"));
5244 assert_eq!(mark_types(&content[1]), vec!["code"]);
5245 assert_eq!(content[2].text.as_deref(), Some(" baz"));
5246 assert_eq!(mark_types(&content[2]), vec!["strong"]);
5247 }
5248
5249 #[test]
5250 fn split_multiple_hits_in_one_line() {
5251 let doc = markdown_to_adf("**`x`** and **`y`**").unwrap();
5252 let content = doc.content[0].content.as_ref().unwrap();
5253 assert_eq!(content.len(), 3);
5254 assert_eq!(mark_types(&content[0]), vec!["code"]);
5255 assert_eq!(content[1].text.as_deref(), Some(" and "));
5256 assert!(content[1].marks.is_none());
5257 assert_eq!(mark_types(&content[2]), vec!["code"]);
5258 }
5259
5260 #[test]
5261 fn split_leaves_separate_runs_untouched() {
5262 let doc = markdown_to_adf("**foo** `bar`").unwrap();
5264 let content = doc.content[0].content.as_ref().unwrap();
5265 assert_eq!(mark_types(&content[0]), vec!["strong"]);
5266 assert_eq!(content[1].text.as_deref(), Some(" "));
5267 assert_eq!(mark_types(&content[2]), vec!["code"]);
5268 }
5269
5270 #[test]
5271 fn split_preserves_link_on_code_run() {
5272 let doc = markdown_to_adf("**[`x`](https://e.com)**").unwrap();
5274 let content = doc.content[0].content.as_ref().unwrap();
5275 assert_eq!(mark_types(&content[0]), vec!["link", "code"]);
5276 }
5277
5278 #[test]
5279 fn split_keeps_span_syntax_marks_for_validator() {
5280 let doc = markdown_to_adf("[`x`]{underline}").unwrap();
5284 let content = doc.content[0].content.as_ref().unwrap();
5285 assert_eq!(mark_types(&content[0]), vec!["underline", "code"]);
5286 }
5287
5288 #[test]
5289 fn split_runs_after_heading_strip_bold_kept() {
5290 let doc = markdown_to_adf("# **`x`**").unwrap();
5294 let content = doc.content[0].content.as_ref().unwrap();
5295 assert_eq!(mark_types(&content[0]), vec!["strong"]);
5296 }
5297
5298 #[test]
5299 fn split_applies_inside_containers() {
5300 let doc = markdown_to_adf("- **`x`**").unwrap();
5302 let list_item = &doc.content[0].content.as_ref().unwrap()[0];
5303 let paragraph = &list_item.content.as_ref().unwrap()[0];
5304 let content = paragraph.content.as_ref().unwrap();
5305 assert_eq!(mark_types(&content[0]), vec!["code"]);
5306 }
5307
5308 #[test]
5309 fn split_strong_code_passes_validation() {
5310 let doc = markdown_to_adf(
5313 "**Testing `exodus` itself is out of scope for this ticket** — coordinate \
5314 with the team that owns the exodus credentials.",
5315 )
5316 .unwrap();
5317 let violations = crate::atlassian::adf_schema::validate_document(&doc);
5318 assert!(violations.is_empty(), "got: {violations:?}");
5319 }
5320
5321 #[test]
5322 fn code_block() {
5323 let md = "```rust\nfn main() {}\n```";
5324 let doc = markdown_to_adf(md).unwrap();
5325 assert_eq!(doc.content[0].node_type, "codeBlock");
5326 let attrs = doc.content[0].attrs.as_ref().unwrap();
5327 assert_eq!(attrs["language"], "rust");
5328 }
5329
5330 #[test]
5331 fn code_block_no_language() {
5332 let md = "```\nsome code\n```";
5333 let doc = markdown_to_adf(md).unwrap();
5334 assert_eq!(doc.content[0].node_type, "codeBlock");
5335 assert!(doc.content[0].attrs.is_none());
5336 }
5337
5338 #[test]
5339 fn code_block_empty_language() {
5340 let md = "```\"\"\nsome code\n```";
5341 let doc = markdown_to_adf(md).unwrap();
5342 assert_eq!(doc.content[0].node_type, "codeBlock");
5343 let attrs = doc.content[0].attrs.as_ref().unwrap();
5344 assert_eq!(attrs["language"], "");
5345 }
5346
5347 #[test]
5348 fn horizontal_rule() {
5349 let doc = markdown_to_adf("---").unwrap();
5350 assert_eq!(doc.content[0].node_type, "rule");
5351 }
5352
5353 #[test]
5354 fn horizontal_rule_stars() {
5355 let doc = markdown_to_adf("***").unwrap();
5356 assert_eq!(doc.content[0].node_type, "rule");
5357 }
5358
5359 #[test]
5360 fn blockquote() {
5361 let md = "> This is a quote\n> Second line";
5362 let doc = markdown_to_adf(md).unwrap();
5363 assert_eq!(doc.content[0].node_type, "blockquote");
5364 }
5365
5366 #[test]
5367 fn bullet_list() {
5368 let md = "- Item 1\n- Item 2\n- Item 3";
5369 let doc = markdown_to_adf(md).unwrap();
5370 assert_eq!(doc.content[0].node_type, "bulletList");
5371 let items = doc.content[0].content.as_ref().unwrap();
5372 assert_eq!(items.len(), 3);
5373 }
5374
5375 #[test]
5376 fn ordered_list() {
5377 let md = "1. First\n2. Second\n3. Third";
5378 let doc = markdown_to_adf(md).unwrap();
5379 assert_eq!(doc.content[0].node_type, "orderedList");
5380 let items = doc.content[0].content.as_ref().unwrap();
5381 assert_eq!(items.len(), 3);
5382 }
5383
5384 #[test]
5385 fn task_list() {
5386 let md = "- [ ] Todo item\n- [x] Done item";
5387 let doc = markdown_to_adf(md).unwrap();
5388 assert_eq!(doc.content[0].node_type, "taskList");
5389 let items = doc.content[0].content.as_ref().unwrap();
5390 assert_eq!(items.len(), 2);
5391 assert_eq!(items[0].node_type, "taskItem");
5392 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5393 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5394 }
5395
5396 #[test]
5397 fn task_list_uppercase_x() {
5398 let md = "- [X] Done item";
5399 let doc = markdown_to_adf(md).unwrap();
5400 assert_eq!(doc.content[0].node_type, "taskList");
5401 let item = &doc.content[0].content.as_ref().unwrap()[0];
5402 assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
5403 }
5404
5405 #[test]
5408 fn task_list_empty_todo_no_trailing_space() {
5409 let md = "- [ ]";
5410 let doc = markdown_to_adf(md).unwrap();
5411 assert_eq!(doc.content[0].node_type, "taskList");
5412 let items = doc.content[0].content.as_ref().unwrap();
5413 assert_eq!(items.len(), 1);
5414 assert_eq!(items[0].node_type, "taskItem");
5415 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5416 assert!(items[0].content.is_none());
5417 }
5418
5419 #[test]
5421 fn task_list_empty_done_no_trailing_space() {
5422 let md = "- [x]\n- [X]";
5423 let doc = markdown_to_adf(md).unwrap();
5424 assert_eq!(doc.content[0].node_type, "taskList");
5425 let items = doc.content[0].content.as_ref().unwrap();
5426 assert_eq!(items.len(), 2);
5427 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
5428 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5429 }
5430
5431 #[test]
5434 fn task_list_body_has_no_leading_space() {
5435 let md = "- [ ] Buy groceries";
5436 let doc = markdown_to_adf(md).unwrap();
5437 let item = &doc.content[0].content.as_ref().unwrap()[0];
5438 let text = item.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5439 assert_eq!(text, "Buy groceries");
5440 }
5441
5442 #[test]
5446 fn round_trip_empty_task_items_stripped_trailing_spaces() {
5447 let json = r#"{
5448 "version": 1,
5449 "type": "doc",
5450 "content": [{
5451 "type": "taskList",
5452 "attrs": {"localId": "abc"},
5453 "content": [
5454 {"type": "taskItem", "attrs": {"localId": "def", "state": "TODO"}},
5455 {"type": "taskItem", "attrs": {"localId": "ghi", "state": "DONE"}}
5456 ]
5457 }]
5458 }"#;
5459 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5460 let md = adf_to_markdown(&doc).unwrap();
5461 let stripped: String = md.lines().map(str::trim_end).collect::<Vec<_>>().join("\n");
5462 let parsed = markdown_to_adf(&stripped).unwrap();
5463 assert_eq!(parsed.content[0].node_type, "taskList");
5464 let items = parsed.content[0].content.as_ref().unwrap();
5465 assert_eq!(items.len(), 2);
5466 assert_eq!(items[0].node_type, "taskItem");
5467 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5468 assert_eq!(items[1].node_type, "taskItem");
5469 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5470 }
5471
5472 #[test]
5473 fn try_parse_task_marker_accepts_bare_checkbox() {
5474 assert_eq!(try_parse_task_marker("[ ]"), Some(("TODO", "")));
5475 assert_eq!(try_parse_task_marker("[x]"), Some(("DONE", "")));
5476 assert_eq!(try_parse_task_marker("[X]"), Some(("DONE", "")));
5477 assert_eq!(try_parse_task_marker("[ ] foo"), Some(("TODO", "foo")));
5478 assert_eq!(try_parse_task_marker("[x] foo"), Some(("DONE", "foo")));
5479 assert_eq!(try_parse_task_marker("[ ]foo"), None);
5480 assert_eq!(try_parse_task_marker("[x]foo"), None);
5481 assert_eq!(try_parse_task_marker("[y] foo"), None);
5482 }
5483
5484 #[test]
5485 fn starts_with_task_marker_matches_parser() {
5486 assert!(starts_with_task_marker("[ ]"));
5489 assert!(starts_with_task_marker("[x]"));
5490 assert!(starts_with_task_marker("[X]"));
5491 assert!(starts_with_task_marker("[ ] foo"));
5492 assert!(starts_with_task_marker("[x] foo\n"));
5493 assert!(starts_with_task_marker("[ ]\n"));
5494 assert!(!starts_with_task_marker("[ ]foo"));
5496 assert!(!starts_with_task_marker("[y] foo"));
5497 assert!(!starts_with_task_marker("foo [ ] bar"));
5498 assert!(!starts_with_task_marker(""));
5499 }
5500
5501 #[test]
5505 fn round_trip_bullet_list_with_literal_checkbox_text() {
5506 let json = r#"{
5507 "version": 1,
5508 "type": "doc",
5509 "content": [{
5510 "type": "bulletList",
5511 "content": [{
5512 "type": "listItem",
5513 "content": [{
5514 "type": "paragraph",
5515 "content": [
5516 {"type": "text", "text": "[ ] Review the "},
5517 {"type": "text", "text": "config.yaml", "marks": [{"type": "code"}]},
5518 {"type": "text", "text": " file"}
5519 ]
5520 }]
5521 }]
5522 }]
5523 }"#;
5524 let original: AdfDocument = serde_json::from_str(json).unwrap();
5525 let md = adf_to_markdown(&original).unwrap();
5526 assert!(
5528 md.contains(r"- \[ ] Review the "),
5529 "rendered markdown: {md:?}"
5530 );
5531 let parsed = markdown_to_adf(&md).unwrap();
5532 assert_eq!(parsed.content[0].node_type, "bulletList");
5533 let item = &parsed.content[0].content.as_ref().unwrap()[0];
5534 assert_eq!(item.node_type, "listItem");
5535 let para = &item.content.as_ref().unwrap()[0];
5536 assert_eq!(para.node_type, "paragraph");
5537 let text_nodes = para.content.as_ref().unwrap();
5538 assert_eq!(text_nodes[0].text.as_deref().unwrap(), "[ ] Review the ");
5539 assert_eq!(text_nodes[1].text.as_deref().unwrap(), "config.yaml");
5540 assert_eq!(text_nodes[2].text.as_deref().unwrap(), " file");
5541 }
5542
5543 #[test]
5545 fn round_trip_bullet_list_with_literal_done_checkbox_text() {
5546 let json = r#"{
5547 "version": 1,
5548 "type": "doc",
5549 "content": [{
5550 "type": "bulletList",
5551 "content": [{
5552 "type": "listItem",
5553 "content": [{
5554 "type": "paragraph",
5555 "content": [{"type": "text", "text": "[x] not actually done"}]
5556 }]
5557 }]
5558 }]
5559 }"#;
5560 let original: AdfDocument = serde_json::from_str(json).unwrap();
5561 let md = adf_to_markdown(&original).unwrap();
5562 assert!(md.contains(r"- \[x] "), "rendered markdown: {md:?}");
5563 let parsed = markdown_to_adf(&md).unwrap();
5564 assert_eq!(parsed.content[0].node_type, "bulletList");
5565 let item = &parsed.content[0].content.as_ref().unwrap()[0];
5566 let para = &item.content.as_ref().unwrap()[0];
5567 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5568 assert_eq!(text, "[x] not actually done");
5569 }
5570
5571 #[test]
5573 fn round_trip_bullet_list_with_bare_literal_checkbox() {
5574 let json = r#"{
5575 "version": 1,
5576 "type": "doc",
5577 "content": [{
5578 "type": "bulletList",
5579 "content": [{
5580 "type": "listItem",
5581 "content": [{
5582 "type": "paragraph",
5583 "content": [{"type": "text", "text": "[ ]"}]
5584 }]
5585 }]
5586 }]
5587 }"#;
5588 let original: AdfDocument = serde_json::from_str(json).unwrap();
5589 let md = adf_to_markdown(&original).unwrap();
5590 let parsed = markdown_to_adf(&md).unwrap();
5591 assert_eq!(parsed.content[0].node_type, "bulletList");
5592 let item = &parsed.content[0].content.as_ref().unwrap()[0];
5593 let para = &item.content.as_ref().unwrap()[0];
5594 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5595 assert_eq!(text, "[ ]");
5596 }
5597
5598 #[test]
5601 fn bullet_list_non_task_bracket_text_not_escaped() {
5602 let json = r#"{
5603 "version": 1,
5604 "type": "doc",
5605 "content": [{
5606 "type": "bulletList",
5607 "content": [{
5608 "type": "listItem",
5609 "content": [{
5610 "type": "paragraph",
5611 "content": [{"type": "text", "text": "[?] unsure"}]
5612 }]
5613 }]
5614 }]
5615 }"#;
5616 let original: AdfDocument = serde_json::from_str(json).unwrap();
5617 let md = adf_to_markdown(&original).unwrap();
5618 assert!(!md.contains(r"\["), "should not escape: {md:?}");
5619 assert!(md.contains("- [?] unsure"), "rendered: {md:?}");
5620 }
5621
5622 #[test]
5625 fn round_trip_nested_bullet_list_with_literal_checkbox_text() {
5626 let json = r#"{
5627 "version": 1,
5628 "type": "doc",
5629 "content": [{
5630 "type": "bulletList",
5631 "content": [{
5632 "type": "listItem",
5633 "content": [
5634 {"type": "paragraph", "content": [{"type": "text", "text": "outer"}]},
5635 {"type": "bulletList", "content": [{
5636 "type": "listItem",
5637 "content": [{
5638 "type": "paragraph",
5639 "content": [{"type": "text", "text": "[ ] inner literal"}]
5640 }]
5641 }]}
5642 ]
5643 }]
5644 }]
5645 }"#;
5646 let original: AdfDocument = serde_json::from_str(json).unwrap();
5647 let md = adf_to_markdown(&original).unwrap();
5648 let parsed = markdown_to_adf(&md).unwrap();
5649 let outer = &parsed.content[0];
5650 assert_eq!(outer.node_type, "bulletList");
5651 let outer_item = &outer.content.as_ref().unwrap()[0];
5652 let inner_list = &outer_item.content.as_ref().unwrap()[1];
5653 assert_eq!(inner_list.node_type, "bulletList");
5654 let inner_item = &inner_list.content.as_ref().unwrap()[0];
5655 assert_eq!(inner_item.node_type, "listItem");
5656 let para = &inner_item.content.as_ref().unwrap()[0];
5657 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5658 assert_eq!(text, "[ ] inner literal");
5659 }
5660
5661 #[test]
5662 fn adf_task_list_to_markdown() {
5663 let doc = AdfDocument {
5664 version: 1,
5665 doc_type: "doc".to_string(),
5666 content: vec![AdfNode::task_list(vec![
5667 AdfNode::task_item(
5668 "TODO",
5669 vec![AdfNode::paragraph(vec![AdfNode::text("Todo")])],
5670 ),
5671 AdfNode::task_item(
5672 "DONE",
5673 vec![AdfNode::paragraph(vec![AdfNode::text("Done")])],
5674 ),
5675 ])],
5676 };
5677 let md = adf_to_markdown(&doc).unwrap();
5678 assert!(md.contains("- [ ] Todo"));
5679 assert!(md.contains("- [x] Done"));
5680 }
5681
5682 #[test]
5683 fn round_trip_task_list() {
5684 let md = "- [ ] Todo item\n- [x] Done item\n";
5685 let doc = markdown_to_adf(md).unwrap();
5686 let result = adf_to_markdown(&doc).unwrap();
5687 assert!(result.contains("- [ ] Todo item"));
5688 assert!(result.contains("- [x] Done item"));
5689 }
5690
5691 #[test]
5693 fn adf_task_item_unwrapped_inline_content() {
5694 let json = r#"{
5696 "version": 1,
5697 "type": "doc",
5698 "content": [{
5699 "type": "taskList",
5700 "attrs": {"localId": "list-001"},
5701 "content": [{
5702 "type": "taskItem",
5703 "attrs": {"localId": "task-001", "state": "TODO"},
5704 "content": [{"type": "text", "text": "Do something"}]
5705 }]
5706 }]
5707 }"#;
5708 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5709 let md = adf_to_markdown(&doc).unwrap();
5710 assert!(md.contains("- [ ] Do something"), "got: {md}");
5711 assert!(!md.contains("adf-unsupported"), "got: {md}");
5712 }
5713
5714 #[test]
5716 fn adf_task_list_multiple_unwrapped_items() {
5717 let json = r#"{
5718 "version": 1,
5719 "type": "doc",
5720 "content": [{
5721 "type": "taskList",
5722 "attrs": {"localId": "list-001"},
5723 "content": [
5724 {
5725 "type": "taskItem",
5726 "attrs": {"localId": "task-001", "state": "TODO"},
5727 "content": [{"type": "text", "text": "First task"}]
5728 },
5729 {
5730 "type": "taskItem",
5731 "attrs": {"localId": "task-002", "state": "DONE"},
5732 "content": [{"type": "text", "text": "Second task"}]
5733 }
5734 ]
5735 }]
5736 }"#;
5737 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5738 let md = adf_to_markdown(&doc).unwrap();
5739 assert!(md.contains("- [ ] First task"), "got: {md}");
5740 assert!(md.contains("- [x] Second task"), "got: {md}");
5741 assert!(!md.contains("adf-unsupported"), "got: {md}");
5742 }
5743
5744 #[test]
5746 fn adf_task_item_unwrapped_inline_with_marks() {
5747 let json = r#"{
5748 "version": 1,
5749 "type": "doc",
5750 "content": [{
5751 "type": "taskList",
5752 "attrs": {"localId": "list-001"},
5753 "content": [{
5754 "type": "taskItem",
5755 "attrs": {"localId": "task-001", "state": "TODO"},
5756 "content": [
5757 {"type": "text", "text": "Buy "},
5758 {"type": "text", "text": "groceries", "marks": [{"type": "strong"}]},
5759 {"type": "text", "text": " today"}
5760 ]
5761 }]
5762 }]
5763 }"#;
5764 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5765 let md = adf_to_markdown(&doc).unwrap();
5766 assert!(md.contains("- [ ] Buy **groceries** today"), "got: {md}");
5767 }
5768
5769 #[test]
5771 fn adf_task_item_unwrapped_preserves_local_id() {
5772 let json = r#"{
5773 "version": 1,
5774 "type": "doc",
5775 "content": [{
5776 "type": "taskList",
5777 "attrs": {"localId": "list-001"},
5778 "content": [{
5779 "type": "taskItem",
5780 "attrs": {"localId": "task-001", "state": "TODO"},
5781 "content": [{"type": "text", "text": "Do something"}]
5782 }]
5783 }]
5784 }"#;
5785 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5786 let md = adf_to_markdown(&doc).unwrap();
5787 assert!(md.contains("{localId=task-001}"), "got: {md}");
5788 assert!(md.contains("{localId=list-001}"), "got: {md}");
5789 }
5790
5791 #[test]
5793 fn round_trip_task_list_unwrapped_inline() {
5794 let json = r#"{
5795 "version": 1,
5796 "type": "doc",
5797 "content": [{
5798 "type": "taskList",
5799 "attrs": {"localId": "list-001"},
5800 "content": [
5801 {
5802 "type": "taskItem",
5803 "attrs": {"localId": "task-001", "state": "TODO"},
5804 "content": [{"type": "text", "text": "Do something"}]
5805 },
5806 {
5807 "type": "taskItem",
5808 "attrs": {"localId": "task-002", "state": "DONE"},
5809 "content": [{"type": "text", "text": "Already done"}]
5810 }
5811 ]
5812 }]
5813 }"#;
5814 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5815 let md = adf_to_markdown(&doc).unwrap();
5816
5817 let doc2 = markdown_to_adf(&md).unwrap();
5819 assert_eq!(doc2.content[0].node_type, "taskList");
5820
5821 let items = doc2.content[0].content.as_ref().unwrap();
5822 assert_eq!(items.len(), 2);
5823 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5824 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5825
5826 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5828 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "task-002");
5829 assert_eq!(
5830 doc2.content[0].attrs.as_ref().unwrap()["localId"],
5831 "list-001"
5832 );
5833 }
5834
5835 #[test]
5837 fn adf_task_item_unwrapped_inline_then_block() {
5838 let json = r#"{
5839 "version": 1,
5840 "type": "doc",
5841 "content": [{
5842 "type": "taskList",
5843 "attrs": {"localId": "list-001"},
5844 "content": [{
5845 "type": "taskItem",
5846 "attrs": {"localId": "task-001", "state": "TODO"},
5847 "content": [
5848 {"type": "text", "text": "Parent task"},
5849 {
5850 "type": "bulletList",
5851 "content": [{
5852 "type": "listItem",
5853 "content": [{
5854 "type": "paragraph",
5855 "content": [{"type": "text", "text": "sub-item"}]
5856 }]
5857 }]
5858 }
5859 ]
5860 }]
5861 }]
5862 }"#;
5863 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5864 let md = adf_to_markdown(&doc).unwrap();
5865 assert!(md.contains("- [ ] Parent task"), "got: {md}");
5866 assert!(md.contains(" - sub-item"), "got: {md}");
5867 assert!(!md.contains("adf-unsupported"), "got: {md}");
5868 }
5869
5870 #[test]
5872 fn adf_task_item_empty_content() {
5873 let json = r#"{
5874 "version": 1,
5875 "type": "doc",
5876 "content": [{
5877 "type": "taskList",
5878 "attrs": {"localId": "list-001"},
5879 "content": [{
5880 "type": "taskItem",
5881 "attrs": {"localId": "task-001", "state": "TODO"},
5882 "content": []
5883 }]
5884 }]
5885 }"#;
5886 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5887 let md = adf_to_markdown(&doc).unwrap();
5888 assert!(md.contains("- [ ] "), "got: {md}");
5889 assert!(!md.contains("adf-unsupported"), "got: {md}");
5890 }
5891
5892 #[test]
5895 fn adf_nested_task_item_renders_without_corruption() {
5896 let json = r#"{
5897 "type": "doc",
5898 "version": 1,
5899 "content": [{
5900 "type": "taskList",
5901 "attrs": {"localId": ""},
5902 "content": [
5903 {
5904 "type": "taskItem",
5905 "attrs": {"localId": "aabbccdd-1234-5678-abcd-aabbccdd1234", "state": "TODO"},
5906 "content": [{"type": "text", "text": "Normal task"}]
5907 },
5908 {
5909 "type": "taskItem",
5910 "attrs": {"localId": ""},
5911 "content": [
5912 {
5913 "type": "taskItem",
5914 "attrs": {"localId": "bbccddee-2345-6789-bcde-bbccddee2345", "state": "TODO"},
5915 "content": [{"type": "text", "text": "Nested task one"}]
5916 },
5917 {
5918 "type": "taskItem",
5919 "attrs": {"localId": "ccddee11-3456-7890-cdef-ccddee113456", "state": "DONE"},
5920 "content": [{"type": "text", "text": "Nested task two"}]
5921 }
5922 ]
5923 }
5924 ]
5925 }]
5926 }"#;
5927 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5928 let md = adf_to_markdown(&doc).unwrap();
5929 assert!(md.contains("- [ ] Normal task"), "got: {md}");
5931 assert!(!md.contains("adf-unsupported"), "got: {md}");
5933 assert!(md.contains(" - [ ] Nested task one"), "got: {md}");
5934 assert!(md.contains(" - [x] Nested task two"), "got: {md}");
5935 }
5936
5937 #[test]
5939 fn round_trip_nested_task_item() {
5940 let json = r#"{
5941 "type": "doc",
5942 "version": 1,
5943 "content": [{
5944 "type": "taskList",
5945 "attrs": {"localId": ""},
5946 "content": [
5947 {
5948 "type": "taskItem",
5949 "attrs": {"localId": "task-001", "state": "TODO"},
5950 "content": [{"type": "text", "text": "Normal task"}]
5951 },
5952 {
5953 "type": "taskItem",
5954 "attrs": {"localId": ""},
5955 "content": [
5956 {
5957 "type": "taskItem",
5958 "attrs": {"localId": "task-002", "state": "TODO"},
5959 "content": [{"type": "text", "text": "Nested one"}]
5960 },
5961 {
5962 "type": "taskItem",
5963 "attrs": {"localId": "task-003", "state": "DONE"},
5964 "content": [{"type": "text", "text": "Nested two"}]
5965 }
5966 ]
5967 }
5968 ]
5969 }]
5970 }"#;
5971 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5972 let md = adf_to_markdown(&doc).unwrap();
5973 let doc2 = markdown_to_adf(&md).unwrap();
5974
5975 assert_eq!(doc2.content[0].node_type, "taskList");
5977 let items = doc2.content[0].content.as_ref().unwrap();
5978 assert_eq!(items.len(), 2, "expected 2 top-level items, got: {items:?}");
5979
5980 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5982 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5983 let first_content = items[0].content.as_ref().unwrap();
5984 assert_eq!(first_content[0].text.as_deref(), Some("Normal task"));
5985
5986 let container = &items[1];
5988 assert_eq!(container.node_type, "taskItem");
5989 let c_attrs = container.attrs.as_ref().unwrap();
5990 assert!(
5991 c_attrs.get("state").is_none(),
5992 "container should have no state attr, got: {c_attrs:?}"
5993 );
5994
5995 let container_content = container.content.as_ref().unwrap();
5997 assert_eq!(
5998 container_content.len(),
5999 2,
6000 "expected 2 bare taskItem children"
6001 );
6002 assert_eq!(container_content[0].node_type, "taskItem");
6003 assert_eq!(
6004 container_content[0].attrs.as_ref().unwrap()["state"],
6005 "TODO"
6006 );
6007 assert_eq!(
6008 container_content[0].attrs.as_ref().unwrap()["localId"],
6009 "task-002"
6010 );
6011 assert_eq!(container_content[1].node_type, "taskItem");
6012 assert_eq!(
6013 container_content[1].attrs.as_ref().unwrap()["state"],
6014 "DONE"
6015 );
6016 assert_eq!(
6017 container_content[1].attrs.as_ref().unwrap()["localId"],
6018 "task-003"
6019 );
6020 }
6021
6022 #[test]
6024 fn adf_nested_task_item_preserves_local_ids() {
6025 let json = r#"{
6026 "type": "doc",
6027 "version": 1,
6028 "content": [{
6029 "type": "taskList",
6030 "attrs": {"localId": "list-001"},
6031 "content": [{
6032 "type": "taskItem",
6033 "attrs": {"localId": "container-001", "state": "TODO"},
6034 "content": [{
6035 "type": "taskItem",
6036 "attrs": {"localId": "child-001", "state": "DONE"},
6037 "content": [{"type": "text", "text": "Nested child"}]
6038 }]
6039 }]
6040 }]
6041 }"#;
6042 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6043 let md = adf_to_markdown(&doc).unwrap();
6044 assert!(
6046 md.contains("localId=container-001"),
6047 "container localId missing: {md}"
6048 );
6049 assert!(
6051 md.contains("localId=child-001"),
6052 "child localId missing: {md}"
6053 );
6054 assert!(!md.contains("adf-unsupported"), "got: {md}");
6055 }
6056
6057 #[test]
6060 fn adf_nested_task_item_mixed_with_block_node() {
6061 let json = r#"{
6062 "type": "doc",
6063 "version": 1,
6064 "content": [{
6065 "type": "taskList",
6066 "attrs": {"localId": ""},
6067 "content": [{
6068 "type": "taskItem",
6069 "attrs": {"localId": "", "state": "TODO"},
6070 "content": [
6071 {
6072 "type": "taskItem",
6073 "attrs": {"localId": "", "state": "TODO"},
6074 "content": [{"type": "text", "text": "A nested task"}]
6075 },
6076 {
6077 "type": "paragraph",
6078 "content": [{"type": "text", "text": "Stray paragraph"}]
6079 }
6080 ]
6081 }]
6082 }]
6083 }"#;
6084 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6085 let md = adf_to_markdown(&doc).unwrap();
6086 assert!(md.contains(" - [ ] A nested task"), "got: {md}");
6087 assert!(md.contains(" Stray paragraph"), "got: {md}");
6088 assert!(!md.contains("adf-unsupported"), "got: {md}");
6089 }
6090
6091 #[test]
6095 fn task_item_with_text_and_nested_sub_content() {
6096 let md = "- [ ] Parent task\n - [ ] Sub task\n";
6097 let doc = markdown_to_adf(md).unwrap();
6098 assert_eq!(doc.content[0].node_type, "taskList");
6099 let items = doc.content[0].content.as_ref().unwrap();
6100 assert_eq!(items.len(), 2, "got: {items:?}");
6103 let parent = &items[0];
6104 assert_eq!(parent.attrs.as_ref().unwrap()["state"], "TODO");
6105 let parent_content = parent.content.as_ref().unwrap();
6106 assert_eq!(parent_content[0].text.as_deref(), Some("Parent task"));
6107 assert_eq!(items[1].node_type, "taskList");
6109 let nested = items[1].content.as_ref().unwrap();
6110 assert_eq!(nested.len(), 1);
6111 assert_eq!(nested[0].attrs.as_ref().unwrap()["state"], "TODO");
6112 }
6113
6114 #[test]
6118 fn task_item_empty_with_non_tasklist_sub_content() {
6119 let md = "- [ ] \n Some paragraph text\n";
6120 let doc = markdown_to_adf(md).unwrap();
6121 assert_eq!(doc.content[0].node_type, "taskList");
6122 let items = doc.content[0].content.as_ref().unwrap();
6123 assert_eq!(items.len(), 1);
6124 let item = &items[0];
6125 assert_eq!(item.attrs.as_ref().unwrap()["state"], "TODO");
6126 let content = item.content.as_ref().unwrap();
6127 assert_eq!(content[0].node_type, "paragraph");
6129 }
6130
6131 #[test]
6133 fn adf_nested_task_item_single_child() {
6134 let json = r#"{
6135 "type": "doc",
6136 "version": 1,
6137 "content": [{
6138 "type": "taskList",
6139 "attrs": {"localId": ""},
6140 "content": [{
6141 "type": "taskItem",
6142 "attrs": {"localId": "", "state": "TODO"},
6143 "content": [{
6144 "type": "taskItem",
6145 "attrs": {"localId": "", "state": "DONE"},
6146 "content": [{"type": "text", "text": "Only child"}]
6147 }]
6148 }]
6149 }]
6150 }"#;
6151 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6152 let md = adf_to_markdown(&doc).unwrap();
6153 assert!(md.contains(" - [x] Only child"), "got: {md}");
6154 assert!(!md.contains("adf-unsupported"), "got: {md}");
6155 }
6156
6157 #[test]
6160 fn adf_nested_tasklist_sibling_renders_indented() {
6161 let json = r#"{
6162 "version": 1,
6163 "type": "doc",
6164 "content": [{
6165 "type": "taskList",
6166 "attrs": {"localId": ""},
6167 "content": [
6168 {
6169 "type": "taskItem",
6170 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000001", "state": "TODO"},
6171 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
6172 },
6173 {
6174 "type": "taskList",
6175 "attrs": {"localId": ""},
6176 "content": [{
6177 "type": "taskItem",
6178 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000002", "state": "TODO"},
6179 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
6180 }]
6181 },
6182 {
6183 "type": "taskItem",
6184 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000003", "state": "TODO"},
6185 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
6186 }
6187 ]
6188 }]
6189 }"#;
6190 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6191 let md = adf_to_markdown(&doc).unwrap();
6192 assert!(md.contains("- [ ] parent task one"), "got: {md}");
6194 assert!(md.contains(" - [ ] nested sub-task"), "got: {md}");
6195 assert!(md.contains("- [ ] parent task two"), "got: {md}");
6196 }
6197
6198 #[test]
6200 fn round_trip_nested_tasklist_preserves_type() {
6201 let json = r#"{
6202 "version": 1,
6203 "type": "doc",
6204 "content": [{
6205 "type": "taskList",
6206 "attrs": {"localId": ""},
6207 "content": [
6208 {
6209 "type": "taskItem",
6210 "attrs": {"localId": "", "state": "TODO"},
6211 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
6212 },
6213 {
6214 "type": "taskList",
6215 "attrs": {"localId": ""},
6216 "content": [{
6217 "type": "taskItem",
6218 "attrs": {"localId": "", "state": "TODO"},
6219 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
6220 }]
6221 },
6222 {
6223 "type": "taskItem",
6224 "attrs": {"localId": "", "state": "TODO"},
6225 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
6226 }
6227 ]
6228 }]
6229 }"#;
6230 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6231 let md = adf_to_markdown(&doc).unwrap();
6232 let rt_doc = markdown_to_adf(&md).unwrap();
6233 assert_eq!(rt_doc.content[0].node_type, "taskList");
6235 let items = rt_doc.content[0].content.as_ref().unwrap();
6236 assert_eq!(items.len(), 3, "got: {items:?}");
6239 assert_eq!(items[0].node_type, "taskItem");
6240 assert_eq!(
6241 items[1].node_type, "taskList",
6242 "nested taskList should survive round-trip"
6243 );
6244 assert_eq!(items[2].node_type, "taskItem");
6245 let nested_items = items[1].content.as_ref().unwrap();
6246 assert_eq!(nested_items[0].attrs.as_ref().unwrap()["state"], "TODO");
6247 }
6248
6249 #[test]
6251 fn adf_nested_tasklist_done_state() {
6252 let json = r#"{
6253 "version": 1,
6254 "type": "doc",
6255 "content": [{
6256 "type": "taskList",
6257 "attrs": {"localId": ""},
6258 "content": [
6259 {
6260 "type": "taskItem",
6261 "attrs": {"localId": "", "state": "TODO"},
6262 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
6263 },
6264 {
6265 "type": "taskList",
6266 "attrs": {"localId": ""},
6267 "content": [{
6268 "type": "taskItem",
6269 "attrs": {"localId": "", "state": "DONE"},
6270 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "done child"}]}]
6271 }]
6272 }
6273 ]
6274 }]
6275 }"#;
6276 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6277 let md = adf_to_markdown(&doc).unwrap();
6278 assert!(md.contains(" - [x] done child"), "got: {md}");
6279 let rt_doc = markdown_to_adf(&md).unwrap();
6281 let items = rt_doc.content[0].content.as_ref().unwrap();
6282 assert_eq!(
6283 items[1].node_type, "taskList",
6284 "nested taskList should survive round-trip"
6285 );
6286 let nested_item = &items[1].content.as_ref().unwrap()[0];
6287 assert_eq!(nested_item.attrs.as_ref().unwrap()["state"], "DONE");
6288 }
6289
6290 #[test]
6292 fn adf_multiple_nested_tasklists() {
6293 let json = r#"{
6294 "version": 1,
6295 "type": "doc",
6296 "content": [{
6297 "type": "taskList",
6298 "attrs": {"localId": ""},
6299 "content": [
6300 {
6301 "type": "taskItem",
6302 "attrs": {"localId": "", "state": "TODO"},
6303 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "first parent"}]}]
6304 },
6305 {
6306 "type": "taskList",
6307 "attrs": {"localId": ""},
6308 "content": [{
6309 "type": "taskItem",
6310 "attrs": {"localId": "", "state": "TODO"},
6311 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child A"}]}]
6312 }]
6313 },
6314 {
6315 "type": "taskItem",
6316 "attrs": {"localId": "", "state": "TODO"},
6317 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "second parent"}]}]
6318 },
6319 {
6320 "type": "taskList",
6321 "attrs": {"localId": ""},
6322 "content": [{
6323 "type": "taskItem",
6324 "attrs": {"localId": "", "state": "DONE"},
6325 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child B"}]}]
6326 }]
6327 }
6328 ]
6329 }]
6330 }"#;
6331 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6332 let md = adf_to_markdown(&doc).unwrap();
6333 assert!(md.contains("- [ ] first parent"), "got: {md}");
6334 assert!(md.contains(" - [ ] child A"), "got: {md}");
6335 assert!(md.contains("- [ ] second parent"), "got: {md}");
6336 assert!(md.contains(" - [x] child B"), "got: {md}");
6337 }
6338
6339 #[test]
6342 fn round_trip_nested_tasklist_stable() {
6343 let json = r#"{
6344 "version": 1,
6345 "type": "doc",
6346 "content": [{
6347 "type": "taskList",
6348 "attrs": {"localId": ""},
6349 "content": [
6350 {
6351 "type": "taskItem",
6352 "attrs": {"localId": "", "state": "TODO"},
6353 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
6354 },
6355 {
6356 "type": "taskList",
6357 "attrs": {"localId": ""},
6358 "content": [{
6359 "type": "taskItem",
6360 "attrs": {"localId": "", "state": "TODO"},
6361 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child"}]}]
6362 }]
6363 }
6364 ]
6365 }]
6366 }"#;
6367 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6368 let md1 = adf_to_markdown(&doc).unwrap();
6370 let rt1 = markdown_to_adf(&md1).unwrap();
6371 let md2 = adf_to_markdown(&rt1).unwrap();
6373 let rt2 = markdown_to_adf(&md2).unwrap();
6374 assert_eq!(md1, md2, "markdown should be stable across round-trips");
6376 let rt1_json = serde_json::to_string(&rt1).unwrap();
6378 let rt2_json = serde_json::to_string(&rt2).unwrap();
6379 assert_eq!(
6380 rt1_json, rt2_json,
6381 "ADF should be stable across round-trips"
6382 );
6383 }
6384
6385 #[test]
6390 fn task_item_mixed_sub_content_splits_siblings() {
6391 let md = "- [ ] Parent task\n - [ ] Sub task\n Some paragraph\n";
6392 let doc = markdown_to_adf(md).unwrap();
6393 let items = doc.content[0].content.as_ref().unwrap();
6394 assert_eq!(items.len(), 2, "got: {items:?}");
6396 assert_eq!(items[0].node_type, "taskItem");
6397 let parent_content = items[0].content.as_ref().unwrap();
6398 assert!(
6400 parent_content.iter().any(|n| n.node_type == "paragraph"),
6401 "non-taskList sub-content should stay as child: {parent_content:?}"
6402 );
6403 assert_eq!(items[1].node_type, "taskList");
6405 }
6406
6407 #[test]
6411 fn empty_task_item_mixed_sub_content_none_arm() {
6412 let md = "- [ ] \n Some paragraph\n - [ ] Sub task\n";
6413 let doc = markdown_to_adf(md).unwrap();
6414 let items = doc.content[0].content.as_ref().unwrap();
6415 assert_eq!(items.len(), 2, "got: {items:?}");
6417 assert_eq!(items[0].node_type, "taskItem");
6418 let parent_content = items[0].content.as_ref().unwrap();
6419 assert!(
6420 parent_content.iter().any(|n| n.node_type == "paragraph"),
6421 "paragraph should be assigned to taskItem: {parent_content:?}"
6422 );
6423 assert_eq!(items[1].node_type, "taskList");
6424 }
6425
6426 #[test]
6431 fn task_item_text_with_non_tasklist_sub_content_only() {
6432 let md = "- [ ] My task\n Extra paragraph content\n";
6433 let doc = markdown_to_adf(md).unwrap();
6434 let items = doc.content[0].content.as_ref().unwrap();
6435 assert_eq!(items.len(), 1, "got: {items:?}");
6437 assert_eq!(items[0].node_type, "taskItem");
6438 let content = items[0].content.as_ref().unwrap();
6439 assert!(
6441 content.iter().any(|n| n.node_type == "paragraph"),
6442 "paragraph sub-content should be a child of taskItem: {content:?}"
6443 );
6444 }
6445
6446 #[test]
6449 fn adf_list_item_leading_block_node() {
6450 let json = r#"{
6451 "version": 1,
6452 "type": "doc",
6453 "content": [{
6454 "type": "bulletList",
6455 "content": [{
6456 "type": "listItem",
6457 "content": [{
6458 "type": "codeBlock",
6459 "attrs": {"language": "rust"},
6460 "content": [{"type": "text", "text": "let x = 1;"}]
6461 }]
6462 }]
6463 }]
6464 }"#;
6465 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6466 let md = adf_to_markdown(&doc).unwrap();
6467 assert!(md.contains("```rust"), "got: {md}");
6468 assert!(md.contains("let x = 1;"), "got: {md}");
6469 for line in md.lines() {
6472 if line.starts_with("- ") {
6473 continue; }
6475 if line.trim().is_empty() {
6476 continue;
6477 }
6478 assert!(
6479 line.starts_with(" "),
6480 "continuation line not indented: {line:?}"
6481 );
6482 }
6483 }
6484
6485 #[test]
6488 fn code_block_in_list_item_backtick_roundtrip() {
6489 let json = r#"{
6490 "version": 1,
6491 "type": "doc",
6492 "content": [{
6493 "type": "bulletList",
6494 "content": [{
6495 "type": "listItem",
6496 "content": [{
6497 "type": "codeBlock",
6498 "attrs": {"language": ""},
6499 "content": [{"type": "text", "text": "error: some value with a backtick ` at end"}]
6500 }]
6501 }]
6502 }]
6503 }"#;
6504 let original: AdfDocument = serde_json::from_str(json).unwrap();
6505 let md = adf_to_markdown(&original).unwrap();
6506 let roundtripped = markdown_to_adf(&md).unwrap();
6507 let list = &roundtripped.content[0];
6508 assert_eq!(list.node_type, "bulletList", "top node: {}", list.node_type);
6509 let item = &list.content.as_ref().unwrap()[0];
6510 let first_child = &item.content.as_ref().unwrap()[0];
6511 assert_eq!(
6512 first_child.node_type, "codeBlock",
6513 "expected codeBlock, got: {}",
6514 first_child.node_type
6515 );
6516 let text = first_child.content.as_ref().unwrap()[0]
6517 .text
6518 .as_deref()
6519 .unwrap();
6520 assert_eq!(text, "error: some value with a backtick ` at end");
6521 }
6522
6523 #[test]
6525 fn code_block_with_language_in_list_item_roundtrip() {
6526 let json = r#"{
6527 "version": 1,
6528 "type": "doc",
6529 "content": [{
6530 "type": "bulletList",
6531 "content": [{
6532 "type": "listItem",
6533 "content": [{
6534 "type": "codeBlock",
6535 "attrs": {"language": "rust"},
6536 "content": [{"type": "text", "text": "fn main() {\n println!(\"hello\");\n}"}]
6537 }]
6538 }]
6539 }]
6540 }"#;
6541 let original: AdfDocument = serde_json::from_str(json).unwrap();
6542 let md = adf_to_markdown(&original).unwrap();
6543 let roundtripped = markdown_to_adf(&md).unwrap();
6544 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6545 let code = &item.content.as_ref().unwrap()[0];
6546 assert_eq!(code.node_type, "codeBlock");
6547 let lang = code
6548 .attrs
6549 .as_ref()
6550 .and_then(|a| a.get("language"))
6551 .and_then(serde_json::Value::as_str)
6552 .unwrap_or("");
6553 assert_eq!(lang, "rust");
6554 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6555 assert!(text.contains("println!"), "code content: {text}");
6556 }
6557
6558 #[test]
6560 fn code_block_in_ordered_list_item_roundtrip() {
6561 let json = r#"{
6562 "version": 1,
6563 "type": "doc",
6564 "content": [{
6565 "type": "orderedList",
6566 "attrs": {"order": 1},
6567 "content": [{
6568 "type": "listItem",
6569 "content": [{
6570 "type": "codeBlock",
6571 "attrs": {"language": ""},
6572 "content": [{"type": "text", "text": "backtick ` here"}]
6573 }]
6574 }]
6575 }]
6576 }"#;
6577 let original: AdfDocument = serde_json::from_str(json).unwrap();
6578 let md = adf_to_markdown(&original).unwrap();
6579 let roundtripped = markdown_to_adf(&md).unwrap();
6580 let list = &roundtripped.content[0];
6581 assert_eq!(list.node_type, "orderedList");
6582 let item = &list.content.as_ref().unwrap()[0];
6583 let code = &item.content.as_ref().unwrap()[0];
6584 assert_eq!(code.node_type, "codeBlock");
6585 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6586 assert_eq!(text, "backtick ` here");
6587 }
6588
6589 #[test]
6591 fn code_block_then_paragraph_in_list_item() {
6592 let json = r#"{
6593 "version": 1,
6594 "type": "doc",
6595 "content": [{
6596 "type": "bulletList",
6597 "content": [{
6598 "type": "listItem",
6599 "content": [
6600 {
6601 "type": "codeBlock",
6602 "attrs": {"language": ""},
6603 "content": [{"type": "text", "text": "code with ` backtick"}]
6604 },
6605 {
6606 "type": "paragraph",
6607 "content": [{"type": "text", "text": "description"}]
6608 }
6609 ]
6610 }]
6611 }]
6612 }"#;
6613 let original: AdfDocument = serde_json::from_str(json).unwrap();
6614 let md = adf_to_markdown(&original).unwrap();
6615 let roundtripped = markdown_to_adf(&md).unwrap();
6616 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6617 let children = item.content.as_ref().unwrap();
6618 assert_eq!(children[0].node_type, "codeBlock");
6619 assert_eq!(children[1].node_type, "paragraph");
6620 }
6621
6622 #[test]
6624 fn code_block_multiple_backticks_in_list_item() {
6625 let json = r#"{
6626 "version": 1,
6627 "type": "doc",
6628 "content": [{
6629 "type": "bulletList",
6630 "content": [{
6631 "type": "listItem",
6632 "content": [{
6633 "type": "codeBlock",
6634 "attrs": {"language": ""},
6635 "content": [{"type": "text", "text": "a ` b `` c ``` d"}]
6636 }]
6637 }]
6638 }]
6639 }"#;
6640 let original: AdfDocument = serde_json::from_str(json).unwrap();
6641 let md = adf_to_markdown(&original).unwrap();
6642 let roundtripped = markdown_to_adf(&md).unwrap();
6643 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6644 let code = &item.content.as_ref().unwrap()[0];
6645 assert_eq!(code.node_type, "codeBlock");
6646 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6647 assert_eq!(text, "a ` b `` c ``` d");
6648 }
6649
6650 #[test]
6653 fn media_first_child_with_sub_content_in_list_item() {
6654 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
6655 {"type":"listItem","content":[
6656 {"type":"mediaSingle","attrs":{"layout":"center"},
6657 "content":[{"type":"media","attrs":{"type":"file","id":"img-99","collection":"col-x","height":50,"width":100}}]},
6658 {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
6659 ]}
6660 ]}]}"#;
6661 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6662 let md = adf_to_markdown(&doc).unwrap();
6663 let rt = markdown_to_adf(&md).unwrap();
6664 let item = &rt.content[0].content.as_ref().unwrap()[0];
6665 let children = item.content.as_ref().unwrap();
6666 assert_eq!(
6667 children.len(),
6668 2,
6669 "expected 2 children, got {}",
6670 children.len()
6671 );
6672 assert_eq!(children[0].node_type, "mediaSingle");
6673 let media = &children[0].content.as_ref().unwrap()[0];
6674 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-99");
6675 assert_eq!(children[1].node_type, "paragraph");
6676 }
6677
6678 #[test]
6679 fn inline_bold() {
6680 let doc = markdown_to_adf("Some **bold** text").unwrap();
6681 let content = doc.content[0].content.as_ref().unwrap();
6682 assert!(content.len() >= 3);
6683 let bold_node = &content[1];
6684 assert_eq!(bold_node.text.as_deref(), Some("bold"));
6685 let marks = bold_node.marks.as_ref().unwrap();
6686 assert_eq!(marks[0].mark_type, "strong");
6687 }
6688
6689 #[test]
6690 fn inline_italic() {
6691 let doc = markdown_to_adf("Some *italic* text").unwrap();
6692 let content = doc.content[0].content.as_ref().unwrap();
6693 let italic_node = &content[1];
6694 assert_eq!(italic_node.text.as_deref(), Some("italic"));
6695 let marks = italic_node.marks.as_ref().unwrap();
6696 assert_eq!(marks[0].mark_type, "em");
6697 }
6698
6699 #[test]
6700 fn inline_code() {
6701 let doc = markdown_to_adf("Use `code` here").unwrap();
6702 let content = doc.content[0].content.as_ref().unwrap();
6703 let code_node = &content[1];
6704 assert_eq!(code_node.text.as_deref(), Some("code"));
6705 let marks = code_node.marks.as_ref().unwrap();
6706 assert_eq!(marks[0].mark_type, "code");
6707 }
6708
6709 #[test]
6713 fn inline_code_with_backtick_emitted_with_double_delimiters() {
6714 let doc = AdfDocument {
6715 version: 1,
6716 doc_type: "doc".to_string(),
6717 content: vec![AdfNode::paragraph(vec![
6718 AdfNode::text("Run "),
6719 AdfNode::text_with_marks(
6720 "ADD `custom_threshold` TEXT NOT NULL",
6721 vec![AdfMark::code()],
6722 ),
6723 AdfNode::text(" to update the schema."),
6724 ])],
6725 };
6726 let md = adf_to_markdown(&doc).unwrap();
6727 assert!(
6728 md.contains("``ADD `custom_threshold` TEXT NOT NULL``"),
6729 "expected double-backtick delimiters, got: {md}"
6730 );
6731 }
6732
6733 #[test]
6736 fn inline_code_double_backtick_delimiters_parse() {
6737 let doc = markdown_to_adf("Run ``ADD `custom_threshold` TEXT NOT NULL`` now").unwrap();
6738 let content = doc.content[0].content.as_ref().unwrap();
6739 assert_eq!(content.len(), 3, "content: {content:?}");
6740 let code_node = &content[1];
6741 assert_eq!(
6742 code_node.text.as_deref(),
6743 Some("ADD `custom_threshold` TEXT NOT NULL")
6744 );
6745 let marks = code_node.marks.as_ref().unwrap();
6746 assert_eq!(marks[0].mark_type, "code");
6747 }
6748
6749 #[test]
6752 fn inline_code_with_backtick_roundtrip() {
6753 let json = r#"{
6754 "version": 1,
6755 "type": "doc",
6756 "content": [{
6757 "type": "paragraph",
6758 "content": [
6759 {"type": "text", "text": "Run "},
6760 {
6761 "type": "text",
6762 "text": "ADD `custom_threshold` TEXT NOT NULL",
6763 "marks": [{"type": "code"}]
6764 },
6765 {"type": "text", "text": " to update the schema."}
6766 ]
6767 }]
6768 }"#;
6769 let original: AdfDocument = serde_json::from_str(json).unwrap();
6770 let md = adf_to_markdown(&original).unwrap();
6771 let roundtripped = markdown_to_adf(&md).unwrap();
6772 let para = &roundtripped.content[0];
6773 let children = para.content.as_ref().unwrap();
6774 assert_eq!(children.len(), 3, "expected 3 children, got: {children:?}");
6775 assert_eq!(children[0].text.as_deref(), Some("Run "));
6776 assert_eq!(
6777 children[1].text.as_deref(),
6778 Some("ADD `custom_threshold` TEXT NOT NULL")
6779 );
6780 let marks = children[1].marks.as_ref().unwrap();
6781 assert_eq!(marks.len(), 1);
6782 assert_eq!(marks[0].mark_type, "code");
6783 assert_eq!(children[2].text.as_deref(), Some(" to update the schema."));
6784 }
6785
6786 #[test]
6791 fn inline_code_with_double_backtick_roundtrip() {
6792 let doc = AdfDocument {
6793 version: 1,
6794 doc_type: "doc".to_string(),
6795 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6796 "x `` y",
6797 vec![AdfMark::code()],
6798 )])],
6799 };
6800 let md = adf_to_markdown(&doc).unwrap();
6801 let roundtripped = markdown_to_adf(&md).unwrap();
6802 let content = roundtripped.content[0].content.as_ref().unwrap();
6803 assert_eq!(content.len(), 1);
6804 assert_eq!(content[0].text.as_deref(), Some("x `` y"));
6805 let marks = content[0].marks.as_ref().unwrap();
6806 assert_eq!(marks[0].mark_type, "code");
6807 }
6808
6809 #[test]
6812 fn inline_code_leading_backtick_roundtrip() {
6813 let doc = AdfDocument {
6814 version: 1,
6815 doc_type: "doc".to_string(),
6816 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6817 "`start",
6818 vec![AdfMark::code()],
6819 )])],
6820 };
6821 let md = adf_to_markdown(&doc).unwrap();
6822 let roundtripped = markdown_to_adf(&md).unwrap();
6823 let content = roundtripped.content[0].content.as_ref().unwrap();
6824 assert_eq!(content[0].text.as_deref(), Some("`start"));
6825 assert_eq!(content[0].marks.as_ref().unwrap()[0].mark_type, "code");
6826 }
6827
6828 #[test]
6830 fn inline_code_trailing_backtick_roundtrip() {
6831 let doc = AdfDocument {
6832 version: 1,
6833 doc_type: "doc".to_string(),
6834 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6835 "end`",
6836 vec![AdfMark::code()],
6837 )])],
6838 };
6839 let md = adf_to_markdown(&doc).unwrap();
6840 let roundtripped = markdown_to_adf(&md).unwrap();
6841 let content = roundtripped.content[0].content.as_ref().unwrap();
6842 assert_eq!(content[0].text.as_deref(), Some("end`"));
6843 }
6844
6845 #[test]
6848 fn inline_code_space_padded_content_roundtrip() {
6849 let doc = AdfDocument {
6850 version: 1,
6851 doc_type: "doc".to_string(),
6852 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6853 " foo ",
6854 vec![AdfMark::code()],
6855 )])],
6856 };
6857 let md = adf_to_markdown(&doc).unwrap();
6858 let roundtripped = markdown_to_adf(&md).unwrap();
6859 let content = roundtripped.content[0].content.as_ref().unwrap();
6860 assert_eq!(content[0].text.as_deref(), Some(" foo "));
6861 }
6862
6863 #[test]
6866 fn inline_code_all_spaces_roundtrip() {
6867 let doc = AdfDocument {
6868 version: 1,
6869 doc_type: "doc".to_string(),
6870 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6871 " ",
6872 vec![AdfMark::code()],
6873 )])],
6874 };
6875 let md = adf_to_markdown(&doc).unwrap();
6876 let roundtripped = markdown_to_adf(&md).unwrap();
6877 let content = roundtripped.content[0].content.as_ref().unwrap();
6878 assert_eq!(content[0].text.as_deref(), Some(" "));
6879 }
6880
6881 #[test]
6884 fn inline_code_with_link_and_backtick_roundtrip() {
6885 let doc = AdfDocument {
6886 version: 1,
6887 doc_type: "doc".to_string(),
6888 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6889 "fn `inner`",
6890 vec![AdfMark::code(), AdfMark::link("https://example.com")],
6891 )])],
6892 };
6893 let md = adf_to_markdown(&doc).unwrap();
6894 assert!(
6895 md.contains("`` fn `inner` ``"),
6896 "expected padded double-backtick delimiters inside link, got: {md}"
6897 );
6898 let roundtripped = markdown_to_adf(&md).unwrap();
6899 let content = roundtripped.content[0].content.as_ref().unwrap();
6900 assert_eq!(content[0].text.as_deref(), Some("fn `inner`"));
6901 let mark_types: Vec<&str> = content[0]
6902 .marks
6903 .as_ref()
6904 .unwrap()
6905 .iter()
6906 .map(|m| m.mark_type.as_str())
6907 .collect();
6908 assert!(mark_types.contains(&"code"));
6909 assert!(mark_types.contains(&"link"));
6910 }
6911
6912 #[test]
6914 fn inline_code_unmatched_run_is_plain_text() {
6915 let doc = markdown_to_adf("foo ``bar baz").unwrap();
6916 let content = doc.content[0].content.as_ref().unwrap();
6917 assert_eq!(content.len(), 1);
6918 assert_eq!(content[0].text.as_deref(), Some("foo ``bar baz"));
6919 assert!(content[0].marks.is_none());
6920 }
6921
6922 #[test]
6926 fn inline_code_mismatched_delimiters_is_plain_text() {
6927 let doc = markdown_to_adf("``foo` bar").unwrap();
6928 let content = doc.content[0].content.as_ref().unwrap();
6929 assert_eq!(content.len(), 1);
6930 assert_eq!(content[0].text.as_deref(), Some("``foo` bar"));
6931 assert!(content[0].marks.is_none());
6932 }
6933
6934 #[test]
6935 fn inline_code_delimiter_chooses_correct_length() {
6936 assert_eq!(inline_code_delimiter("no ticks"), (1, false));
6937 assert_eq!(inline_code_delimiter("one ` here"), (2, false));
6938 assert_eq!(inline_code_delimiter("two `` here"), (3, false));
6939 assert_eq!(inline_code_delimiter("three ``` here"), (4, false));
6940 assert_eq!(inline_code_delimiter("`leading"), (2, true));
6941 assert_eq!(inline_code_delimiter("trailing`"), (2, true));
6942 assert_eq!(inline_code_delimiter(" foo "), (1, true));
6943 assert_eq!(inline_code_delimiter(" "), (1, false));
6944 assert_eq!(inline_code_delimiter(" "), (1, false));
6945 assert_eq!(inline_code_delimiter(" foo"), (1, false));
6946 }
6947
6948 #[test]
6949 fn try_parse_inline_code_strips_paired_spaces() {
6950 let (end, content) = try_parse_inline_code("`` `foo` ``", 0).unwrap();
6951 assert_eq!(end, 11);
6952 assert_eq!(content, "`foo`");
6953 }
6954
6955 #[test]
6956 fn try_parse_inline_code_all_space_content_is_preserved() {
6957 let (_end, content) = try_parse_inline_code("` `", 0).unwrap();
6958 assert_eq!(content, " ");
6959 }
6960
6961 #[test]
6962 fn try_parse_inline_code_single_run_matches_first_close() {
6963 let (end, content) = try_parse_inline_code("`foo` tail", 0).unwrap();
6964 assert_eq!(end, 5);
6965 assert_eq!(content, "foo");
6966 }
6967
6968 #[test]
6969 fn try_parse_inline_code_no_match_returns_none() {
6970 assert!(try_parse_inline_code("``unmatched", 0).is_none());
6971 assert!(try_parse_inline_code("plain text", 0).is_none());
6972 }
6973
6974 #[test]
6975 fn is_code_fence_opener_rejects_info_with_backtick() {
6976 assert!(is_code_fence_opener("```"));
6977 assert!(is_code_fence_opener("```rust"));
6978 assert!(is_code_fence_opener("```\"\""));
6979 assert!(!is_code_fence_opener("```x `` y```"));
6980 assert!(!is_code_fence_opener("``not-enough"));
6981 assert!(!is_code_fence_opener("no fence"));
6982 }
6983
6984 #[test]
6985 fn inline_strikethrough() {
6986 let doc = markdown_to_adf("Some ~~deleted~~ text").unwrap();
6987 let content = doc.content[0].content.as_ref().unwrap();
6988 let strike_node = &content[1];
6989 assert_eq!(strike_node.text.as_deref(), Some("deleted"));
6990 let marks = strike_node.marks.as_ref().unwrap();
6991 assert_eq!(marks[0].mark_type, "strike");
6992 }
6993
6994 #[test]
6995 fn inline_link() {
6996 let doc = markdown_to_adf("Click [here](https://example.com) now").unwrap();
6997 let content = doc.content[0].content.as_ref().unwrap();
6998 let link_node = &content[1];
6999 assert_eq!(link_node.text.as_deref(), Some("here"));
7000 let marks = link_node.marks.as_ref().unwrap();
7001 assert_eq!(marks[0].mark_type, "link");
7002 }
7003
7004 #[test]
7005 fn block_image() {
7006 let md = "";
7007 let doc = markdown_to_adf(md).unwrap();
7008 assert_eq!(doc.content[0].node_type, "mediaSingle");
7009 }
7010
7011 #[test]
7012 fn table() {
7013 let md = "| A | B |\n| --- | --- |\n| 1 | 2 |";
7014 let doc = markdown_to_adf(md).unwrap();
7015 assert_eq!(doc.content[0].node_type, "table");
7016 let rows = doc.content[0].content.as_ref().unwrap();
7017 assert_eq!(rows.len(), 2); }
7019
7020 #[test]
7023 fn adf_paragraph_to_markdown() {
7024 let doc = AdfDocument {
7025 version: 1,
7026 doc_type: "doc".to_string(),
7027 content: vec![AdfNode::paragraph(vec![AdfNode::text("Hello world")])],
7028 };
7029 let md = adf_to_markdown(&doc).unwrap();
7030 assert_eq!(md.trim(), "Hello world");
7031 }
7032
7033 #[test]
7034 fn adf_heading_to_markdown() {
7035 let doc = AdfDocument {
7036 version: 1,
7037 doc_type: "doc".to_string(),
7038 content: vec![AdfNode::heading(2, vec![AdfNode::text("Title")])],
7039 };
7040 let md = adf_to_markdown(&doc).unwrap();
7041 assert_eq!(md.trim(), "## Title");
7042 }
7043
7044 #[test]
7045 fn adf_bold_to_markdown() {
7046 let doc = AdfDocument {
7047 version: 1,
7048 doc_type: "doc".to_string(),
7049 content: vec![AdfNode::paragraph(vec![
7050 AdfNode::text("Normal "),
7051 AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
7052 AdfNode::text(" text"),
7053 ])],
7054 };
7055 let md = adf_to_markdown(&doc).unwrap();
7056 assert_eq!(md.trim(), "Normal **bold** text");
7057 }
7058
7059 #[test]
7060 fn adf_code_block_to_markdown() {
7061 let doc = AdfDocument {
7062 version: 1,
7063 doc_type: "doc".to_string(),
7064 content: vec![AdfNode::code_block(Some("rust"), "let x = 1;")],
7065 };
7066 let md = adf_to_markdown(&doc).unwrap();
7067 assert!(md.contains("```rust"));
7068 assert!(md.contains("let x = 1;"));
7069 assert!(md.contains("```"));
7070 }
7071
7072 #[test]
7073 fn adf_rule_to_markdown() {
7074 let doc = AdfDocument {
7075 version: 1,
7076 doc_type: "doc".to_string(),
7077 content: vec![AdfNode::rule()],
7078 };
7079 let md = adf_to_markdown(&doc).unwrap();
7080 assert!(md.contains("---"));
7081 }
7082
7083 #[test]
7084 fn adf_bullet_list_to_markdown() {
7085 let doc = AdfDocument {
7086 version: 1,
7087 doc_type: "doc".to_string(),
7088 content: vec![AdfNode::bullet_list(vec![
7089 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("A")])]),
7090 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("B")])]),
7091 ])],
7092 };
7093 let md = adf_to_markdown(&doc).unwrap();
7094 assert!(md.contains("- A"));
7095 assert!(md.contains("- B"));
7096 }
7097
7098 #[test]
7099 fn adf_link_to_markdown() {
7100 let doc = AdfDocument {
7101 version: 1,
7102 doc_type: "doc".to_string(),
7103 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7104 "click",
7105 vec![AdfMark::link("https://example.com")],
7106 )])],
7107 };
7108 let md = adf_to_markdown(&doc).unwrap();
7109 assert_eq!(md.trim(), "[click](https://example.com)");
7110 }
7111
7112 #[test]
7113 fn unsupported_block_preserved_as_json() {
7114 let doc = AdfDocument {
7115 version: 1,
7116 doc_type: "doc".to_string(),
7117 content: vec![AdfNode {
7118 node_type: "unknownBlock".to_string(),
7119 attrs: Some(serde_json::json!({"key": "value"})),
7120 content: None,
7121 text: None,
7122 marks: None,
7123 local_id: None,
7124 parameters: None,
7125 }],
7126 };
7127 let md = adf_to_markdown(&doc).unwrap();
7128 assert!(md.contains("```adf-unsupported"));
7129 assert!(md.contains("\"unknownBlock\""));
7130 }
7131
7132 #[test]
7133 fn unsupported_block_round_trips() {
7134 let original = AdfDocument {
7135 version: 1,
7136 doc_type: "doc".to_string(),
7137 content: vec![AdfNode {
7138 node_type: "unknownBlock".to_string(),
7139 attrs: Some(serde_json::json!({"key": "value"})),
7140 content: None,
7141 text: None,
7142 marks: None,
7143 local_id: None,
7144 parameters: None,
7145 }],
7146 };
7147 let md = adf_to_markdown(&original).unwrap();
7148 let restored = markdown_to_adf(&md).unwrap();
7149 assert_eq!(restored.content[0].node_type, "unknownBlock");
7150 assert_eq!(restored.content[0].attrs.as_ref().unwrap()["key"], "value");
7151 }
7152
7153 #[test]
7156 fn round_trip_simple_document() {
7157 let md = "# Hello\n\nSome text with **bold** and *italic*.\n\n- Item 1\n- Item 2\n";
7158 let adf = markdown_to_adf(md).unwrap();
7159 let restored = adf_to_markdown(&adf).unwrap();
7160
7161 assert!(restored.contains("# Hello"));
7162 assert!(restored.contains("**bold**"));
7163 assert!(restored.contains("*italic*"));
7164 assert!(restored.contains("- Item 1"));
7165 assert!(restored.contains("- Item 2"));
7166 }
7167
7168 #[test]
7169 fn round_trip_code_block() {
7170 let md = "```python\nprint('hello')\n```\n";
7171 let adf = markdown_to_adf(md).unwrap();
7172 let restored = adf_to_markdown(&adf).unwrap();
7173
7174 assert!(restored.contains("```python"));
7175 assert!(restored.contains("print('hello')"));
7176 }
7177
7178 #[test]
7179 fn round_trip_code_block_no_attrs() {
7180 let adf_json = r#"{"version":1,"type":"doc","content":[
7181 {"type":"codeBlock","content":[{"type":"text","text":"plain code"}]}
7182 ]}"#;
7183 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7184 assert!(doc.content[0].attrs.is_none());
7185 let md = adf_to_markdown(&doc).unwrap();
7186 let round_tripped = markdown_to_adf(&md).unwrap();
7187 assert!(round_tripped.content[0].attrs.is_none());
7188 }
7189
7190 #[test]
7191 fn round_trip_code_block_empty_language() {
7192 let adf_json = r#"{"version":1,"type":"doc","content":[
7193 {"type":"codeBlock","attrs":{"language":""},"content":[{"type":"text","text":"simple code block no backtick"}]}
7194 ]}"#;
7195 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7196 let attrs = doc.content[0].attrs.as_ref().unwrap();
7197 assert_eq!(attrs["language"], "");
7198 let md = adf_to_markdown(&doc).unwrap();
7199 let round_tripped = markdown_to_adf(&md).unwrap();
7200 let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
7201 assert_eq!(rt_attrs["language"], "");
7202 }
7203
7204 #[test]
7205 fn round_trip_code_block_with_language() {
7206 let adf_json = r#"{"version":1,"type":"doc","content":[
7207 {"type":"codeBlock","attrs":{"language":"python"},"content":[{"type":"text","text":"print('hi')"}]}
7208 ]}"#;
7209 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7210 let md = adf_to_markdown(&doc).unwrap();
7211 let round_tripped = markdown_to_adf(&md).unwrap();
7212 let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
7213 assert_eq!(rt_attrs["language"], "python");
7214 }
7215
7216 #[test]
7217 fn multiple_paragraphs() {
7218 let md = "First paragraph.\n\nSecond paragraph.\n";
7219 let adf = markdown_to_adf(md).unwrap();
7220 assert_eq!(adf.content.len(), 2);
7221 assert_eq!(adf.content[0].node_type, "paragraph");
7222 assert_eq!(adf.content[1].node_type, "paragraph");
7223 }
7224
7225 #[test]
7228 fn horizontal_rule_underscores() {
7229 let doc = markdown_to_adf("___").unwrap();
7230 assert_eq!(doc.content[0].node_type, "rule");
7231 }
7232
7233 #[test]
7234 fn not_a_horizontal_rule_too_short() {
7235 let doc = markdown_to_adf("--").unwrap();
7236 assert_eq!(doc.content[0].node_type, "paragraph");
7237 }
7238
7239 #[test]
7240 fn bullet_list_star_marker() {
7241 let md = "* Apple\n* Banana";
7242 let doc = markdown_to_adf(md).unwrap();
7243 assert_eq!(doc.content[0].node_type, "bulletList");
7244 let items = doc.content[0].content.as_ref().unwrap();
7245 assert_eq!(items.len(), 2);
7246 }
7247
7248 #[test]
7249 fn bullet_list_plus_marker() {
7250 let md = "+ One\n+ Two";
7251 let doc = markdown_to_adf(md).unwrap();
7252 assert_eq!(doc.content[0].node_type, "bulletList");
7253 }
7254
7255 #[test]
7256 fn ordered_list_non_one_start() {
7257 let md = "5. Fifth\n6. Sixth";
7258 let doc = markdown_to_adf(md).unwrap();
7259 let node = &doc.content[0];
7260 assert_eq!(node.node_type, "orderedList");
7261 let attrs = node.attrs.as_ref().unwrap();
7262 assert_eq!(attrs["order"], 5);
7263 }
7264
7265 #[test]
7266 fn ordered_list_start_at_one_omits_order_attr() {
7267 let md = "1. First\n2. Second";
7271 let doc = markdown_to_adf(md).unwrap();
7272 let node = &doc.content[0];
7273 assert_eq!(node.node_type, "orderedList");
7274 assert!(
7275 node.attrs.is_none(),
7276 "attrs should be omitted when order=1, got: {:?}",
7277 node.attrs
7278 );
7279 }
7280
7281 #[test]
7282 fn blockquote_bare_marker() {
7283 let md = ">quoted text";
7285 let doc = markdown_to_adf(md).unwrap();
7286 assert_eq!(doc.content[0].node_type, "blockquote");
7287 }
7288
7289 #[test]
7290 fn image_no_alt() {
7291 let md = "";
7292 let doc = markdown_to_adf(md).unwrap();
7293 let node = &doc.content[0];
7294 assert_eq!(node.node_type, "mediaSingle");
7295 let media = &node.content.as_ref().unwrap()[0];
7297 let attrs = media.attrs.as_ref().unwrap();
7298 assert!(attrs.get("alt").is_none());
7299 }
7300
7301 #[test]
7302 fn image_with_alt() {
7303 let md = "";
7304 let doc = markdown_to_adf(md).unwrap();
7305 let media = &doc.content[0].content.as_ref().unwrap()[0];
7306 let attrs = media.attrs.as_ref().unwrap();
7307 assert_eq!(attrs["alt"], "A photo");
7308 }
7309
7310 #[test]
7311 fn table_multi_body_rows() {
7312 let md = "| H1 | H2 |\n| --- | --- |\n| a | b |\n| c | d |";
7313 let doc = markdown_to_adf(md).unwrap();
7314 let rows = doc.content[0].content.as_ref().unwrap();
7315 assert_eq!(rows.len(), 3); let header_cells = rows[0].content.as_ref().unwrap();
7318 assert_eq!(header_cells[0].node_type, "tableHeader");
7319 let body_cells = rows[1].content.as_ref().unwrap();
7321 assert_eq!(body_cells[0].node_type, "tableCell");
7322 }
7323
7324 #[test]
7325 fn table_no_separator_is_not_table() {
7326 let md = "| not | a table |";
7328 let doc = markdown_to_adf(md).unwrap();
7329 assert_eq!(doc.content[0].node_type, "paragraph");
7330 }
7331
7332 #[test]
7333 fn inline_underscore_bold() {
7334 let doc = markdown_to_adf("Some __bold__ text").unwrap();
7335 let content = doc.content[0].content.as_ref().unwrap();
7336 let bold_node = &content[1];
7337 assert_eq!(bold_node.text.as_deref(), Some("bold"));
7338 let marks = bold_node.marks.as_ref().unwrap();
7339 assert_eq!(marks[0].mark_type, "strong");
7340 }
7341
7342 #[test]
7343 fn inline_underscore_italic() {
7344 let doc = markdown_to_adf("Some _italic_ text").unwrap();
7345 let content = doc.content[0].content.as_ref().unwrap();
7346 let italic_node = &content[1];
7347 assert_eq!(italic_node.text.as_deref(), Some("italic"));
7348 let marks = italic_node.marks.as_ref().unwrap();
7349 assert_eq!(marks[0].mark_type, "em");
7350 }
7351
7352 #[test]
7353 fn intraword_underscore_not_emphasis() {
7354 let doc = markdown_to_adf("call do_something_useful now").unwrap();
7356 let content = doc.content[0].content.as_ref().unwrap();
7357 assert_eq!(content.len(), 1, "should be a single text node");
7358 assert_eq!(
7359 content[0].text.as_deref(),
7360 Some("call do_something_useful now")
7361 );
7362 assert!(content[0].marks.is_none());
7363 }
7364
7365 #[test]
7366 fn intraword_underscore_multiple() {
7367 let doc = markdown_to_adf("use a_b_c_d here").unwrap();
7369 let content = doc.content[0].content.as_ref().unwrap();
7370 assert_eq!(content.len(), 1);
7371 assert_eq!(content[0].text.as_deref(), Some("use a_b_c_d here"));
7372 assert!(content[0].marks.is_none());
7373 }
7374
7375 #[test]
7376 fn intraword_double_underscore_not_bold() {
7377 let doc = markdown_to_adf("foo__bar__baz").unwrap();
7379 let content = doc.content[0].content.as_ref().unwrap();
7380 assert_eq!(content.len(), 1);
7381 assert_eq!(content[0].text.as_deref(), Some("foo__bar__baz"));
7382 assert!(content[0].marks.is_none());
7383 }
7384
7385 #[test]
7386 fn intraword_triple_underscore_not_bold_italic() {
7387 let doc = markdown_to_adf("x___y___z").unwrap();
7389 let content = doc.content[0].content.as_ref().unwrap();
7390 assert_eq!(content.len(), 1);
7391 assert_eq!(content[0].text.as_deref(), Some("x___y___z"));
7392 assert!(content[0].marks.is_none());
7393 }
7394
7395 #[test]
7396 fn underscore_emphasis_still_works_with_spaces() {
7397 let doc = markdown_to_adf("some _italic_ here").unwrap();
7399 let content = doc.content[0].content.as_ref().unwrap();
7400 assert_eq!(content.len(), 3);
7401 assert_eq!(content[1].text.as_deref(), Some("italic"));
7402 let marks = content[1].marks.as_ref().unwrap();
7403 assert_eq!(marks[0].mark_type, "em");
7404 }
7405
7406 #[test]
7407 fn underscore_bold_still_works_with_spaces() {
7408 let doc = markdown_to_adf("some __bold__ here").unwrap();
7410 let content = doc.content[0].content.as_ref().unwrap();
7411 assert_eq!(content.len(), 3);
7412 assert_eq!(content[1].text.as_deref(), Some("bold"));
7413 let marks = content[1].marks.as_ref().unwrap();
7414 assert_eq!(marks[0].mark_type, "strong");
7415 }
7416
7417 #[test]
7418 fn intraword_underscore_closing_only() {
7419 let doc = markdown_to_adf("_foo_bar").unwrap();
7421 let content = doc.content[0].content.as_ref().unwrap();
7422 assert_eq!(content.len(), 1);
7423 assert_eq!(content[0].text.as_deref(), Some("_foo_bar"));
7424 }
7425
7426 #[test]
7427 fn intraword_double_underscore_closing_only() {
7428 let doc = markdown_to_adf("__foo__bar").unwrap();
7430 let content = doc.content[0].content.as_ref().unwrap();
7431 assert_eq!(content.len(), 1);
7432 assert_eq!(content[0].text.as_deref(), Some("__foo__bar"));
7433 }
7434
7435 #[test]
7436 fn intraword_triple_underscore_closing_only() {
7437 let doc = markdown_to_adf("___foo___bar").unwrap();
7439 let content = doc.content[0].content.as_ref().unwrap();
7440 assert_eq!(content.len(), 1);
7441 assert_eq!(content[0].text.as_deref(), Some("___foo___bar"));
7442 }
7443
7444 #[test]
7445 fn asterisk_emphasis_unaffected_by_intraword_fix() {
7446 let doc = markdown_to_adf("foo*bar*baz").unwrap();
7448 let content = doc.content[0].content.as_ref().unwrap();
7449 assert!(content.len() > 1 || content[0].marks.is_some());
7451 }
7452
7453 #[test]
7454 fn intraword_underscore_at_start_of_text() {
7455 let doc = markdown_to_adf("_italic_ word").unwrap();
7457 let content = doc.content[0].content.as_ref().unwrap();
7458 assert_eq!(content[0].text.as_deref(), Some("italic"));
7459 let marks = content[0].marks.as_ref().unwrap();
7460 assert_eq!(marks[0].mark_type, "em");
7461 }
7462
7463 #[test]
7464 fn intraword_underscore_at_end_of_text() {
7465 let doc = markdown_to_adf("word _italic_").unwrap();
7467 let content = doc.content[0].content.as_ref().unwrap();
7468 let last = content.last().unwrap();
7469 assert_eq!(last.text.as_deref(), Some("italic"));
7470 let marks = last.marks.as_ref().unwrap();
7471 assert_eq!(marks[0].mark_type, "em");
7472 }
7473
7474 #[test]
7475 fn intraword_underscore_opening_only() {
7476 let doc = markdown_to_adf("a_b c_d").unwrap();
7479 let content = doc.content[0].content.as_ref().unwrap();
7480 assert_eq!(content.len(), 1);
7481 assert_eq!(content[0].text.as_deref(), Some("a_b c_d"));
7482 }
7483
7484 #[test]
7485 fn intraword_underscore_roundtrip() {
7486 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call the do_something_useful function"}]}]}"#;
7488 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7489 let jfm = adf_to_markdown(&adf).unwrap();
7490 let roundtripped = markdown_to_adf(&jfm).unwrap();
7491 let content = roundtripped.content[0].content.as_ref().unwrap();
7492 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7493 assert_eq!(
7494 content[0].text.as_deref(),
7495 Some("call the do_something_useful function")
7496 );
7497 assert!(content[0].marks.is_none());
7498 }
7499
7500 #[test]
7501 fn asterisk_emphasis_roundtrip() {
7502 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status: *confirmed* and active"}]}]}"#;
7504 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7505 let jfm = adf_to_markdown(&adf).unwrap();
7506 let roundtripped = markdown_to_adf(&jfm).unwrap();
7507 let content = roundtripped.content[0].content.as_ref().unwrap();
7508 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7509 assert_eq!(
7510 content[0].text.as_deref(),
7511 Some("Status: *confirmed* and active")
7512 );
7513 assert!(content[0].marks.is_none());
7514 }
7515
7516 #[test]
7517 fn double_asterisk_roundtrip() {
7518 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use **kwargs in Python"}]}]}"#;
7520 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7521 let jfm = adf_to_markdown(&adf).unwrap();
7522 let roundtripped = markdown_to_adf(&jfm).unwrap();
7523 let content = roundtripped.content[0].content.as_ref().unwrap();
7524 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7525 assert_eq!(content[0].text.as_deref(), Some("Use **kwargs in Python"));
7526 assert!(content[0].marks.is_none());
7527 }
7528
7529 #[test]
7530 fn asterisk_with_em_mark_roundtrip() {
7531 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a*b","marks":[{"type":"em"}]}]}]}"#;
7533 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7534 let jfm = adf_to_markdown(&adf).unwrap();
7535 let roundtripped = markdown_to_adf(&jfm).unwrap();
7536 let content = roundtripped.content[0].content.as_ref().unwrap();
7537 let em_node = content.iter().find(|n| {
7539 n.marks
7540 .as_ref()
7541 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7542 });
7543 assert!(em_node.is_some(), "should have an em-marked node");
7544 assert_eq!(em_node.unwrap().text.as_deref(), Some("a*b"));
7545 }
7546
7547 #[test]
7548 fn lone_asterisk_roundtrip() {
7549 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"rating: 5 * stars"}]}]}"#;
7551 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7552 let jfm = adf_to_markdown(&adf).unwrap();
7553 let roundtripped = markdown_to_adf(&jfm).unwrap();
7554 let content = roundtripped.content[0].content.as_ref().unwrap();
7555 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7556 assert_eq!(content[0].text.as_deref(), Some("rating: 5 * stars"));
7557 }
7558
7559 #[test]
7560 fn escape_emphasis_markers_unit() {
7561 assert_eq!(escape_emphasis_markers("hello"), "hello");
7562 assert_eq!(escape_emphasis_markers("*bold*"), r"\*bold\*");
7563 assert_eq!(escape_emphasis_markers("**strong**"), r"\*\*strong\*\*");
7564 assert_eq!(escape_emphasis_markers("no stars"), "no stars");
7565 assert_eq!(escape_emphasis_markers("a * b"), r"a \* b");
7566 assert_eq!(escape_emphasis_markers(""), "");
7567 }
7568
7569 #[test]
7570 fn escape_emphasis_markers_underscore_intraword() {
7571 assert_eq!(escape_emphasis_markers("foo_bar"), "foo_bar");
7574 assert_eq!(escape_emphasis_markers("a_b_c"), "a_b_c");
7575 assert_eq!(escape_emphasis_markers("foo__bar"), "foo__bar");
7576 assert_eq!(
7577 escape_emphasis_markers("call do_something_useful"),
7578 "call do_something_useful"
7579 );
7580 }
7581
7582 #[test]
7583 fn escape_emphasis_markers_underscore_at_boundary() {
7584 assert_eq!(escape_emphasis_markers("_Action"), r"\_Action");
7587 assert_eq!(escape_emphasis_markers("Action_"), r"Action\_");
7588 assert_eq!(escape_emphasis_markers("_ "), r"\_ ");
7589 assert_eq!(escape_emphasis_markers(" _"), r" \_");
7590 assert_eq!(escape_emphasis_markers("_"), r"\_");
7591 }
7592
7593 #[test]
7594 fn escape_emphasis_markers_underscore_with_punctuation() {
7595 assert_eq!(escape_emphasis_markers("foo _bar"), r"foo \_bar");
7597 assert_eq!(escape_emphasis_markers("foo_ bar"), r"foo\_ bar");
7598 assert_eq!(escape_emphasis_markers("(_x_)"), r"(\_x\_)");
7599 }
7600
7601 #[test]
7602 fn find_unescaped_skips_backslash_escaped() {
7603 assert_eq!(find_unescaped(r"a\*\*b**", "**"), Some(6));
7605 assert_eq!(find_unescaped(r"a\*\*b", "**"), None);
7607 assert_eq!(find_unescaped("a**b", "**"), Some(1));
7609 assert_eq!(find_unescaped("", "**"), None);
7611 }
7612
7613 #[test]
7614 fn find_unescaped_char_skips_backslash_escaped() {
7615 assert_eq!(find_unescaped_char(r"a\*b*", b'*'), Some(4));
7617 assert_eq!(find_unescaped_char(r"\*", b'*'), None);
7619 assert_eq!(find_unescaped_char("a*b", b'*'), Some(1));
7621 assert_eq!(find_unescaped_char("", b'*'), None);
7623 }
7624
7625 #[test]
7626 fn double_asterisk_in_strong_mark_roundtrip() {
7627 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call **kwargs","marks":[{"type":"strong"}]}]}]}"#;
7629 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7630 let jfm = adf_to_markdown(&adf).unwrap();
7631 let roundtripped = markdown_to_adf(&jfm).unwrap();
7632 let content = roundtripped.content[0].content.as_ref().unwrap();
7633 let strong_node = content.iter().find(|n| {
7634 n.marks
7635 .as_ref()
7636 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7637 });
7638 assert!(strong_node.is_some(), "should have a strong-marked node");
7639 assert_eq!(strong_node.unwrap().text.as_deref(), Some("call **kwargs"));
7640 }
7641
7642 #[test]
7643 fn backtick_code_roundtrip() {
7644 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Set `max_retries` to 3 in the config"}]}]}"#;
7646 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7647 let jfm = adf_to_markdown(&adf).unwrap();
7648 let roundtripped = markdown_to_adf(&jfm).unwrap();
7649 let content = roundtripped.content[0].content.as_ref().unwrap();
7650 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7651 assert_eq!(
7652 content[0].text.as_deref(),
7653 Some("Set `max_retries` to 3 in the config")
7654 );
7655 assert!(content[0].marks.is_none());
7656 }
7657
7658 #[test]
7659 fn multiple_backtick_spans_roundtrip() {
7660 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use `foo` and `bar` together"}]}]}"#;
7662 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7663 let jfm = adf_to_markdown(&adf).unwrap();
7664 let roundtripped = markdown_to_adf(&jfm).unwrap();
7665 let content = roundtripped.content[0].content.as_ref().unwrap();
7666 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7667 assert_eq!(
7668 content[0].text.as_deref(),
7669 Some("Use `foo` and `bar` together")
7670 );
7671 assert!(content[0].marks.is_none());
7672 }
7673
7674 #[test]
7675 fn lone_backtick_roundtrip() {
7676 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use a ` character"}]}]}"#;
7678 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7679 let jfm = adf_to_markdown(&adf).unwrap();
7680 let roundtripped = markdown_to_adf(&jfm).unwrap();
7681 let content = roundtripped.content[0].content.as_ref().unwrap();
7682 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7683 assert_eq!(content[0].text.as_deref(), Some("Use a ` character"));
7684 assert!(content[0].marks.is_none());
7685 }
7686
7687 #[test]
7688 fn backtick_with_code_mark_roundtrip() {
7689 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"max_retries","marks":[{"type":"code"}]}]}]}"#;
7691 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7692 let jfm = adf_to_markdown(&adf).unwrap();
7693 assert_eq!(jfm.trim(), "`max_retries`");
7694 let roundtripped = markdown_to_adf(&jfm).unwrap();
7695 let content = roundtripped.content[0].content.as_ref().unwrap();
7696 let code_node = content.iter().find(|n| {
7697 n.marks
7698 .as_ref()
7699 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7700 });
7701 assert!(code_node.is_some(), "should have a code-marked node");
7702 assert_eq!(code_node.unwrap().text.as_deref(), Some("max_retries"));
7703 }
7704
7705 #[test]
7706 fn backtick_with_em_mark_roundtrip() {
7707 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"use `cfg`","marks":[{"type":"em"}]}]}]}"#;
7709 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7710 let jfm = adf_to_markdown(&adf).unwrap();
7711 let roundtripped = markdown_to_adf(&jfm).unwrap();
7712 let content = roundtripped.content[0].content.as_ref().unwrap();
7713 let em_node = content.iter().find(|n| {
7714 n.marks
7715 .as_ref()
7716 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7717 });
7718 assert!(em_node.is_some(), "should have an em-marked node");
7719 assert_eq!(em_node.unwrap().text.as_deref(), Some("use `cfg`"));
7720 }
7721
7722 #[test]
7723 fn escape_pipes_in_cell_unit() {
7724 assert_eq!(escape_pipes_in_cell("hello"), "hello");
7725 assert_eq!(escape_pipes_in_cell("a|b"), r"a\|b");
7726 assert_eq!(escape_pipes_in_cell("|"), r"\|");
7727 assert_eq!(escape_pipes_in_cell("|a|b|"), r"\|a\|b\|");
7728 assert_eq!(escape_pipes_in_cell(""), "");
7729 assert_eq!(
7730 escape_pipes_in_cell("`parser.decode[T|json]`"),
7731 r"`parser.decode[T\|json]`"
7732 );
7733 }
7734
7735 #[test]
7736 fn escape_backticks_unit() {
7737 assert_eq!(escape_backticks("hello"), "hello");
7738 assert_eq!(escape_backticks("`code`"), r"\`code\`");
7739 assert_eq!(escape_backticks("no ticks"), "no ticks");
7740 assert_eq!(escape_backticks("a ` b"), r"a \` b");
7741 assert_eq!(escape_backticks(""), "");
7742 assert_eq!(escape_backticks("`a` and `b`"), r"\`a\` and \`b\`");
7743 }
7744
7745 #[test]
7748 fn backslash_in_text_roundtrip() {
7749 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"The path is C:\\Users\\admin\\file.txt"}]}]}"#;
7751 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7752 let jfm = adf_to_markdown(&adf).unwrap();
7753 let roundtripped = markdown_to_adf(&jfm).unwrap();
7754 let content = roundtripped.content[0].content.as_ref().unwrap();
7755 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7756 assert_eq!(
7757 content[0].text.as_deref(),
7758 Some(r"The path is C:\Users\admin\file.txt")
7759 );
7760 }
7761
7762 #[test]
7763 fn backslash_emitted_as_double_backslash() {
7764 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\b"}]}]}"#;
7765 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7766 let jfm = adf_to_markdown(&adf).unwrap();
7767 assert!(
7768 jfm.contains(r"a\\b"),
7769 "JFM should contain escaped backslash: {jfm}"
7770 );
7771 }
7772
7773 #[test]
7774 fn consecutive_backslashes_roundtrip() {
7775 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\\\b"}]}]}"#;
7776 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7777 let jfm = adf_to_markdown(&adf).unwrap();
7778 let roundtripped = markdown_to_adf(&jfm).unwrap();
7779 let content = roundtripped.content[0].content.as_ref().unwrap();
7780 assert_eq!(
7781 content[0].text.as_deref(),
7782 Some(r"a\\b"),
7783 "consecutive backslashes should survive round-trip"
7784 );
7785 }
7786
7787 #[test]
7788 fn backslash_with_strong_mark_roundtrip() {
7789 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"strong"}]}]}]}"#;
7790 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7791 let jfm = adf_to_markdown(&adf).unwrap();
7792 let roundtripped = markdown_to_adf(&jfm).unwrap();
7793 let content = roundtripped.content[0].content.as_ref().unwrap();
7794 let strong_node = content.iter().find(|n| {
7795 n.marks
7796 .as_ref()
7797 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7798 });
7799 assert!(strong_node.is_some(), "should have a strong-marked node");
7800 assert_eq!(strong_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7801 }
7802
7803 #[test]
7804 fn backslash_with_code_mark_not_escaped() {
7805 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"code"}]}]}]}"#;
7807 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7808 let jfm = adf_to_markdown(&adf).unwrap();
7809 assert_eq!(jfm.trim(), r"`C:\Users`");
7810 let roundtripped = markdown_to_adf(&jfm).unwrap();
7811 let content = roundtripped.content[0].content.as_ref().unwrap();
7812 let code_node = content.iter().find(|n| {
7813 n.marks
7814 .as_ref()
7815 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7816 });
7817 assert!(code_node.is_some(), "should have a code-marked node");
7818 assert_eq!(code_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7819 }
7820
7821 #[test]
7822 fn backslash_before_special_chars_roundtrip() {
7823 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"\\*not bold\\*"}]}]}"#;
7825 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7826 let jfm = adf_to_markdown(&adf).unwrap();
7827 let roundtripped = markdown_to_adf(&jfm).unwrap();
7828 let content = roundtripped.content[0].content.as_ref().unwrap();
7829 assert_eq!(
7830 content[0].text.as_deref(),
7831 Some(r"\*not bold\*"),
7832 "backslash before special char should survive round-trip"
7833 );
7834 }
7835
7836 #[test]
7837 fn backslash_and_newline_in_text_roundtrip() {
7838 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\path\nline2"}]}]}"#;
7840 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7841 let jfm = adf_to_markdown(&adf).unwrap();
7842 let roundtripped = markdown_to_adf(&jfm).unwrap();
7843 let content = roundtripped.content[0].content.as_ref().unwrap();
7844 assert_eq!(
7845 content[0].text.as_deref(),
7846 Some("C:\\path\nline2"),
7847 "backslash and newline should both survive round-trip"
7848 );
7849 }
7850
7851 #[test]
7852 fn lone_backslash_roundtrip() {
7853 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a \\ b"}]}]}"#;
7854 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7855 let jfm = adf_to_markdown(&adf).unwrap();
7856 let roundtripped = markdown_to_adf(&jfm).unwrap();
7857 let content = roundtripped.content[0].content.as_ref().unwrap();
7858 assert_eq!(content[0].text.as_deref(), Some(r"a \ b"));
7859 }
7860
7861 #[test]
7862 fn trailing_backslash_in_text_roundtrip() {
7863 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\"}]}]}"#;
7865 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7866 let jfm = adf_to_markdown(&adf).unwrap();
7867 let roundtripped = markdown_to_adf(&jfm).unwrap();
7868 let content = roundtripped.content[0].content.as_ref().unwrap();
7869 assert_eq!(
7870 content[0].text.as_deref(),
7871 Some(r"end\"),
7872 "trailing backslash should survive round-trip"
7873 );
7874 }
7875
7876 #[test]
7877 fn escape_bare_urls_unit() {
7878 assert_eq!(escape_bare_urls("hello"), "hello");
7879 assert_eq!(escape_bare_urls(""), "");
7880 assert_eq!(
7881 escape_bare_urls("https://example.com"),
7882 r"\https://example.com"
7883 );
7884 assert_eq!(
7885 escape_bare_urls("http://example.com"),
7886 r"\http://example.com"
7887 );
7888 assert_eq!(
7889 escape_bare_urls("see https://a.com and https://b.com"),
7890 r"see \https://a.com and \https://b.com"
7891 );
7892 assert_eq!(escape_bare_urls("http header"), "http header");
7894 assert_eq!(escape_bare_urls("https is secure"), "https is secure");
7895 }
7896
7897 #[test]
7898 fn heading_not_valid_without_space() {
7899 let doc = markdown_to_adf("#Title").unwrap();
7901 assert_eq!(doc.content[0].node_type, "paragraph");
7902 }
7903
7904 #[test]
7905 fn heading_level_too_high() {
7906 let doc = markdown_to_adf("####### Not a heading").unwrap();
7908 assert_eq!(doc.content[0].node_type, "paragraph");
7909 }
7910
7911 #[test]
7912 fn empty_document() {
7913 let doc = markdown_to_adf("").unwrap();
7914 assert!(doc.content.is_empty());
7915 }
7916
7917 #[test]
7918 fn only_blank_lines() {
7919 let doc = markdown_to_adf("\n\n\n").unwrap();
7920 assert!(doc.content.is_empty());
7921 }
7922
7923 #[test]
7924 fn code_block_unterminated() {
7925 let md = "```rust\nfn main() {}";
7927 let doc = markdown_to_adf(md).unwrap();
7928 assert_eq!(doc.content[0].node_type, "codeBlock");
7929 }
7930
7931 #[test]
7932 fn mixed_document() {
7933 let md = "# Title\n\nA paragraph.\n\n- Item\n\n```\ncode\n```\n\n> quote\n\n---\n\n1. numbered\n";
7934 let doc = markdown_to_adf(md).unwrap();
7935 let types: Vec<&str> = doc.content.iter().map(|n| n.node_type.as_str()).collect();
7936 assert_eq!(
7937 types,
7938 vec![
7939 "heading",
7940 "paragraph",
7941 "bulletList",
7942 "codeBlock",
7943 "blockquote",
7944 "rule",
7945 "orderedList",
7946 ]
7947 );
7948 }
7949
7950 #[test]
7953 fn adf_ordered_list_to_markdown() {
7954 let doc = AdfDocument {
7955 version: 1,
7956 doc_type: "doc".to_string(),
7957 content: vec![AdfNode::ordered_list(
7958 vec![
7959 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("First")])]),
7960 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("Second")])]),
7961 ],
7962 None,
7963 )],
7964 };
7965 let md = adf_to_markdown(&doc).unwrap();
7966 assert!(md.contains("1. First"));
7967 assert!(md.contains("2. Second"));
7968 }
7969
7970 #[test]
7971 fn adf_ordered_list_custom_start() {
7972 let doc = AdfDocument {
7973 version: 1,
7974 doc_type: "doc".to_string(),
7975 content: vec![AdfNode::ordered_list(
7976 vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
7977 AdfNode::text("Third"),
7978 ])])],
7979 Some(3),
7980 )],
7981 };
7982 let md = adf_to_markdown(&doc).unwrap();
7983 assert!(md.contains("3. Third"));
7984 }
7985
7986 #[test]
7987 fn adf_blockquote_to_markdown() {
7988 let doc = AdfDocument {
7989 version: 1,
7990 doc_type: "doc".to_string(),
7991 content: vec![AdfNode::blockquote(vec![AdfNode::paragraph(vec![
7992 AdfNode::text("A quote"),
7993 ])])],
7994 };
7995 let md = adf_to_markdown(&doc).unwrap();
7996 assert!(md.contains("> A quote"));
7997 }
7998
7999 #[test]
8000 fn adf_table_to_markdown() {
8001 let doc = AdfDocument {
8002 version: 1,
8003 doc_type: "doc".to_string(),
8004 content: vec![AdfNode::table(vec![
8005 AdfNode::table_row(vec![
8006 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Name")])]),
8007 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Value")])]),
8008 ]),
8009 AdfNode::table_row(vec![
8010 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a")])]),
8011 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("1")])]),
8012 ]),
8013 ])],
8014 };
8015 let md = adf_to_markdown(&doc).unwrap();
8016 assert!(md.contains("| Name | Value |"));
8017 assert!(md.contains("| --- | --- |"));
8018 assert!(md.contains("| a | 1 |"));
8019 }
8020
8021 #[test]
8022 fn adf_media_to_markdown() {
8023 let doc = AdfDocument {
8024 version: 1,
8025 doc_type: "doc".to_string(),
8026 content: vec![AdfNode::media_single(
8027 "https://example.com/img.png",
8028 Some("Alt"),
8029 )],
8030 };
8031 let md = adf_to_markdown(&doc).unwrap();
8032 assert!(md.contains(""));
8033 }
8034
8035 #[test]
8036 fn adf_media_no_alt_to_markdown() {
8037 let doc = AdfDocument {
8038 version: 1,
8039 doc_type: "doc".to_string(),
8040 content: vec![AdfNode::media_single("https://example.com/img.png", None)],
8041 };
8042 let md = adf_to_markdown(&doc).unwrap();
8043 assert!(md.contains(""));
8044 }
8045
8046 #[test]
8047 fn adf_italic_to_markdown() {
8048 let doc = AdfDocument {
8049 version: 1,
8050 doc_type: "doc".to_string(),
8051 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8052 "emphasis",
8053 vec![AdfMark::em()],
8054 )])],
8055 };
8056 let md = adf_to_markdown(&doc).unwrap();
8057 assert_eq!(md.trim(), "*emphasis*");
8058 }
8059
8060 #[test]
8061 fn adf_strikethrough_to_markdown() {
8062 let doc = AdfDocument {
8063 version: 1,
8064 doc_type: "doc".to_string(),
8065 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8066 "deleted",
8067 vec![AdfMark::strike()],
8068 )])],
8069 };
8070 let md = adf_to_markdown(&doc).unwrap();
8071 assert_eq!(md.trim(), "~~deleted~~");
8072 }
8073
8074 #[test]
8075 fn adf_inline_code_to_markdown() {
8076 let doc = AdfDocument {
8077 version: 1,
8078 doc_type: "doc".to_string(),
8079 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8080 "code",
8081 vec![AdfMark::code()],
8082 )])],
8083 };
8084 let md = adf_to_markdown(&doc).unwrap();
8085 assert_eq!(md.trim(), "`code`");
8086 }
8087
8088 #[test]
8089 fn adf_code_with_link_to_markdown() {
8090 let doc = AdfDocument {
8091 version: 1,
8092 doc_type: "doc".to_string(),
8093 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8094 "func",
8095 vec![AdfMark::code(), AdfMark::link("https://example.com")],
8096 )])],
8097 };
8098 let md = adf_to_markdown(&doc).unwrap();
8099 assert_eq!(md.trim(), "[`func`](https://example.com)");
8100 }
8101
8102 #[test]
8103 fn adf_bold_italic_to_markdown() {
8104 let doc = AdfDocument {
8105 version: 1,
8106 doc_type: "doc".to_string(),
8107 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8108 "both",
8109 vec![AdfMark::strong(), AdfMark::em()],
8110 )])],
8111 };
8112 let md = adf_to_markdown(&doc).unwrap();
8113 assert_eq!(md.trim(), "***both***");
8114 }
8115
8116 #[test]
8117 fn adf_bold_link_to_markdown() {
8118 let doc = AdfDocument {
8119 version: 1,
8120 doc_type: "doc".to_string(),
8121 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8122 "bold link",
8123 vec![AdfMark::strong(), AdfMark::link("https://example.com")],
8124 )])],
8125 };
8126 let md = adf_to_markdown(&doc).unwrap();
8127 assert_eq!(md.trim(), "**[bold link](https://example.com)**");
8128 }
8129
8130 #[test]
8131 fn adf_strikethrough_bold_to_markdown() {
8132 let doc = AdfDocument {
8133 version: 1,
8134 doc_type: "doc".to_string(),
8135 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8136 "struck",
8137 vec![AdfMark::strike(), AdfMark::strong()],
8138 )])],
8139 };
8140 let md = adf_to_markdown(&doc).unwrap();
8141 assert_eq!(md.trim(), "~~**struck**~~");
8142 }
8143
8144 #[test]
8145 fn adf_hard_break_to_markdown() {
8146 let doc = AdfDocument {
8147 version: 1,
8148 doc_type: "doc".to_string(),
8149 content: vec![AdfNode::paragraph(vec![
8150 AdfNode::text("Line 1"),
8151 AdfNode::hard_break(),
8152 AdfNode::text("Line 2"),
8153 ])],
8154 };
8155 let md = adf_to_markdown(&doc).unwrap();
8156 assert!(md.contains("Line 1\\\n Line 2"));
8157 }
8158
8159 #[test]
8160 fn adf_unsupported_inline_to_markdown() {
8161 let doc = AdfDocument {
8162 version: 1,
8163 doc_type: "doc".to_string(),
8164 content: vec![AdfNode::paragraph(vec![AdfNode {
8165 node_type: "unknownInline".to_string(),
8166 attrs: Some(serde_json::json!({"key": "value"})),
8167 content: None,
8168 text: None,
8169 marks: None,
8170 local_id: None,
8171 parameters: None,
8172 }])],
8173 };
8174 let md = adf_to_markdown(&doc).unwrap();
8175 assert!(md.contains(":adf-unsupported[]{json="));
8179 assert!(md.contains("unknownInline"));
8180 assert!(!md.contains("<!-- unsupported inline"));
8181 }
8182
8183 #[test]
8184 fn unsupported_inline_round_trips() {
8185 let original = AdfDocument {
8186 version: 1,
8187 doc_type: "doc".to_string(),
8188 content: vec![AdfNode::paragraph(vec![
8189 AdfNode::text("before "),
8190 AdfNode {
8191 node_type: "unknownInline".to_string(),
8192 attrs: Some(serde_json::json!({"key": "value", "n": 3})),
8193 content: None,
8194 text: Some("fallback".to_string()),
8195 marks: None,
8196 local_id: None,
8197 parameters: None,
8198 },
8199 AdfNode::text(" after"),
8200 ])],
8201 };
8202 let md = adf_to_markdown(&original).unwrap();
8203 let restored = markdown_to_adf(&md).unwrap();
8204 let para = &restored.content[0];
8206 let unknown = para
8207 .content
8208 .as_ref()
8209 .unwrap()
8210 .iter()
8211 .find(|n| n.node_type == "unknownInline")
8212 .expect("unknownInline node preserved");
8213 assert_eq!(unknown.attrs.as_ref().unwrap()["key"], "value");
8214 assert_eq!(unknown.attrs.as_ref().unwrap()["n"], 3);
8215 assert_eq!(unknown.text.as_deref(), Some("fallback"));
8216 }
8217
8218 #[test]
8221 fn adf_media_inline_to_markdown() {
8222 let doc = AdfDocument {
8223 version: 1,
8224 doc_type: "doc".to_string(),
8225 content: vec![AdfNode::paragraph(vec![
8226 AdfNode::text("see "),
8227 AdfNode::media_inline(serde_json::json!({
8228 "type": "image",
8229 "id": "abcdef01-2345-6789-abcd-abcdef012345",
8230 "collection": "contentId-111111",
8231 "width": 200,
8232 "height": 100
8233 })),
8234 AdfNode::text(" for details"),
8235 ])],
8236 };
8237 let md = adf_to_markdown(&doc).unwrap();
8238 assert!(md.contains(":media-inline[]{"), "got: {md}");
8239 assert!(md.contains("type=image"));
8240 assert!(md.contains("id=abcdef01-2345-6789-abcd-abcdef012345"));
8241 assert!(md.contains("collection=contentId-111111"));
8242 assert!(md.contains("width=200"));
8243 assert!(md.contains("height=100"));
8244 assert!(!md.contains("<!-- unsupported inline"));
8245 }
8246
8247 #[test]
8248 fn media_inline_round_trip() {
8249 let doc = AdfDocument {
8250 version: 1,
8251 doc_type: "doc".to_string(),
8252 content: vec![AdfNode::paragraph(vec![
8253 AdfNode::text("see "),
8254 AdfNode::media_inline(serde_json::json!({
8255 "type": "image",
8256 "id": "abcdef01-2345-6789-abcd-abcdef012345",
8257 "collection": "contentId-111111",
8258 "width": 200,
8259 "height": 100
8260 })),
8261 AdfNode::text(" for details"),
8262 ])],
8263 };
8264 let md = adf_to_markdown(&doc).unwrap();
8265 let rt = markdown_to_adf(&md).unwrap();
8266
8267 let content = rt.content[0].content.as_ref().unwrap();
8268 assert_eq!(content[0].text.as_deref(), Some("see "));
8269 assert_eq!(content[1].node_type, "mediaInline");
8270 let attrs = content[1].attrs.as_ref().unwrap();
8271 assert_eq!(attrs["type"], "image");
8272 assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
8273 assert_eq!(attrs["collection"], "contentId-111111");
8274 assert_eq!(attrs["width"], 200);
8275 assert_eq!(attrs["height"], 100);
8276 assert_eq!(content[2].text.as_deref(), Some(" for details"));
8277 }
8278
8279 #[test]
8280 fn media_inline_external_url_round_trip() {
8281 let doc = AdfDocument {
8282 version: 1,
8283 doc_type: "doc".to_string(),
8284 content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
8285 serde_json::json!({
8286 "type": "external",
8287 "url": "https://example.com/image.png",
8288 "alt": "example",
8289 "width": 400,
8290 "height": 300
8291 }),
8292 )])],
8293 };
8294 let md = adf_to_markdown(&doc).unwrap();
8295 let rt = markdown_to_adf(&md).unwrap();
8296
8297 let content = rt.content[0].content.as_ref().unwrap();
8298 assert_eq!(content[0].node_type, "mediaInline");
8299 let attrs = content[0].attrs.as_ref().unwrap();
8300 assert_eq!(attrs["type"], "external");
8301 assert_eq!(attrs["url"], "https://example.com/image.png");
8302 assert_eq!(attrs["alt"], "example");
8303 assert_eq!(attrs["width"], 400);
8304 assert_eq!(attrs["height"], 300);
8305 }
8306
8307 #[test]
8308 fn media_inline_minimal_attrs() {
8309 let doc = AdfDocument {
8310 version: 1,
8311 doc_type: "doc".to_string(),
8312 content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
8313 serde_json::json!({"type": "file", "id": "abc-123"}),
8314 )])],
8315 };
8316 let md = adf_to_markdown(&doc).unwrap();
8317 let rt = markdown_to_adf(&md).unwrap();
8318
8319 let content = rt.content[0].content.as_ref().unwrap();
8320 assert_eq!(content[0].node_type, "mediaInline");
8321 let attrs = content[0].attrs.as_ref().unwrap();
8322 assert_eq!(attrs["type"], "file");
8323 assert_eq!(attrs["id"], "abc-123");
8324 }
8325
8326 #[test]
8327 fn media_inline_from_issue_476_reproducer() {
8328 let adf_json: serde_json::Value = serde_json::json!({
8330 "type": "doc",
8331 "version": 1,
8332 "content": [
8333 {
8334 "type": "paragraph",
8335 "content": [
8336 {"type": "text", "text": "see "},
8337 {
8338 "type": "mediaInline",
8339 "attrs": {
8340 "collection": "contentId-111111",
8341 "height": 100,
8342 "id": "abcdef01-2345-6789-abcd-abcdef012345",
8343 "localId": "aabbccdd-1234-5678-abcd-aabbccdd1234",
8344 "type": "image",
8345 "width": 200
8346 }
8347 },
8348 {"type": "text", "text": " for details"}
8349 ]
8350 }
8351 ]
8352 });
8353 let doc: AdfDocument = serde_json::from_value(adf_json).unwrap();
8354 let md = adf_to_markdown(&doc).unwrap();
8355 assert!(
8356 !md.contains("<!-- unsupported inline"),
8357 "mediaInline should not be unsupported; got: {md}"
8358 );
8359
8360 let rt = markdown_to_adf(&md).unwrap();
8361 let content = rt.content[0].content.as_ref().unwrap();
8362 assert_eq!(content[1].node_type, "mediaInline");
8363 let attrs = content[1].attrs.as_ref().unwrap();
8364 assert_eq!(attrs["type"], "image");
8365 assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
8366 assert_eq!(attrs["collection"], "contentId-111111");
8367 assert_eq!(attrs["width"], 200);
8368 assert_eq!(attrs["height"], 100);
8369 assert_eq!(attrs["localId"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
8370 }
8371
8372 #[test]
8373 fn emoji_shortcode() {
8374 let doc = markdown_to_adf("Hello :wave: world").unwrap();
8375 let content = doc.content[0].content.as_ref().unwrap();
8376 assert_eq!(content[0].text.as_deref(), Some("Hello "));
8377 assert_eq!(content[1].node_type, "emoji");
8378 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":wave:");
8379 assert_eq!(content[2].text.as_deref(), Some(" world"));
8380 }
8381
8382 #[test]
8383 fn adf_emoji_to_markdown() {
8384 let doc = AdfDocument {
8385 version: 1,
8386 doc_type: "doc".to_string(),
8387 content: vec![AdfNode::paragraph(vec![AdfNode::emoji("thumbsup")])],
8388 };
8389 let md = adf_to_markdown(&doc).unwrap();
8390 assert!(md.contains(":thumbsup:"));
8391 }
8392
8393 #[test]
8394 fn adf_emoji_with_colon_prefix_to_markdown() {
8395 let doc = AdfDocument {
8397 version: 1,
8398 doc_type: "doc".to_string(),
8399 content: vec![AdfNode::paragraph(vec![AdfNode {
8400 node_type: "emoji".to_string(),
8401 attrs: Some(serde_json::json!({"shortName": ":thumbsup:"})),
8402 content: None,
8403 text: None,
8404 marks: None,
8405 local_id: None,
8406 parameters: None,
8407 }])],
8408 };
8409 let md = adf_to_markdown(&doc).unwrap();
8410 assert!(md.contains(":thumbsup:"));
8411 assert!(!md.contains("::thumbsup::"));
8413 }
8414
8415 #[test]
8416 fn round_trip_emoji() {
8417 let md = "Hello :wave: world\n";
8418 let doc = markdown_to_adf(md).unwrap();
8419 let result = adf_to_markdown(&doc).unwrap();
8420 assert!(result.contains(":wave:"));
8421 }
8422
8423 #[test]
8424 fn emoji_with_id_and_text_round_trips() {
8425 let doc = AdfDocument {
8426 version: 1,
8427 doc_type: "doc".to_string(),
8428 content: vec![AdfNode::paragraph(vec![AdfNode {
8429 node_type: "emoji".to_string(),
8430 attrs: Some(
8431 serde_json::json!({"shortName": ":check_mark:", "id": "2705", "text": "✅"}),
8432 ),
8433 content: None,
8434 text: None,
8435 marks: None,
8436 local_id: None,
8437 parameters: None,
8438 }])],
8439 };
8440 let md = adf_to_markdown(&doc).unwrap();
8441 assert!(md.contains(":check_mark:"), "shortcode present: {md}");
8442 assert!(md.contains("id="), "id attr present: {md}");
8443 assert!(md.contains("text="), "text attr present: {md}");
8444
8445 let round_tripped = markdown_to_adf(&md).unwrap();
8447 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8448 let attrs = emoji.attrs.as_ref().unwrap();
8449 assert_eq!(attrs["shortName"], ":check_mark:");
8450 assert_eq!(attrs["id"], "2705");
8451 assert_eq!(attrs["text"], "✅");
8452 }
8453
8454 #[test]
8455 fn emoji_without_extra_attrs_still_works() {
8456 let md = "Hello :wave: world\n";
8457 let doc = markdown_to_adf(md).unwrap();
8458 let emoji = &doc.content[0].content.as_ref().unwrap()[1];
8459 assert_eq!(emoji.attrs.as_ref().unwrap()["shortName"], ":wave:");
8460 assert!(emoji.attrs.as_ref().unwrap().get("id").is_none());
8462 }
8463
8464 #[test]
8465 fn emoji_shortname_preserves_colons_round_trip() {
8466 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8468 {"type":"emoji","attrs":{"shortName":":cross_mark:","id":"atlassian-cross_mark","text":"❌"}}
8469 ]}]}"#;
8470 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8471
8472 let md = adf_to_markdown(&doc).unwrap();
8474 let round_tripped = markdown_to_adf(&md).unwrap();
8475 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8476 let attrs = emoji.attrs.as_ref().unwrap();
8477 assert_eq!(
8478 attrs["shortName"], ":cross_mark:",
8479 "shortName should preserve colons, got: {}",
8480 attrs["shortName"]
8481 );
8482 assert_eq!(attrs["id"], "atlassian-cross_mark");
8483 assert_eq!(attrs["text"], "❌");
8484 }
8485
8486 #[test]
8487 fn emoji_shortname_without_colons_preserved() {
8488 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8490 {"type":"emoji","attrs":{"shortName":"white_check_mark","id":"2705","text":"✅"}}
8491 ]}]}"#;
8492 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8493 let md = adf_to_markdown(&doc).unwrap();
8494 let round_tripped = markdown_to_adf(&md).unwrap();
8495 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8496 let attrs = emoji.attrs.as_ref().unwrap();
8497 assert_eq!(
8498 attrs["shortName"], "white_check_mark",
8499 "shortName without colons should stay without colons, got: {}",
8500 attrs["shortName"]
8501 );
8502 }
8503
8504 #[test]
8505 fn colon_in_text_not_emoji() {
8506 let doc = markdown_to_adf("Time is 10:30 today").unwrap();
8508 let content = doc.content[0].content.as_ref().unwrap();
8509 assert_eq!(content.len(), 1);
8510 assert_eq!(content[0].node_type, "text");
8511 }
8512
8513 #[test]
8514 fn text_with_shortcode_pattern_round_trips_as_text() {
8515 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Alert :fire: triggered on pod:pod42"}]}]}"#;
8517 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8518
8519 let md = adf_to_markdown(&doc).unwrap();
8520 let round_tripped = markdown_to_adf(&md).unwrap();
8521 let content = round_tripped.content[0].content.as_ref().unwrap();
8522
8523 assert_eq!(
8524 content.len(),
8525 1,
8526 "should be a single text node, got: {content:?}"
8527 );
8528 assert_eq!(content[0].node_type, "text");
8529 assert_eq!(
8530 content[0].text.as_deref().unwrap(),
8531 "Alert :fire: triggered on pod:pod42"
8532 );
8533 }
8534
8535 #[test]
8536 fn double_colon_pattern_round_trips_as_text() {
8537 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status::Active::Running"}]}]}"#;
8539 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8540
8541 let md = adf_to_markdown(&doc).unwrap();
8542 let round_tripped = markdown_to_adf(&md).unwrap();
8543 let content = round_tripped.content[0].content.as_ref().unwrap();
8544
8545 assert_eq!(
8546 content.len(),
8547 1,
8548 "should be a single text node, got: {content:?}"
8549 );
8550 assert_eq!(content[0].node_type, "text");
8551 assert_eq!(
8552 content[0].text.as_deref().unwrap(),
8553 "Status::Active::Running"
8554 );
8555 }
8556
8557 #[test]
8558 fn real_emoji_node_still_round_trips() {
8559 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8561 {"type":"text","text":"Hello "},
8562 {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8563 {"type":"text","text":" world"}
8564 ]}]}"#;
8565 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8566
8567 let md = adf_to_markdown(&doc).unwrap();
8568 let round_tripped = markdown_to_adf(&md).unwrap();
8569 let content = round_tripped.content[0].content.as_ref().unwrap();
8570
8571 assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8573 assert_eq!(content[0].text.as_deref(), Some("Hello "));
8574 assert_eq!(content[1].node_type, "emoji");
8575 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":fire:");
8576 assert_eq!(content[2].text.as_deref(), Some(" world"));
8577 }
8578
8579 #[test]
8580 fn combined_emoji_shortname_round_trips_as_single_node() {
8581 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8586 {"type":"text","text":"Thanks for the help "},
8587 {"type":"emoji","attrs":{"shortName":":slightly_smiling_face::bow:","id":"","text":""}}
8588 ]}]}"#;
8589 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8590
8591 let md = adf_to_markdown(&doc).unwrap();
8592 let round_tripped = markdown_to_adf(&md).unwrap();
8593 let content = round_tripped.content[0].content.as_ref().unwrap();
8594
8595 assert_eq!(
8596 content.len(),
8597 2,
8598 "should have text + single combined emoji: {content:?}"
8599 );
8600 assert_eq!(content[0].text.as_deref(), Some("Thanks for the help "));
8601 assert_eq!(content[1].node_type, "emoji");
8602 let attrs = content[1].attrs.as_ref().unwrap();
8603 assert_eq!(attrs["shortName"], ":slightly_smiling_face::bow:");
8604 assert_eq!(attrs["id"], "");
8605 assert_eq!(attrs["text"], "");
8606 }
8607
8608 #[test]
8609 fn triple_combined_emoji_shortname_round_trips_as_single_node() {
8610 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8612 {"type":"emoji","attrs":{"shortName":":a::b::c:","id":"x","text":""}}
8613 ]}]}"#;
8614 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8615
8616 let md = adf_to_markdown(&doc).unwrap();
8617 let round_tripped = markdown_to_adf(&md).unwrap();
8618 let content = round_tripped.content[0].content.as_ref().unwrap();
8619
8620 assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8621 assert_eq!(content[0].node_type, "emoji");
8622 let attrs = content[0].attrs.as_ref().unwrap();
8623 assert_eq!(attrs["shortName"], ":a::b::c:");
8624 assert_eq!(attrs["id"], "x");
8625 }
8626
8627 #[test]
8628 fn consecutive_emojis_remain_separate_nodes() {
8629 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8633 {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8634 {"type":"emoji","attrs":{"shortName":":water:","id":"1f4a7","text":"💧"}}
8635 ]}]}"#;
8636 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8637
8638 let md = adf_to_markdown(&doc).unwrap();
8639 let round_tripped = markdown_to_adf(&md).unwrap();
8640 let content = round_tripped.content[0].content.as_ref().unwrap();
8641
8642 assert_eq!(content.len(), 2, "should be two emoji nodes: {content:?}");
8643 assert_eq!(content[0].node_type, "emoji");
8644 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8645 assert_eq!(content[1].node_type, "emoji");
8646 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8647 }
8648
8649 #[test]
8650 fn adjacent_shortcodes_without_directive_parse_as_two_emojis() {
8651 let md = ":fire::water:";
8654 let doc = markdown_to_adf(md).unwrap();
8655 let content = doc.content[0].content.as_ref().unwrap();
8656
8657 assert_eq!(content.len(), 2, "should be two emojis: {content:?}");
8658 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8659 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8660 }
8661
8662 #[test]
8663 fn combined_emoji_shortname_preserves_local_id() {
8664 let md = r#":a::b:{shortName=":a::b:" id="x" text="y" localId="abc"}"#;
8667 let doc = markdown_to_adf(md).unwrap();
8668 let content = doc.content[0].content.as_ref().unwrap();
8669
8670 assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8671 let attrs = content[0].attrs.as_ref().unwrap();
8672 assert_eq!(attrs["shortName"], ":a::b:");
8673 assert_eq!(attrs["id"], "x");
8674 assert_eq!(attrs["text"], "y");
8675 assert_eq!(attrs["localId"], "abc");
8676 }
8677
8678 #[test]
8679 fn text_shortcode_with_marks_round_trips() {
8680 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8682 {"type":"text","text":"Alert :fire: triggered","marks":[{"type":"strong"}]}
8683 ]}]}"#;
8684 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8685
8686 let md = adf_to_markdown(&doc).unwrap();
8687 let round_tripped = markdown_to_adf(&md).unwrap();
8688 let content = round_tripped.content[0].content.as_ref().unwrap();
8689
8690 assert_eq!(
8691 content.len(),
8692 1,
8693 "should be single bold text node: {content:?}"
8694 );
8695 assert_eq!(content[0].node_type, "text");
8696 assert_eq!(
8697 content[0].text.as_deref().unwrap(),
8698 "Alert :fire: triggered"
8699 );
8700 assert!(content[0]
8701 .marks
8702 .as_ref()
8703 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong")));
8704 }
8705
8706 #[test]
8707 fn mixed_emoji_node_and_text_shortcode_round_trips() {
8708 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8710 {"type":"emoji","attrs":{"shortName":":wave:","id":"1f44b","text":"👋"}},
8711 {"type":"text","text":" says :hello: to you"}
8712 ]}]}"#;
8713 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8714
8715 let md = adf_to_markdown(&doc).unwrap();
8716 let round_tripped = markdown_to_adf(&md).unwrap();
8717 let content = round_tripped.content[0].content.as_ref().unwrap();
8718
8719 assert_eq!(content.len(), 2, "should have 2 nodes: {content:?}");
8721 assert_eq!(content[0].node_type, "emoji");
8722 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":wave:");
8723 assert_eq!(content[1].node_type, "text");
8724 assert_eq!(content[1].text.as_deref().unwrap(), " says :hello: to you");
8725 }
8726
8727 #[test]
8728 fn code_block_with_shortcode_pattern_round_trips() {
8729 let adf_json = r#"{"version":1,"type":"doc","content":[
8732 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8733 {"type":"text","text":"module Foo::Bar::Baz\n def hello\n puts 'world'\n end\nend"}
8734 ]}
8735 ]}"#;
8736 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8737
8738 let md = adf_to_markdown(&doc).unwrap();
8739 let round_tripped = markdown_to_adf(&md).unwrap();
8740
8741 assert_eq!(
8742 round_tripped.content.len(),
8743 1,
8744 "should be a single codeBlock"
8745 );
8746 let cb = &round_tripped.content[0];
8747 assert_eq!(cb.node_type, "codeBlock");
8748 let content = cb.content.as_ref().expect("codeBlock content");
8749 assert_eq!(
8750 content.len(),
8751 1,
8752 "should be a single text node: {content:?}"
8753 );
8754 assert_eq!(content[0].node_type, "text");
8755 assert_eq!(
8756 content[0].text.as_deref().unwrap(),
8757 "module Foo::Bar::Baz\n def hello\n puts 'world'\n end\nend"
8758 );
8759 assert!(
8760 content.iter().all(|n| n.node_type != "emoji"),
8761 "no emoji nodes should be present, got: {content:?}"
8762 );
8763 }
8764
8765 #[test]
8766 fn code_block_with_exact_namespaced_shortcode_pattern_round_trips() {
8767 let adf_json = r#"{"version":1,"type":"doc","content":[
8769 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8770 {"type":"text","text":"class ZBC::Acme::PlanType::Professional < Base"}
8771 ]}
8772 ]}"#;
8773 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8774
8775 let md = adf_to_markdown(&doc).unwrap();
8776 let round_tripped = markdown_to_adf(&md).unwrap();
8777
8778 let cb = &round_tripped.content[0];
8779 assert_eq!(cb.node_type, "codeBlock");
8780 let content = cb.content.as_ref().expect("codeBlock content");
8781 assert_eq!(content.len(), 1, "should be a single text node");
8782 assert_eq!(
8783 content[0].text.as_deref().unwrap(),
8784 "class ZBC::Acme::PlanType::Professional < Base"
8785 );
8786 }
8787
8788 #[test]
8789 fn code_block_with_literal_shortcode_round_trips() {
8790 let adf_json = r#"{"version":1,"type":"doc","content":[
8793 {"type":"codeBlock","attrs":{"language":"text"},"content":[
8794 {"type":"text","text":":fire: :wave: :thumbsup:"}
8795 ]}
8796 ]}"#;
8797 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8798
8799 let md = adf_to_markdown(&doc).unwrap();
8800 let round_tripped = markdown_to_adf(&md).unwrap();
8801
8802 let cb = &round_tripped.content[0];
8803 assert_eq!(cb.node_type, "codeBlock");
8804 let content = cb.content.as_ref().expect("codeBlock content");
8805 assert_eq!(
8806 content.len(),
8807 1,
8808 "should be a single text node: {content:?}"
8809 );
8810 assert_eq!(content[0].node_type, "text");
8811 assert_eq!(
8812 content[0].text.as_deref().unwrap(),
8813 ":fire: :wave: :thumbsup:"
8814 );
8815 }
8816
8817 #[test]
8818 fn inline_code_with_shortcode_pattern_round_trips() {
8819 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8822 {"type":"text","text":"See "},
8823 {"type":"text","text":"Foo::Bar::Baz","marks":[{"type":"code"}]},
8824 {"type":"text","text":" for details"}
8825 ]}]}"#;
8826 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8827
8828 let md = adf_to_markdown(&doc).unwrap();
8829 let round_tripped = markdown_to_adf(&md).unwrap();
8830 let content = round_tripped.content[0].content.as_ref().unwrap();
8831
8832 assert_eq!(content.len(), 3, "should have 3 text nodes: {content:?}");
8833 assert_eq!(content[0].text.as_deref(), Some("See "));
8834 assert_eq!(content[1].text.as_deref(), Some("Foo::Bar::Baz"));
8835 assert!(content[1]
8836 .marks
8837 .as_ref()
8838 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8839 assert_eq!(content[2].text.as_deref(), Some(" for details"));
8840 assert!(
8841 content.iter().all(|n| n.node_type != "emoji"),
8842 "no emoji nodes should be present"
8843 );
8844 }
8845
8846 #[test]
8847 fn inline_code_with_literal_shortcode_round_trips() {
8848 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8851 {"type":"text","text":":fire:","marks":[{"type":"code"}]}
8852 ]}]}"#;
8853 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8854
8855 let md = adf_to_markdown(&doc).unwrap();
8856 let round_tripped = markdown_to_adf(&md).unwrap();
8857 let content = round_tripped.content[0].content.as_ref().unwrap();
8858
8859 assert_eq!(
8860 content.len(),
8861 1,
8862 "should be a single code node: {content:?}"
8863 );
8864 assert_eq!(content[0].node_type, "text");
8865 assert_eq!(content[0].text.as_deref(), Some(":fire:"));
8866 assert!(content[0]
8867 .marks
8868 .as_ref()
8869 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8870 }
8871
8872 #[test]
8873 fn code_block_in_list_with_shortcode_pattern_round_trips() {
8874 let adf_json = r#"{"version":1,"type":"doc","content":[
8877 {"type":"bulletList","content":[
8878 {"type":"listItem","content":[
8879 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8880 {"type":"text","text":"Foo::Bar::Baz"}
8881 ]}
8882 ]}
8883 ]}
8884 ]}"#;
8885 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8886
8887 let md = adf_to_markdown(&doc).unwrap();
8888 let round_tripped = markdown_to_adf(&md).unwrap();
8889
8890 let list = &round_tripped.content[0];
8891 assert_eq!(list.node_type, "bulletList");
8892 let item = &list.content.as_ref().unwrap()[0];
8893 assert_eq!(item.node_type, "listItem");
8894 let cb = &item.content.as_ref().unwrap()[0];
8895 assert_eq!(cb.node_type, "codeBlock");
8896 let cb_content = cb.content.as_ref().unwrap();
8897 assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8898 assert_eq!(cb_content[0].node_type, "text");
8899 }
8900
8901 #[test]
8902 fn code_block_with_unicode_shortcode_pattern_round_trips() {
8903 let adf_json = r#"{"version":1,"type":"doc","content":[
8907 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8908 {"type":"text","text":"class ZBC::配置::Production < Base"}
8909 ]}
8910 ]}"#;
8911 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8912
8913 let md = adf_to_markdown(&doc).unwrap();
8914 let round_tripped = markdown_to_adf(&md).unwrap();
8915
8916 let cb = &round_tripped.content[0];
8917 assert_eq!(cb.node_type, "codeBlock");
8918 let content = cb.content.as_ref().expect("codeBlock content");
8919 assert_eq!(content.len(), 1);
8920 assert_eq!(
8921 content[0].text.as_deref().unwrap(),
8922 "class ZBC::配置::Production < Base"
8923 );
8924 }
8925
8926 #[test]
8927 fn list_item_hardbreak_then_code_block_round_trips() {
8928 let adf_json = r#"{"version":1,"type":"doc","content":[
8934 {"type":"bulletList","content":[
8935 {"type":"listItem","content":[
8936 {"type":"paragraph","content":[
8937 {"type":"text","text":"Consider removing this check."},
8938 {"type":"hardBreak"}
8939 ]},
8940 {"type":"codeBlock","content":[
8941 {"type":"text","text":"x = Foo::Bar::Baz.new"}
8942 ]}
8943 ]}
8944 ]}
8945 ]}"#;
8946 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8947
8948 let md = adf_to_markdown(&doc).unwrap();
8949 let round_tripped = markdown_to_adf(&md).unwrap();
8950
8951 let list = &round_tripped.content[0];
8952 assert_eq!(list.node_type, "bulletList");
8953 let item = &list.content.as_ref().unwrap()[0];
8954 assert_eq!(item.node_type, "listItem");
8955 let item_content = item.content.as_ref().unwrap();
8956 assert_eq!(
8957 item_content.len(),
8958 2,
8959 "listItem should have paragraph + codeBlock, got: {item_content:?}"
8960 );
8961 assert_eq!(item_content[0].node_type, "paragraph");
8962 assert_eq!(item_content[1].node_type, "codeBlock");
8963
8964 let cb_content = item_content[1].content.as_ref().unwrap();
8966 assert_eq!(cb_content[0].node_type, "text");
8967 assert_eq!(
8968 cb_content[0].text.as_deref().unwrap(),
8969 "x = Foo::Bar::Baz.new"
8970 );
8971
8972 assert!(
8974 item_content
8975 .iter()
8976 .flat_map(|n| n.content.iter().flat_map(|c| c.iter()))
8977 .all(|n| n.node_type != "emoji"),
8978 "no emoji nodes should be present, got: {item_content:?}"
8979 );
8980 }
8981
8982 #[test]
8983 fn list_item_hardbreak_then_nested_list_still_works() {
8984 let md = "- first\\\n continuation text\\\n - nested item\n";
8988 let doc = markdown_to_adf(md).unwrap();
8989 let list = &doc.content[0];
8990 assert_eq!(list.node_type, "bulletList");
8991 let item = &list.content.as_ref().unwrap()[0];
8992 let item_content = item.content.as_ref().unwrap();
8994 let para = &item_content[0];
8995 assert_eq!(para.node_type, "paragraph");
8996 let para_nodes = para.content.as_ref().unwrap();
8997 assert!(
8998 para_nodes
8999 .iter()
9000 .any(|n| n.text.as_deref() == Some("continuation text")),
9001 "continuation text should survive: {para_nodes:?}"
9002 );
9003 }
9004
9005 #[test]
9006 fn list_item_hardbreak_then_image_still_works() {
9007 let md = "- first\\\n {type=file id=x}\n";
9012 let doc = markdown_to_adf(md).unwrap();
9013 let list = &doc.content[0];
9014 let item = &list.content.as_ref().unwrap()[0];
9015 let item_content = item.content.as_ref().unwrap();
9016 assert!(
9018 item_content.iter().any(|n| n.node_type == "mediaSingle"),
9019 "mediaSingle should still be a block-level sibling, got: {item_content:?}"
9020 );
9021 }
9022
9023 #[test]
9024 fn list_item_hardbreak_then_container_directive_round_trips() {
9025 let adf_json = r#"{"version":1,"type":"doc","content":[
9029 {"type":"bulletList","content":[
9030 {"type":"listItem","content":[
9031 {"type":"paragraph","content":[
9032 {"type":"text","text":"intro"},
9033 {"type":"hardBreak"}
9034 ]},
9035 {"type":"panel","attrs":{"panelType":"info"},"content":[
9036 {"type":"paragraph","content":[
9037 {"type":"text","text":"panel body"}
9038 ]}
9039 ]}
9040 ]}
9041 ]}
9042 ]}"#;
9043 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9044 let md = adf_to_markdown(&doc).unwrap();
9045 let round_tripped = markdown_to_adf(&md).unwrap();
9046
9047 let item = &round_tripped.content[0].content.as_ref().unwrap()[0];
9048 let item_content = item.content.as_ref().unwrap();
9049 assert!(
9050 item_content.iter().any(|n| n.node_type == "panel"),
9051 "panel should survive as block-level sibling, got: {item_content:?}"
9052 );
9053 }
9054
9055 #[test]
9056 fn inline_code_with_unicode_shortcode_pattern_round_trips() {
9057 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
9060 {"type":"text","text":"See "},
9061 {"type":"text","text":"ZBC::配置::Production","marks":[{"type":"code"}]},
9062 {"type":"text","text":" for prod"}
9063 ]}]}"#;
9064 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9065
9066 let md = adf_to_markdown(&doc).unwrap();
9067 let round_tripped = markdown_to_adf(&md).unwrap();
9068 let content = round_tripped.content[0].content.as_ref().unwrap();
9069
9070 assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
9071 assert_eq!(content[1].text.as_deref(), Some("ZBC::配置::Production"));
9072 assert!(content[1]
9073 .marks
9074 .as_ref()
9075 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
9076 }
9077
9078 #[test]
9079 fn code_block_followed_by_shortcode_text_round_trips() {
9080 let adf_json = r#"{"version":1,"type":"doc","content":[
9083 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
9084 {"type":"text","text":"Foo::Bar::Baz"}
9085 ]},
9086 {"type":"paragraph","content":[
9087 {"type":"text","text":"Status::Active::Running"}
9088 ]}
9089 ]}"#;
9090 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9091
9092 let md = adf_to_markdown(&doc).unwrap();
9093 let round_tripped = markdown_to_adf(&md).unwrap();
9094
9095 assert_eq!(round_tripped.content.len(), 2);
9096 let cb = &round_tripped.content[0];
9097 assert_eq!(cb.node_type, "codeBlock");
9098 let cb_content = cb.content.as_ref().unwrap();
9099 assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
9100
9101 let para = &round_tripped.content[1];
9102 assert_eq!(para.node_type, "paragraph");
9103 let para_content = para.content.as_ref().unwrap();
9104 assert_eq!(para_content.len(), 1);
9105 assert_eq!(para_content[0].node_type, "text");
9106 assert_eq!(
9107 para_content[0].text.as_deref(),
9108 Some("Status::Active::Running")
9109 );
9110 }
9111
9112 #[test]
9113 fn adf_inline_card_to_markdown() {
9114 let doc = AdfDocument {
9115 version: 1,
9116 doc_type: "doc".to_string(),
9117 content: vec![AdfNode::paragraph(vec![AdfNode {
9118 node_type: "inlineCard".to_string(),
9119 attrs: Some(
9120 serde_json::json!({"url": "https://org.atlassian.net/browse/ACCS-4382"}),
9121 ),
9122 content: None,
9123 text: None,
9124 marks: None,
9125 local_id: None,
9126 parameters: None,
9127 }])],
9128 };
9129 let md = adf_to_markdown(&doc).unwrap();
9130 assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
9131 assert!(!md.contains("<!-- unsupported inline"));
9132 }
9133
9134 #[test]
9135 fn inline_card_directive_round_trips() {
9136 let original = AdfDocument {
9138 version: 1,
9139 doc_type: "doc".to_string(),
9140 content: vec![AdfNode::paragraph(vec![AdfNode::inline_card(
9141 "https://org.atlassian.net/browse/ACCS-4382",
9142 )])],
9143 };
9144 let md = adf_to_markdown(&original).unwrap();
9145 assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
9146 let restored = markdown_to_adf(&md).unwrap();
9147 let node = &restored.content[0].content.as_ref().unwrap()[0];
9148 assert_eq!(node.node_type, "inlineCard");
9149 assert_eq!(
9150 node.attrs.as_ref().unwrap()["url"],
9151 "https://org.atlassian.net/browse/ACCS-4382"
9152 );
9153 }
9154
9155 #[test]
9156 fn inline_card_directive_parsed_from_jfm() {
9157 let doc = markdown_to_adf("See :card[https://example.com/issue/123] for details.").unwrap();
9159 let nodes = doc.content[0].content.as_ref().unwrap();
9160 assert_eq!(nodes[0].node_type, "text");
9161 assert_eq!(nodes[0].text.as_deref(), Some("See "));
9162 assert_eq!(nodes[1].node_type, "inlineCard");
9163 assert_eq!(
9164 nodes[1].attrs.as_ref().unwrap()["url"],
9165 "https://example.com/issue/123"
9166 );
9167 assert_eq!(nodes[2].node_type, "text");
9168 assert_eq!(nodes[2].text.as_deref(), Some(" for details."));
9169 }
9170
9171 #[test]
9172 fn self_link_becomes_link_mark_not_inline_card() {
9173 let doc = markdown_to_adf("[https://example.com](https://example.com)").unwrap();
9176 let node = &doc.content[0].content.as_ref().unwrap()[0];
9177 assert_eq!(node.node_type, "text");
9178 assert_eq!(node.text.as_deref(), Some("https://example.com"));
9179 let mark = &node.marks.as_ref().unwrap()[0];
9180 assert_eq!(mark.mark_type, "link");
9181 assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
9182 }
9183
9184 #[test]
9185 fn url_link_text_with_trailing_slash_mismatch_becomes_link_mark() {
9186 let doc =
9189 markdown_to_adf("[https://octopz.example.com](https://octopz.example.com/)").unwrap();
9190 let node = &doc.content[0].content.as_ref().unwrap()[0];
9191 assert_eq!(node.node_type, "text");
9192 assert_eq!(node.text.as_deref(), Some("https://octopz.example.com"));
9193 let mark = &node.marks.as_ref().unwrap()[0];
9194 assert_eq!(mark.mark_type, "link");
9195 assert_eq!(
9196 mark.attrs.as_ref().unwrap()["href"],
9197 "https://octopz.example.com/"
9198 );
9199 }
9200
9201 #[test]
9202 fn named_link_does_not_become_inline_card() {
9203 let doc = markdown_to_adf("[#4668](https://github.com/org/repo/pull/4668)").unwrap();
9205 let node = &doc.content[0].content.as_ref().unwrap()[0];
9206 assert_eq!(node.node_type, "text");
9207 assert_eq!(node.text.as_deref(), Some("#4668"));
9208 let mark = &node.marks.as_ref().unwrap()[0];
9209 assert_eq!(mark.mark_type, "link");
9210 }
9211
9212 #[test]
9213 fn adf_inline_card_no_url_to_markdown() {
9214 let doc = AdfDocument {
9215 version: 1,
9216 doc_type: "doc".to_string(),
9217 content: vec![AdfNode::paragraph(vec![AdfNode {
9218 node_type: "inlineCard".to_string(),
9219 attrs: Some(serde_json::json!({})),
9220 content: None,
9221 text: None,
9222 marks: None,
9223 local_id: None,
9224 parameters: None,
9225 }])],
9226 };
9227 let md = adf_to_markdown(&doc).unwrap();
9228 assert!(!md.contains("<!-- unsupported inline"));
9230 }
9231
9232 #[test]
9233 fn adf_code_block_no_language_to_markdown() {
9234 let doc = AdfDocument {
9235 version: 1,
9236 doc_type: "doc".to_string(),
9237 content: vec![AdfNode::code_block(None, "plain code")],
9238 };
9239 let md = adf_to_markdown(&doc).unwrap();
9240 assert!(md.contains("```\n"));
9241 assert!(md.contains("plain code"));
9242 }
9243
9244 #[test]
9245 fn adf_code_block_empty_language_to_markdown() {
9246 let doc = AdfDocument {
9247 version: 1,
9248 doc_type: "doc".to_string(),
9249 content: vec![AdfNode::code_block(Some(""), "plain code")],
9250 };
9251 let md = adf_to_markdown(&doc).unwrap();
9252 assert!(md.contains("```\"\"\n"));
9253 assert!(md.contains("plain code"));
9254 }
9255
9256 #[test]
9259 fn round_trip_table() {
9260 let md = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
9261 let adf = markdown_to_adf(md).unwrap();
9262 let restored = adf_to_markdown(&adf).unwrap();
9263 assert!(restored.contains("| A | B |"));
9264 assert!(restored.contains("| 1 | 2 |"));
9265 }
9266
9267 #[test]
9268 fn round_trip_blockquote() {
9269 let md = "> This is quoted\n";
9270 let adf = markdown_to_adf(md).unwrap();
9271 let restored = adf_to_markdown(&adf).unwrap();
9272 assert!(restored.contains("> This is quoted"));
9273 }
9274
9275 #[test]
9276 fn round_trip_image() {
9277 let md = "\n";
9278 let adf = markdown_to_adf(md).unwrap();
9279 let restored = adf_to_markdown(&adf).unwrap();
9280 assert!(restored.contains(""));
9281 }
9282
9283 #[test]
9284 fn round_trip_ordered_list() {
9285 let md = "1. A\n2. B\n3. C\n";
9286 let adf = markdown_to_adf(md).unwrap();
9287 let restored = adf_to_markdown(&adf).unwrap();
9288 assert!(restored.contains("1. A"));
9289 assert!(restored.contains("2. B"));
9290 assert!(restored.contains("3. C"));
9291 }
9292
9293 #[test]
9294 fn round_trip_inline_marks() {
9295 let md = "Text with `code` and ~~strike~~ and [link](https://x.com).\n";
9296 let adf = markdown_to_adf(md).unwrap();
9297 let restored = adf_to_markdown(&adf).unwrap();
9298 assert!(restored.contains("`code`"));
9299 assert!(restored.contains("~~strike~~"));
9300 assert!(restored.contains("[link](https://x.com)"));
9301 }
9302
9303 #[test]
9306 fn panel_info() {
9307 let md = ":::panel{type=info}\nThis is informational.\n:::";
9308 let doc = markdown_to_adf(md).unwrap();
9309 assert_eq!(doc.content[0].node_type, "panel");
9310 assert_eq!(doc.content[0].attrs.as_ref().unwrap()["panelType"], "info");
9311 let inner = doc.content[0].content.as_ref().unwrap();
9312 assert_eq!(inner[0].node_type, "paragraph");
9313 }
9314
9315 #[test]
9316 fn adf_panel_to_markdown() {
9317 let doc = AdfDocument {
9318 version: 1,
9319 doc_type: "doc".to_string(),
9320 content: vec![AdfNode::panel(
9321 "warning",
9322 vec![AdfNode::paragraph(vec![AdfNode::text("Be careful.")])],
9323 )],
9324 };
9325 let md = adf_to_markdown(&doc).unwrap();
9326 assert!(md.contains(":::panel{type=warning}"));
9327 assert!(md.contains("Be careful."));
9328 assert!(md.contains(":::"));
9329 }
9330
9331 #[test]
9332 fn round_trip_panel() {
9333 let md = ":::panel{type=info}\nThis is informational.\n:::\n";
9334 let doc = markdown_to_adf(md).unwrap();
9335 let result = adf_to_markdown(&doc).unwrap();
9336 assert!(result.contains(":::panel{type=info}"));
9337 assert!(result.contains("This is informational."));
9338 }
9339
9340 #[test]
9341 fn expand_with_title() {
9342 let md = ":::expand{title=\"Click me\"}\nHidden content.\n:::";
9343 let doc = markdown_to_adf(md).unwrap();
9344 assert_eq!(doc.content[0].node_type, "expand");
9345 assert_eq!(doc.content[0].attrs.as_ref().unwrap()["title"], "Click me");
9346 }
9347
9348 #[test]
9349 fn adf_expand_to_markdown() {
9350 let doc = AdfDocument {
9351 version: 1,
9352 doc_type: "doc".to_string(),
9353 content: vec![AdfNode::expand(
9354 Some("Details"),
9355 vec![AdfNode::paragraph(vec![AdfNode::text("Inner.")])],
9356 )],
9357 };
9358 let md = adf_to_markdown(&doc).unwrap();
9359 assert!(md.contains(":::expand{title=\"Details\"}"));
9360 assert!(md.contains("Inner."));
9361 }
9362
9363 #[test]
9364 fn round_trip_expand() {
9365 let md = ":::expand{title=\"Details\"}\nInner content.\n:::\n";
9366 let doc = markdown_to_adf(md).unwrap();
9367 let result = adf_to_markdown(&doc).unwrap();
9368 assert!(result.contains(":::expand{title=\"Details\"}"));
9369 assert!(result.contains("Inner content."));
9370 }
9371
9372 #[test]
9373 fn layout_two_columns() {
9374 let md =
9375 "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
9376 let doc = markdown_to_adf(md).unwrap();
9377 assert_eq!(doc.content[0].node_type, "layoutSection");
9378 let columns = doc.content[0].content.as_ref().unwrap();
9379 assert_eq!(columns.len(), 2);
9380 assert_eq!(columns[0].node_type, "layoutColumn");
9381 assert_eq!(columns[1].node_type, "layoutColumn");
9382 }
9383
9384 #[test]
9385 fn adf_layout_to_markdown() {
9386 let doc = AdfDocument {
9387 version: 1,
9388 doc_type: "doc".to_string(),
9389 content: vec![AdfNode::layout_section(vec![
9390 AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Left.")])]),
9391 AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Right.")])]),
9392 ])],
9393 };
9394 let md = adf_to_markdown(&doc).unwrap();
9395 assert!(md.contains("::::layout"));
9396 assert!(md.contains(":::column{width=50}"));
9397 assert!(md.contains("Left."));
9398 assert!(md.contains("Right."));
9399 }
9400
9401 #[test]
9402 fn layout_column_localid_roundtrip() {
9403 let adf_json = r#"{
9404 "version": 1,
9405 "type": "doc",
9406 "content": [{
9407 "type": "layoutSection",
9408 "content": [
9409 {
9410 "type": "layoutColumn",
9411 "attrs": {"width": 50.0, "localId": "aabb112233cc"},
9412 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Left"}]}]
9413 },
9414 {
9415 "type": "layoutColumn",
9416 "attrs": {"width": 50.0, "localId": "ddeeff445566"},
9417 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Right"}]}]
9418 }
9419 ]
9420 }]
9421 }"#;
9422 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9423 let md = adf_to_markdown(&doc).unwrap();
9424 assert!(
9425 md.contains("localId=aabb112233cc"),
9426 "first column localId should appear in markdown: {md}"
9427 );
9428 assert!(
9429 md.contains("localId=ddeeff445566"),
9430 "second column localId should appear in markdown: {md}"
9431 );
9432 let rt = markdown_to_adf(&md).unwrap();
9433 let cols = rt.content[0].content.as_ref().unwrap();
9434 assert_eq!(
9435 cols[0].attrs.as_ref().unwrap()["localId"],
9436 "aabb112233cc",
9437 "first column localId should round-trip"
9438 );
9439 assert_eq!(
9440 cols[1].attrs.as_ref().unwrap()["localId"],
9441 "ddeeff445566",
9442 "second column localId should round-trip"
9443 );
9444 }
9445
9446 #[test]
9447 fn layout_column_without_localid() {
9448 let md =
9449 "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
9450 let doc = markdown_to_adf(md).unwrap();
9451 let cols = doc.content[0].content.as_ref().unwrap();
9452 assert!(
9453 cols[0].attrs.as_ref().unwrap().get("localId").is_none(),
9454 "column without localId should not gain one"
9455 );
9456 let md2 = adf_to_markdown(&doc).unwrap();
9457 assert!(
9458 !md2.contains("localId"),
9459 "no localId should appear in output: {md2}"
9460 );
9461 }
9462
9463 #[test]
9464 fn layout_column_localid_stripped_when_option_set() {
9465 let adf_json = r#"{
9466 "version": 1,
9467 "type": "doc",
9468 "content": [{
9469 "type": "layoutSection",
9470 "content": [{
9471 "type": "layoutColumn",
9472 "attrs": {"width": 50.0, "localId": "aabb112233cc"},
9473 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Col"}]}]
9474 }]
9475 }]
9476 }"#;
9477 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9478 let opts = RenderOptions {
9479 strip_local_ids: true,
9480 ..Default::default()
9481 };
9482 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
9483 assert!(!md.contains("localId"), "localId should be stripped: {md}");
9484 }
9485
9486 #[test]
9487 fn layout_column_localid_flush_previous() {
9488 let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nLeft.\n:::column{width=50 localId=ddeeff445566}\nRight.\n:::\n::::";
9490 let doc = markdown_to_adf(md).unwrap();
9491 let cols = doc.content[0].content.as_ref().unwrap();
9492 assert_eq!(
9493 cols[0].attrs.as_ref().unwrap()["localId"],
9494 "aabb112233cc",
9495 "flush-previous column should preserve localId"
9496 );
9497 assert_eq!(
9498 cols[1].attrs.as_ref().unwrap()["localId"],
9499 "ddeeff445566",
9500 "second column localId should be preserved"
9501 );
9502 }
9503
9504 #[test]
9505 fn layout_column_localid_flush_last() {
9506 let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nOnly column.";
9508 let doc = markdown_to_adf(md).unwrap();
9509 let cols = doc.content[0].content.as_ref().unwrap();
9510 assert_eq!(
9511 cols[0].attrs.as_ref().unwrap()["localId"],
9512 "aabb112233cc",
9513 "flush-last column should preserve localId"
9514 );
9515 }
9516
9517 #[test]
9519 fn issue_555_layout_column_fractional_width_roundtrip() {
9520 let adf_json = r#"{
9521 "version": 1,
9522 "type": "doc",
9523 "content": [{
9524 "type": "layoutSection",
9525 "content": [
9526 {
9527 "type": "layoutColumn",
9528 "attrs": {"width": 66.66},
9529 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9530 },
9531 {
9532 "type": "layoutColumn",
9533 "attrs": {"width": 33.34},
9534 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9535 }
9536 ]
9537 }]
9538 }"#;
9539 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9540 let md = adf_to_markdown(&doc).unwrap();
9541 assert!(md.contains("width=66.66"), "fractional width in md: {md}");
9542 assert!(md.contains("width=33.34"), "fractional width in md: {md}");
9543 let rt = markdown_to_adf(&md).unwrap();
9544 let cols = rt.content[0].content.as_ref().unwrap();
9545 assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 66.66);
9546 assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 33.34);
9547 }
9548
9549 #[test]
9551 fn issue_555_layout_column_five_sixths_width_roundtrip() {
9552 let adf_json = r#"{
9553 "version": 1,
9554 "type": "doc",
9555 "content": [{
9556 "type": "layoutSection",
9557 "content": [
9558 {
9559 "type": "layoutColumn",
9560 "attrs": {"width": 83.33},
9561 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9562 },
9563 {
9564 "type": "layoutColumn",
9565 "attrs": {"width": 16.67},
9566 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9567 }
9568 ]
9569 }]
9570 }"#;
9571 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9572 let md = adf_to_markdown(&doc).unwrap();
9573 let rt = markdown_to_adf(&md).unwrap();
9574 let cols = rt.content[0].content.as_ref().unwrap();
9575 assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 83.33);
9576 assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 16.67);
9577 }
9578
9579 #[test]
9581 fn issue_555_layout_column_integer_width_preserved() {
9582 let adf_json = r#"{
9583 "version": 1,
9584 "type": "doc",
9585 "content": [{
9586 "type": "layoutSection",
9587 "content": [
9588 {
9589 "type": "layoutColumn",
9590 "attrs": {"width": 50},
9591 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "A"}]}]
9592 },
9593 {
9594 "type": "layoutColumn",
9595 "attrs": {"width": 50},
9596 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "B"}]}]
9597 }
9598 ]
9599 }]
9600 }"#;
9601 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9602 let md = adf_to_markdown(&doc).unwrap();
9603 assert!(
9604 md.contains("width=50") && !md.contains("width=50."),
9605 "integer width should render without decimal: {md}"
9606 );
9607 let rt = markdown_to_adf(&md).unwrap();
9608 let cols = rt.content[0].content.as_ref().unwrap();
9609 let w0 = &cols[0].attrs.as_ref().unwrap()["width"];
9610 assert!(
9611 w0.is_i64() || w0.is_u64(),
9612 "width should remain a JSON integer, got: {w0}"
9613 );
9614 assert_eq!(w0.as_i64(), Some(50));
9615 }
9616
9617 #[test]
9619 fn issue_555_media_single_integer_width_preserved() {
9620 let adf_json = r#"{
9621 "version": 1,
9622 "type": "doc",
9623 "content": [{
9624 "type": "mediaSingle",
9625 "attrs": {"layout": "center", "width": 800},
9626 "content": [
9627 {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9628 ]
9629 }]
9630 }"#;
9631 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9632 let md = adf_to_markdown(&doc).unwrap();
9633 assert!(
9634 md.contains("width=800") && !md.contains("width=800."),
9635 "integer width should render without decimal: {md}"
9636 );
9637 let rt = markdown_to_adf(&md).unwrap();
9638 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9639 let w = &ms_attrs["width"];
9640 assert!(
9641 w.is_i64() || w.is_u64(),
9642 "mediaSingle width should remain JSON integer, got: {w}"
9643 );
9644 assert_eq!(w.as_i64(), Some(800));
9645 }
9646
9647 #[test]
9651 fn issue_555_media_single_fractional_width_preserved() {
9652 let adf_json = r#"{
9653 "version": 1,
9654 "type": "doc",
9655 "content": [{
9656 "type": "mediaSingle",
9657 "attrs": {"layout": "center", "width": 66.5},
9658 "content": [
9659 {"type": "media", "attrs": {"type": "external", "url": "https://example.com/diagram.png"}}
9660 ]
9661 }]
9662 }"#;
9663 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9664 let md = adf_to_markdown(&doc).unwrap();
9665 assert!(
9666 md.contains("width=66.5"),
9667 "fractional width must appear in JFM: {md}"
9668 );
9669 let rt = markdown_to_adf(&md).unwrap();
9670 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9671 assert_eq!(ms_attrs["width"], 66.5);
9672 }
9673
9674 #[test]
9676 fn issue_555_media_single_float_width_preserved() {
9677 let adf_json = r#"{
9678 "version": 1,
9679 "type": "doc",
9680 "content": [{
9681 "type": "mediaSingle",
9682 "attrs": {"layout": "center", "width": 800.0},
9683 "content": [
9684 {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9685 ]
9686 }]
9687 }"#;
9688 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9689 let md = adf_to_markdown(&doc).unwrap();
9690 assert!(
9691 md.contains("width=800.0"),
9692 "float width should render with decimal: {md}"
9693 );
9694 let rt = markdown_to_adf(&md).unwrap();
9695 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9696 let w = &ms_attrs["width"];
9697 assert!(
9698 w.is_f64(),
9699 "mediaSingle float width should stay a JSON float, got: {w}"
9700 );
9701 assert_eq!(w.as_f64(), Some(800.0));
9702 }
9703
9704 #[test]
9706 fn issue_555_media_single_wide_layout_integer_width_roundtrip() {
9707 let adf_json = r#"{
9708 "version": 1,
9709 "type": "doc",
9710 "content": [{
9711 "type": "mediaSingle",
9712 "attrs": {"layout": "wide", "width": 1420},
9713 "content": [
9714 {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9715 ]
9716 }]
9717 }"#;
9718 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9719 let md = adf_to_markdown(&doc).unwrap();
9720 let rt = markdown_to_adf(&md).unwrap();
9721 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9722 assert_eq!(ms_attrs["layout"], "wide");
9723 let w = &ms_attrs["width"];
9724 assert!(
9725 w.is_i64() || w.is_u64(),
9726 "mediaSingle width should remain JSON integer, got: {w}"
9727 );
9728 assert_eq!(w.as_i64(), Some(1420));
9729 }
9730
9731 #[test]
9734 fn issue_555_file_media_single_integer_width_preserved() {
9735 let adf_json = r#"{
9736 "version": 1,
9737 "type": "doc",
9738 "content": [{
9739 "type": "mediaSingle",
9740 "attrs": {"layout": "wide", "width": 1420},
9741 "content": [
9742 {"type": "media", "attrs": {"id": "abc-123", "type": "file", "collection": "col-1", "width": 1200, "height": 800}}
9743 ]
9744 }]
9745 }"#;
9746 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9747 let md = adf_to_markdown(&doc).unwrap();
9748 let rt = markdown_to_adf(&md).unwrap();
9749 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9750 let ms_w = &ms_attrs["width"];
9751 assert!(ms_w.is_i64() || ms_w.is_u64(), "ms width: {ms_w}");
9752 assert_eq!(ms_w.as_i64(), Some(1420));
9753 let media = &rt.content[0].content.as_ref().unwrap()[0];
9754 let media_attrs = media.attrs.as_ref().unwrap();
9755 let mw = &media_attrs["width"];
9756 assert!(mw.is_i64() || mw.is_u64(), "media width: {mw}");
9757 assert_eq!(mw.as_i64(), Some(1200));
9758 let mh = &media_attrs["height"];
9759 assert!(mh.is_i64() || mh.is_u64(), "media height: {mh}");
9760 assert_eq!(mh.as_i64(), Some(800));
9761 }
9762
9763 #[test]
9765 fn issue_555_fmt_numeric_attr_preserves_type() {
9766 assert_eq!(
9767 fmt_numeric_attr(&serde_json::json!(50)).as_deref(),
9768 Some("50")
9769 );
9770 assert_eq!(
9771 fmt_numeric_attr(&serde_json::json!(50.0)).as_deref(),
9772 Some("50.0")
9773 );
9774 assert_eq!(
9775 fmt_numeric_attr(&serde_json::json!(66.66)).as_deref(),
9776 Some("66.66")
9777 );
9778 assert_eq!(
9779 fmt_numeric_attr(&serde_json::json!(-5)).as_deref(),
9780 Some("-5")
9781 );
9782 assert_eq!(fmt_numeric_attr(&serde_json::json!("not a number")), None);
9783 let big = serde_json::Value::Number(serde_json::Number::from(u64::MAX));
9785 assert_eq!(
9786 fmt_numeric_attr(&big).as_deref(),
9787 Some("18446744073709551615")
9788 );
9789 assert_eq!(fmt_numeric_attr(&serde_json::Value::Null), None);
9791 }
9792
9793 #[test]
9795 fn issue_555_parse_numeric_attr_detects_type() {
9796 let v = parse_numeric_attr("800").unwrap();
9797 assert!(v.is_i64() || v.is_u64(), "'800' should parse as integer");
9798 assert_eq!(v.as_i64(), Some(800));
9799
9800 let v = parse_numeric_attr("800.0").unwrap();
9801 assert!(v.is_f64(), "'800.0' should parse as float");
9802 assert_eq!(v.as_f64(), Some(800.0));
9803
9804 let v = parse_numeric_attr("66.66").unwrap();
9805 assert!(v.is_f64());
9806 assert_eq!(v.as_f64(), Some(66.66));
9807
9808 let v = parse_numeric_attr("1e2").unwrap();
9810 assert!(v.is_f64());
9811 assert_eq!(v.as_f64(), Some(100.0));
9812 let v = parse_numeric_attr("1E2").unwrap();
9813 assert!(v.is_f64());
9814 assert_eq!(v.as_f64(), Some(100.0));
9815
9816 let v = parse_numeric_attr("-42").unwrap();
9818 assert!(v.is_i64());
9819 assert_eq!(v.as_i64(), Some(-42));
9820
9821 assert!(parse_numeric_attr("not-a-number").is_none());
9822 assert!(parse_numeric_attr("").is_none());
9823 assert!(parse_numeric_attr("1.2.3").is_none());
9824 }
9825
9826 #[test]
9829 fn issue_555_media_single_wide_layout_fractional_width_roundtrip() {
9830 let adf_json = r#"{
9831 "version": 1,
9832 "type": "doc",
9833 "content": [{
9834 "type": "mediaSingle",
9835 "attrs": {"layout": "wide", "width": 83.33},
9836 "content": [
9837 {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9838 ]
9839 }]
9840 }"#;
9841 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9842 let md = adf_to_markdown(&doc).unwrap();
9843 assert!(md.contains("layout=wide"), "layout must appear in md: {md}");
9844 assert!(md.contains("width=83.33"), "width must appear in md: {md}");
9845 let rt = markdown_to_adf(&md).unwrap();
9846 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9847 assert_eq!(ms_attrs["layout"], "wide");
9848 assert_eq!(ms_attrs["width"], 83.33);
9849 }
9850
9851 #[test]
9855 fn issue_555_file_media_single_fractional_media_width_preserved() {
9856 let adf_json = r#"{
9857 "version": 1,
9858 "type": "doc",
9859 "content": [{
9860 "type": "mediaSingle",
9861 "attrs": {"layout": "wide", "width": 66.5},
9862 "content": [
9863 {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c"}}
9864 ]
9865 }]
9866 }"#;
9867 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9868 let md = adf_to_markdown(&doc).unwrap();
9869 assert!(md.contains("mediaWidth=66.5"), "mediaWidth in md: {md}");
9870 let rt = markdown_to_adf(&md).unwrap();
9871 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9872 assert_eq!(ms_attrs["width"], 66.5);
9873 }
9874
9875 #[test]
9879 fn issue_555_file_media_fractional_inner_dimensions_preserved() {
9880 let adf_json = r#"{
9881 "version": 1,
9882 "type": "doc",
9883 "content": [{
9884 "type": "mediaSingle",
9885 "attrs": {"layout": "center"},
9886 "content": [
9887 {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c", "width": 1200.5, "height": 800.25}}
9888 ]
9889 }]
9890 }"#;
9891 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9892 let md = adf_to_markdown(&doc).unwrap();
9893 assert!(md.contains("width=1200.5"), "width in md: {md}");
9894 assert!(md.contains("height=800.25"), "height in md: {md}");
9895 let rt = markdown_to_adf(&md).unwrap();
9896 let media = &rt.content[0].content.as_ref().unwrap()[0];
9897 let attrs = media.attrs.as_ref().unwrap();
9898 assert_eq!(attrs["width"], 1200.5);
9899 assert_eq!(attrs["height"], 800.25);
9900 }
9901
9902 #[test]
9903 fn decisions_list() {
9904 let md = ":::decisions\n- <> Use PostgreSQL\n- <> REST API\n:::";
9905 let doc = markdown_to_adf(md).unwrap();
9906 assert_eq!(doc.content[0].node_type, "decisionList");
9907 let items = doc.content[0].content.as_ref().unwrap();
9908 assert_eq!(items.len(), 2);
9909 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DECIDED");
9910 }
9911
9912 #[test]
9915 fn decision_item_content_is_inline_not_paragraph() {
9916 let md = ":::decisions\n- <> Use Rust\n:::";
9917 let doc = markdown_to_adf(md).unwrap();
9918 let items = doc.content[0].content.as_ref().unwrap();
9919 let first_child = &items[0].content.as_ref().unwrap()[0];
9920 assert_eq!(
9921 first_child.node_type, "text",
9922 "decisionItem must contain inline nodes directly, not a paragraph wrapper"
9923 );
9924 assert_eq!(first_child.text.as_deref(), Some("Use Rust"));
9925 }
9926
9927 #[test]
9928 fn adf_decisions_to_markdown() {
9929 let doc = AdfDocument {
9930 version: 1,
9931 doc_type: "doc".to_string(),
9932 content: vec![AdfNode::decision_list(vec![AdfNode::decision_item(
9933 "DECIDED",
9934 vec![AdfNode::paragraph(vec![AdfNode::text("Use PostgreSQL")])],
9935 )])],
9936 };
9937 let md = adf_to_markdown(&doc).unwrap();
9938 assert!(md.contains(":::decisions"));
9939 assert!(md.contains("- <> Use PostgreSQL"));
9940 }
9941
9942 #[test]
9943 fn bodied_extension_container() {
9944 let md = ":::extension{type=com.forge key=my-macro}\nContent.\n:::";
9945 let doc = markdown_to_adf(md).unwrap();
9946 assert_eq!(doc.content[0].node_type, "bodiedExtension");
9947 assert_eq!(
9948 doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9949 "com.forge"
9950 );
9951 }
9952
9953 #[test]
9954 fn adf_bodied_extension_to_markdown() {
9955 let doc = AdfDocument {
9956 version: 1,
9957 doc_type: "doc".to_string(),
9958 content: vec![AdfNode::bodied_extension(
9959 "com.forge",
9960 "my-macro",
9961 vec![AdfNode::paragraph(vec![AdfNode::text("Content.")])],
9962 )],
9963 };
9964 let md = adf_to_markdown(&doc).unwrap();
9965 assert!(md.contains(":::extension{type=com.forge key=my-macro}"));
9966 assert!(md.contains("Content."));
9967 }
9968
9969 #[test]
9972 fn leaf_block_card() {
9973 let doc = markdown_to_adf("::card[https://example.com/browse/PROJ-123]").unwrap();
9974 assert_eq!(doc.content[0].node_type, "blockCard");
9975 assert_eq!(
9976 doc.content[0].attrs.as_ref().unwrap()["url"],
9977 "https://example.com/browse/PROJ-123"
9978 );
9979 }
9980
9981 #[test]
9982 fn adf_block_card_to_markdown() {
9983 let doc = AdfDocument {
9984 version: 1,
9985 doc_type: "doc".to_string(),
9986 content: vec![AdfNode::block_card("https://example.com/browse/PROJ-123")],
9987 };
9988 let md = adf_to_markdown(&doc).unwrap();
9989 assert!(md.contains("::card[https://example.com/browse/PROJ-123]"));
9990 }
9991
9992 #[test]
9993 fn round_trip_block_card() {
9994 let md = "::card[https://example.com/browse/PROJ-123]\n";
9995 let doc = markdown_to_adf(md).unwrap();
9996 let result = adf_to_markdown(&doc).unwrap();
9997 assert!(result.contains("::card[https://example.com/browse/PROJ-123]"));
9998 }
9999
10000 #[test]
10001 fn leaf_embed_card() {
10002 let doc =
10003 markdown_to_adf("::embed[https://figma.com/file/abc]{layout=wide width=80}").unwrap();
10004 assert_eq!(doc.content[0].node_type, "embedCard");
10005 let attrs = doc.content[0].attrs.as_ref().unwrap();
10006 assert_eq!(attrs["url"], "https://figma.com/file/abc");
10007 assert_eq!(attrs["layout"], "wide");
10008 assert_eq!(attrs["width"], 80.0);
10009 }
10010
10011 #[test]
10012 fn leaf_embed_card_with_original_height() {
10013 let doc = markdown_to_adf(
10014 "::embed[https://example.com]{layout=center originalHeight=732 width=100}",
10015 )
10016 .unwrap();
10017 assert_eq!(doc.content[0].node_type, "embedCard");
10018 let attrs = doc.content[0].attrs.as_ref().unwrap();
10019 assert_eq!(attrs["url"], "https://example.com");
10020 assert_eq!(attrs["layout"], "center");
10021 assert_eq!(attrs["originalHeight"], 732.0);
10022 assert_eq!(attrs["width"], 100.0);
10023 }
10024
10025 #[test]
10026 fn adf_embed_card_to_markdown() {
10027 let doc = AdfDocument {
10028 version: 1,
10029 doc_type: "doc".to_string(),
10030 content: vec![AdfNode::embed_card(
10031 "https://figma.com/file/abc",
10032 Some("wide"),
10033 None,
10034 Some(80.0),
10035 )],
10036 };
10037 let md = adf_to_markdown(&doc).unwrap();
10038 assert!(md.contains("::embed[https://figma.com/file/abc]{layout=wide width=80}"));
10039 }
10040
10041 #[test]
10042 fn adf_embed_card_original_height_to_markdown() {
10043 let doc = AdfDocument {
10044 version: 1,
10045 doc_type: "doc".to_string(),
10046 content: vec![AdfNode::embed_card(
10047 "https://example.com",
10048 Some("center"),
10049 Some(732.0),
10050 Some(100.0),
10051 )],
10052 };
10053 let md = adf_to_markdown(&doc).unwrap();
10054 assert!(
10055 md.contains("::embed[https://example.com]{layout=center originalHeight=732 width=100}"),
10056 "expected originalHeight and width in md: {md}"
10057 );
10058 }
10059
10060 #[test]
10061 fn embed_card_roundtrip_with_all_attrs() {
10062 let adf_json = r#"{"version":1,"type":"doc","content":[{
10063 "type":"embedCard",
10064 "attrs":{"layout":"center","originalHeight":732.0,"url":"https://example.com","width":100.0}
10065 }]}"#;
10066 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10067 let md = adf_to_markdown(&doc).unwrap();
10068 assert!(
10069 md.contains("originalHeight=732"),
10070 "originalHeight missing from md: {md}"
10071 );
10072 assert!(md.contains("width=100"), "width missing from md: {md}");
10073 let rt = markdown_to_adf(&md).unwrap();
10074 let attrs = rt.content[0].attrs.as_ref().unwrap();
10075 assert_eq!(attrs["originalHeight"], 732.0);
10076 assert_eq!(attrs["width"], 100.0);
10077 assert_eq!(attrs["layout"], "center");
10078 assert_eq!(attrs["url"], "https://example.com");
10079 }
10080
10081 #[test]
10082 fn embed_card_fractional_dimensions() {
10083 let doc = AdfDocument {
10084 version: 1,
10085 doc_type: "doc".to_string(),
10086 content: vec![AdfNode::embed_card(
10087 "https://example.com",
10088 Some("center"),
10089 Some(732.5),
10090 Some(99.9),
10091 )],
10092 };
10093 let md = adf_to_markdown(&doc).unwrap();
10094 assert!(
10095 md.contains("originalHeight=732.5"),
10096 "fractional originalHeight missing: {md}"
10097 );
10098 assert!(md.contains("width=99.9"), "fractional width missing: {md}");
10099 let rt = markdown_to_adf(&md).unwrap();
10100 let attrs = rt.content[0].attrs.as_ref().unwrap();
10101 assert_eq!(attrs["originalHeight"], 732.5);
10102 assert_eq!(attrs["width"], 99.9);
10103 }
10104
10105 #[test]
10106 fn embed_card_integer_width_in_json() {
10107 let adf_json = r#"{"version":1,"type":"doc","content":[{
10109 "type":"embedCard",
10110 "attrs":{"url":"https://example.com","width":100}
10111 }]}"#;
10112 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10113 let md = adf_to_markdown(&doc).unwrap();
10114 assert!(
10115 md.contains("width=100"),
10116 "integer width missing from md: {md}"
10117 );
10118 let rt = markdown_to_adf(&md).unwrap();
10119 assert_eq!(rt.content[0].attrs.as_ref().unwrap()["width"], 100.0);
10120 }
10121
10122 #[test]
10123 fn embed_card_only_original_height() {
10124 let adf_json = r#"{"version":1,"type":"doc","content":[{
10126 "type":"embedCard",
10127 "attrs":{"url":"https://example.com","originalHeight":500.0}
10128 }]}"#;
10129 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10130 let md = adf_to_markdown(&doc).unwrap();
10131 assert!(
10132 md.contains("originalHeight=500"),
10133 "originalHeight missing: {md}"
10134 );
10135 assert!(!md.contains("width="), "width should not appear: {md}");
10136 let rt = markdown_to_adf(&md).unwrap();
10137 let attrs = rt.content[0].attrs.as_ref().unwrap();
10138 assert_eq!(attrs["originalHeight"], 500.0);
10139 assert!(attrs.get("width").is_none());
10140 }
10141
10142 #[test]
10143 fn leaf_void_extension() {
10144 let doc = markdown_to_adf("::extension{type=com.atlassian.macro key=jira-chart}").unwrap();
10145 assert_eq!(doc.content[0].node_type, "extension");
10146 assert_eq!(
10147 doc.content[0].attrs.as_ref().unwrap()["extensionType"],
10148 "com.atlassian.macro"
10149 );
10150 assert_eq!(
10151 doc.content[0].attrs.as_ref().unwrap()["extensionKey"],
10152 "jira-chart"
10153 );
10154 }
10155
10156 #[test]
10157 fn adf_void_extension_to_markdown() {
10158 let doc = AdfDocument {
10159 version: 1,
10160 doc_type: "doc".to_string(),
10161 content: vec![AdfNode::extension(
10162 "com.atlassian.macro",
10163 "jira-chart",
10164 None,
10165 )],
10166 };
10167 let md = adf_to_markdown(&doc).unwrap();
10168 assert!(md.contains("::extension{type=com.atlassian.macro key=jira-chart}"));
10169 }
10170
10171 #[test]
10174 fn bare_url_autolink() {
10175 let doc = markdown_to_adf("Visit https://example.com today").unwrap();
10176 let content = doc.content[0].content.as_ref().unwrap();
10177 assert_eq!(content[0].text.as_deref(), Some("Visit "));
10178 assert_eq!(content[1].node_type, "inlineCard");
10179 assert_eq!(
10180 content[1].attrs.as_ref().unwrap()["url"],
10181 "https://example.com"
10182 );
10183 assert_eq!(content[2].text.as_deref(), Some(" today"));
10184 }
10185
10186 #[test]
10187 fn bare_url_strips_trailing_punctuation() {
10188 let doc = markdown_to_adf("See https://example.com.").unwrap();
10189 let content = doc.content[0].content.as_ref().unwrap();
10190 assert_eq!(
10191 content[1].attrs.as_ref().unwrap()["url"],
10192 "https://example.com"
10193 );
10194 }
10195
10196 #[test]
10197 fn bare_url_round_trip() {
10198 let doc = markdown_to_adf("Visit https://example.com/path today").unwrap();
10199 let md = adf_to_markdown(&doc).unwrap();
10200 assert!(md.contains(":card[https://example.com/path]"));
10201 }
10202
10203 #[test]
10206 fn plain_text_url_round_trips_as_text() {
10207 let adf_json = r#"{
10210 "version": 1,
10211 "type": "doc",
10212 "content": [{
10213 "type": "paragraph",
10214 "content": [
10215 {"type": "text", "text": "https://example.com/some/path/to/resource"}
10216 ]
10217 }]
10218 }"#;
10219 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10220 let jfm = adf_to_markdown(&adf).unwrap();
10221 let roundtripped = markdown_to_adf(&jfm).unwrap();
10222 let content = roundtripped.content[0].content.as_ref().unwrap();
10223 assert_eq!(content.len(), 1, "should be a single node");
10224 assert_eq!(content[0].node_type, "text");
10225 assert_eq!(
10226 content[0].text.as_deref(),
10227 Some("https://example.com/some/path/to/resource")
10228 );
10229 }
10230
10231 #[test]
10232 fn url_text_with_link_mark_round_trips_as_text_node() {
10233 let adf_json = r#"{
10237 "version": 1,
10238 "type": "doc",
10239 "content": [{
10240 "type": "paragraph",
10241 "content": [{
10242 "type": "text",
10243 "text": "https://octopz.example.com",
10244 "marks": [{"type": "link", "attrs": {"href": "https://octopz.example.com/"}}]
10245 }]
10246 }]
10247 }"#;
10248 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10249 let jfm = adf_to_markdown(&adf).unwrap();
10250 let roundtripped = markdown_to_adf(&jfm).unwrap();
10251 let content = roundtripped.content[0].content.as_ref().unwrap();
10252 assert_eq!(content.len(), 1, "should be a single node");
10253 assert_eq!(content[0].node_type, "text", "must be text, not inlineCard");
10254 assert_eq!(
10255 content[0].text.as_deref(),
10256 Some("https://octopz.example.com")
10257 );
10258 let mark = &content[0].marks.as_ref().unwrap()[0];
10259 assert_eq!(mark.mark_type, "link");
10260 assert_eq!(
10261 mark.attrs.as_ref().unwrap()["href"],
10262 "https://octopz.example.com/"
10263 );
10264 }
10265
10266 #[test]
10267 fn url_text_with_exact_link_mark_round_trips() {
10268 let adf_json = r#"{
10270 "version": 1,
10271 "type": "doc",
10272 "content": [{
10273 "type": "paragraph",
10274 "content": [{
10275 "type": "text",
10276 "text": "https://example.com/path",
10277 "marks": [{"type": "link", "attrs": {"href": "https://example.com/path"}}]
10278 }]
10279 }]
10280 }"#;
10281 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10282 let jfm = adf_to_markdown(&adf).unwrap();
10283 let roundtripped = markdown_to_adf(&jfm).unwrap();
10284 let content = roundtripped.content[0].content.as_ref().unwrap();
10285 assert_eq!(content.len(), 1, "should be a single node");
10286 assert_eq!(content[0].node_type, "text");
10287 assert_eq!(content[0].text.as_deref(), Some("https://example.com/path"));
10288 let mark = &content[0].marks.as_ref().unwrap()[0];
10289 assert_eq!(mark.mark_type, "link");
10290 }
10291
10292 #[test]
10293 fn plain_text_url_amid_text_round_trips() {
10294 let adf_json = r#"{
10296 "version": 1,
10297 "type": "doc",
10298 "content": [{
10299 "type": "paragraph",
10300 "content": [
10301 {"type": "text", "text": "see https://example.com for info"}
10302 ]
10303 }]
10304 }"#;
10305 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10306 let jfm = adf_to_markdown(&adf).unwrap();
10307 let roundtripped = markdown_to_adf(&jfm).unwrap();
10308 let content = roundtripped.content[0].content.as_ref().unwrap();
10309 assert_eq!(content.len(), 1);
10310 assert_eq!(content[0].node_type, "text");
10311 assert_eq!(
10312 content[0].text.as_deref(),
10313 Some("see https://example.com for info")
10314 );
10315 }
10316
10317 #[test]
10318 fn plain_text_multiple_urls_round_trips() {
10319 let adf_json = r#"{
10320 "version": 1,
10321 "type": "doc",
10322 "content": [{
10323 "type": "paragraph",
10324 "content": [
10325 {"type": "text", "text": "http://a.com and https://b.com"}
10326 ]
10327 }]
10328 }"#;
10329 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10330 let jfm = adf_to_markdown(&adf).unwrap();
10331 let roundtripped = markdown_to_adf(&jfm).unwrap();
10332 let content = roundtripped.content[0].content.as_ref().unwrap();
10333 assert_eq!(content.len(), 1);
10334 assert_eq!(content[0].node_type, "text");
10335 assert_eq!(
10336 content[0].text.as_deref(),
10337 Some("http://a.com and https://b.com")
10338 );
10339 }
10340
10341 #[test]
10342 fn plain_text_http_prefix_no_url_unchanged() {
10343 let adf_json = r#"{
10345 "version": 1,
10346 "type": "doc",
10347 "content": [{
10348 "type": "paragraph",
10349 "content": [
10350 {"type": "text", "text": "the http header is important"}
10351 ]
10352 }]
10353 }"#;
10354 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10355 let jfm = adf_to_markdown(&adf).unwrap();
10356 let roundtripped = markdown_to_adf(&jfm).unwrap();
10357 let content = roundtripped.content[0].content.as_ref().unwrap();
10358 assert_eq!(
10359 content[0].text.as_deref(),
10360 Some("the http header is important")
10361 );
10362 }
10363
10364 #[test]
10365 fn linked_url_text_round_trips() {
10366 let adf_json = r#"{
10370 "version": 1,
10371 "type": "doc",
10372 "content": [{
10373 "type": "paragraph",
10374 "content": [{
10375 "type": "text",
10376 "text": "https://example.com",
10377 "marks": [{"type": "link", "attrs": {"href": "https://example.com"}}]
10378 }]
10379 }]
10380 }"#;
10381 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10382 let jfm = adf_to_markdown(&adf).unwrap();
10383 let roundtripped = markdown_to_adf(&jfm).unwrap();
10384 let content = roundtripped.content[0].content.as_ref().unwrap();
10385 assert_eq!(content.len(), 1);
10386 assert_eq!(content[0].node_type, "text");
10387 assert_eq!(content[0].text.as_deref(), Some("https://example.com"));
10388 let mark = &content[0].marks.as_ref().unwrap()[0];
10389 assert_eq!(mark.mark_type, "link");
10390 assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
10391 }
10392
10393 #[test]
10396 fn escape_link_brackets_unit() {
10397 assert_eq!(escape_link_brackets("hello"), "hello");
10398 assert_eq!(escape_link_brackets("["), "\\[");
10399 assert_eq!(escape_link_brackets("]"), "\\]");
10400 assert_eq!(escape_link_brackets("[PROJ-456]"), "\\[PROJ-456\\]");
10401 assert_eq!(escape_link_brackets("a[b]c"), "a\\[b\\]c");
10402 }
10403
10404 #[test]
10405 fn bracket_text_with_link_mark_escapes_brackets() {
10406 let doc = AdfDocument {
10409 version: 1,
10410 doc_type: "doc".to_string(),
10411 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10412 "[",
10413 vec![AdfMark::link("https://example.com")],
10414 )])],
10415 };
10416 let md = adf_to_markdown(&doc).unwrap();
10417 assert_eq!(md.trim(), "[\\[](https://example.com)");
10418 }
10419
10420 #[test]
10421 fn bracket_text_with_link_mark_round_trips() {
10422 let adf_json = r#"{
10425 "type": "doc",
10426 "version": 1,
10427 "content": [{
10428 "type": "paragraph",
10429 "content": [
10430 {
10431 "type": "text",
10432 "text": "[",
10433 "marks": [{"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}]
10434 },
10435 {
10436 "type": "text",
10437 "text": "PROJ-456] Fix the auth bug",
10438 "marks": [
10439 {"type": "underline"},
10440 {"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}
10441 ]
10442 }
10443 ]
10444 }]
10445 }"#;
10446 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10447 let jfm = adf_to_markdown(&adf).unwrap();
10448
10449 assert!(jfm.contains("\\["), "opening bracket should be escaped");
10451
10452 let rt = markdown_to_adf(&jfm).unwrap();
10454 let content = rt.content[0].content.as_ref().unwrap();
10455
10456 let link_nodes: Vec<_> = content
10458 .iter()
10459 .filter(|n| {
10460 n.marks
10461 .as_ref()
10462 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
10463 })
10464 .collect();
10465 assert!(
10466 !link_nodes.is_empty(),
10467 "link mark must be preserved on round-trip"
10468 );
10469
10470 let all_text: String = content.iter().filter_map(|n| n.text.as_deref()).collect();
10472 assert!(
10473 all_text.contains('['),
10474 "literal '[' must survive round-trip"
10475 );
10476 assert!(
10477 all_text.contains("PROJ-456]"),
10478 "continuation text must survive round-trip"
10479 );
10480 }
10481
10482 #[test]
10483 fn closing_bracket_in_link_text_round_trips() {
10484 let doc = AdfDocument {
10487 version: 1,
10488 doc_type: "doc".to_string(),
10489 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10490 "item]",
10491 vec![AdfMark::link("https://example.com")],
10492 )])],
10493 };
10494 let md = adf_to_markdown(&doc).unwrap();
10495 assert_eq!(md.trim(), "[item\\]](https://example.com)");
10496
10497 let rt = markdown_to_adf(&md).unwrap();
10498 let content = rt.content[0].content.as_ref().unwrap();
10499 assert_eq!(content[0].text.as_deref(), Some("item]"));
10500 assert!(content[0]
10501 .marks
10502 .as_ref()
10503 .unwrap()
10504 .iter()
10505 .any(|m| m.mark_type == "link"));
10506 }
10507
10508 #[test]
10509 fn brackets_in_link_text_round_trip() {
10510 let doc = AdfDocument {
10512 version: 1,
10513 doc_type: "doc".to_string(),
10514 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10515 "[PROJ-123]",
10516 vec![AdfMark::link("https://example.com")],
10517 )])],
10518 };
10519 let md = adf_to_markdown(&doc).unwrap();
10520 assert_eq!(md.trim(), "[\\[PROJ-123\\]](https://example.com)");
10521
10522 let rt = markdown_to_adf(&md).unwrap();
10523 let content = rt.content[0].content.as_ref().unwrap();
10524 assert_eq!(content[0].text.as_deref(), Some("[PROJ-123]"));
10525 assert!(content[0]
10526 .marks
10527 .as_ref()
10528 .unwrap()
10529 .iter()
10530 .any(|m| m.mark_type == "link"));
10531 }
10532
10533 #[test]
10534 fn plain_text_brackets_not_escaped() {
10535 let doc = AdfDocument {
10537 version: 1,
10538 doc_type: "doc".to_string(),
10539 content: vec![AdfNode::paragraph(vec![AdfNode::text(
10540 "see [PROJ-123] for details",
10541 )])],
10542 };
10543 let md = adf_to_markdown(&doc).unwrap();
10544 assert_eq!(md.trim(), "see [PROJ-123] for details");
10545 }
10546
10547 #[test]
10548 fn link_with_no_brackets_unchanged() {
10549 let doc = AdfDocument {
10551 version: 1,
10552 doc_type: "doc".to_string(),
10553 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10554 "click here",
10555 vec![AdfMark::link("https://example.com")],
10556 )])],
10557 };
10558 let md = adf_to_markdown(&doc).unwrap();
10559 assert_eq!(md.trim(), "[click here](https://example.com)");
10560 }
10561
10562 #[test]
10565 fn url_with_brackets_as_link_text_round_trips() {
10566 let href = "https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10571 let doc = AdfDocument {
10572 version: 1,
10573 doc_type: "doc".to_string(),
10574 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10575 href,
10576 vec![AdfMark::link(href)],
10577 )])],
10578 };
10579 let md = adf_to_markdown(&doc).unwrap();
10580 let rt = markdown_to_adf(&md).unwrap();
10581 let content = rt.content[0].content.as_ref().unwrap();
10582 assert_eq!(content.len(), 1);
10583 assert_eq!(content[0].node_type, "text");
10584 assert_eq!(content[0].text.as_deref(), Some(href));
10585 let mark = &content[0].marks.as_ref().unwrap()[0];
10586 assert_eq!(mark.mark_type, "link");
10587 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10588 }
10589
10590 #[test]
10591 fn url_with_brackets_embedded_in_link_text_round_trips() {
10592 let href = "https://example.com/logs?query=service%20environment%20data&from=100&to=200";
10599 let text =
10600 "See the logs: https://example.com/logs?query=service[\u{2026}]data&from=100&to=200";
10601 let doc = AdfDocument {
10602 version: 1,
10603 doc_type: "doc".to_string(),
10604 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10605 text,
10606 vec![AdfMark::link(href)],
10607 )])],
10608 };
10609 let md = adf_to_markdown(&doc).unwrap();
10610 let rt = markdown_to_adf(&md).unwrap();
10611 let content = rt.content[0].content.as_ref().unwrap();
10612 assert_eq!(content.len(), 1, "content split unexpectedly: {content:?}");
10613 assert_eq!(content[0].node_type, "text");
10614 assert_eq!(content[0].text.as_deref(), Some(text));
10615 let mark = &content[0].marks.as_ref().unwrap()[0];
10616 assert_eq!(mark.mark_type, "link");
10617 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10618 }
10619
10620 #[test]
10621 fn url_with_brackets_plain_text_round_trips() {
10622 let text =
10625 "See the dashboard: https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10626 let doc = AdfDocument {
10627 version: 1,
10628 doc_type: "doc".to_string(),
10629 content: vec![AdfNode::paragraph(vec![AdfNode::text(text)])],
10630 };
10631 let md = adf_to_markdown(&doc).unwrap();
10632 let rt = markdown_to_adf(&md).unwrap();
10633 let content = rt.content[0].content.as_ref().unwrap();
10634 assert_eq!(content.len(), 1);
10635 assert_eq!(content[0].node_type, "text");
10636 assert_eq!(content[0].text.as_deref(), Some(text));
10637 assert!(content[0].marks.is_none());
10638 }
10639
10640 #[test]
10641 fn url_with_link_mark_embedded_no_brackets_round_trips() {
10642 let href = "https://example.com/";
10645 let text = "See https://example.com/ for more";
10646 let doc = AdfDocument {
10647 version: 1,
10648 doc_type: "doc".to_string(),
10649 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10650 text,
10651 vec![AdfMark::link(href)],
10652 )])],
10653 };
10654 let md = adf_to_markdown(&doc).unwrap();
10655 let rt = markdown_to_adf(&md).unwrap();
10656 let content = rt.content[0].content.as_ref().unwrap();
10657 assert_eq!(content.len(), 1);
10658 assert_eq!(content[0].node_type, "text");
10659 assert_eq!(content[0].text.as_deref(), Some(text));
10660 let mark = &content[0].marks.as_ref().unwrap()[0];
10661 assert_eq!(mark.mark_type, "link");
10662 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10663 }
10664
10665 #[test]
10666 fn nested_brackets_in_link_text_round_trip() {
10667 let href = "https://x.com";
10670 let text = "foo [a[b]c] bar";
10671 let doc = AdfDocument {
10672 version: 1,
10673 doc_type: "doc".to_string(),
10674 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10675 text,
10676 vec![AdfMark::link(href)],
10677 )])],
10678 };
10679 let md = adf_to_markdown(&doc).unwrap();
10680 let rt = markdown_to_adf(&md).unwrap();
10681 let content = rt.content[0].content.as_ref().unwrap();
10682 assert_eq!(content.len(), 1);
10683 assert_eq!(content[0].node_type, "text");
10684 assert_eq!(content[0].text.as_deref(), Some(text));
10685 }
10686
10687 #[test]
10688 fn bracket_url_bracket_in_link_text_round_trips() {
10689 let href = "https://y.com";
10695 let text = "[see https://x.com/a[0]=1 here]";
10696 let doc = AdfDocument {
10697 version: 1,
10698 doc_type: "doc".to_string(),
10699 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10700 text,
10701 vec![AdfMark::link(href)],
10702 )])],
10703 };
10704 let md = adf_to_markdown(&doc).unwrap();
10705 let rt = markdown_to_adf(&md).unwrap();
10706 let content = rt.content[0].content.as_ref().unwrap();
10707 assert_eq!(content.len(), 1);
10708 assert_eq!(content[0].node_type, "text");
10709 assert_eq!(content[0].text.as_deref(), Some(text));
10710 let mark = &content[0].marks.as_ref().unwrap()[0];
10711 assert_eq!(mark.mark_type, "link");
10712 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10713 }
10714
10715 #[test]
10716 fn escape_bare_urls_applied_inside_link_text() {
10717 let doc = AdfDocument {
10723 version: 1,
10724 doc_type: "doc".to_string(),
10725 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10726 "See https://example.com/",
10727 vec![AdfMark::link("https://target.example.com/")],
10728 )])],
10729 };
10730 let md = adf_to_markdown(&doc).unwrap();
10731 assert!(
10732 md.contains(r"\https://example.com/"),
10733 "bare URL inside link text must be escaped, got: {md}"
10734 );
10735 }
10736
10737 #[test]
10738 fn inline_card_still_round_trips() {
10739 let adf_json = r#"{
10742 "version": 1,
10743 "type": "doc",
10744 "content": [{
10745 "type": "paragraph",
10746 "content": [
10747 {"type": "inlineCard", "attrs": {"url": "https://example.com/page"}}
10748 ]
10749 }]
10750 }"#;
10751 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10752 let jfm = adf_to_markdown(&adf).unwrap();
10753 assert!(jfm.contains(":card[https://example.com/page]"));
10754 let roundtripped = markdown_to_adf(&jfm).unwrap();
10755 let content = roundtripped.content[0].content.as_ref().unwrap();
10756 assert_eq!(content[0].node_type, "inlineCard");
10757 assert_eq!(
10758 content[0].attrs.as_ref().unwrap()["url"],
10759 "https://example.com/page"
10760 );
10761 }
10762
10763 #[test]
10766 fn url_safe_in_bracket_content_balanced() {
10767 assert!(url_safe_in_bracket_content("https://example.com"));
10769 assert!(url_safe_in_bracket_content("https://example.com/[id]"));
10770 assert!(url_safe_in_bracket_content("a[b[c]d]e"));
10771 assert!(url_safe_in_bracket_content(""));
10772 }
10773
10774 #[test]
10775 fn url_safe_in_bracket_content_unbalanced() {
10776 assert!(!url_safe_in_bracket_content("a]b"));
10778 assert!(!url_safe_in_bracket_content("https://example.com/path]end"));
10779 assert!(!url_safe_in_bracket_content("a\nb"));
10781 }
10782
10783 #[test]
10784 fn inline_card_url_with_closing_bracket_round_trip() {
10785 let adf_json = r#"{
10790 "version": 1,
10791 "type": "doc",
10792 "content": [{
10793 "type": "paragraph",
10794 "content": [
10795 {"type": "text", "text": "See: "},
10796 {"type": "inlineCard", "attrs": {"url": "https://example.com/path]end/?q=1"}}
10797 ]
10798 }]
10799 }"#;
10800 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10801 let jfm = adf_to_markdown(&adf).unwrap();
10802 assert!(
10803 jfm.contains(r#":card[]{url="https://example.com/path]end/?q=1"}"#),
10804 "expected attr-form for URL with `]`, got: {jfm}"
10805 );
10806 let rt = markdown_to_adf(&jfm).unwrap();
10807 let content = rt.content[0].content.as_ref().unwrap();
10808 assert_eq!(content.len(), 2, "expected 2 inline nodes, got {content:?}");
10809 assert_eq!(content[0].node_type, "text");
10810 assert_eq!(content[0].text.as_deref(), Some("See: "));
10811 assert_eq!(content[1].node_type, "inlineCard");
10812 assert_eq!(
10813 content[1].attrs.as_ref().unwrap()["url"],
10814 "https://example.com/path]end/?q=1"
10815 );
10816 }
10817
10818 #[test]
10819 fn inline_card_url_with_closing_bracket_preserves_local_id() {
10820 let adf_json = r#"{
10822 "version": 1,
10823 "type": "doc",
10824 "content": [{
10825 "type": "paragraph",
10826 "content": [
10827 {"type": "inlineCard", "attrs": {
10828 "url": "https://example.com/a]b",
10829 "localId": "c-77"
10830 }}
10831 ]
10832 }]
10833 }"#;
10834 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10835 let jfm = adf_to_markdown(&adf).unwrap();
10836 assert!(
10837 jfm.contains(r#"url="https://example.com/a]b""#),
10838 "jfm: {jfm}"
10839 );
10840 assert!(jfm.contains("localId=c-77"), "jfm: {jfm}");
10841 let rt = markdown_to_adf(&jfm).unwrap();
10842 let card = &rt.content[0].content.as_ref().unwrap()[0];
10843 assert_eq!(card.node_type, "inlineCard");
10844 assert_eq!(
10845 card.attrs.as_ref().unwrap()["url"],
10846 "https://example.com/a]b"
10847 );
10848 assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-77");
10849 }
10850
10851 #[test]
10852 fn block_card_url_with_closing_bracket_round_trip() {
10853 let adf_json = r#"{
10855 "version": 1,
10856 "type": "doc",
10857 "content": [
10858 {"type": "blockCard", "attrs": {"url": "https://example.com/path]end"}}
10859 ]
10860 }"#;
10861 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10862 let jfm = adf_to_markdown(&adf).unwrap();
10863 assert!(
10864 jfm.contains(r#"::card[]{url="https://example.com/path]end"}"#),
10865 "expected attr-form for blockCard with `]`, got: {jfm}"
10866 );
10867 let rt = markdown_to_adf(&jfm).unwrap();
10868 assert_eq!(rt.content[0].node_type, "blockCard");
10869 assert_eq!(
10870 rt.content[0].attrs.as_ref().unwrap()["url"],
10871 "https://example.com/path]end"
10872 );
10873 }
10874
10875 #[test]
10876 fn block_card_attr_form_parses_without_renderer() {
10877 let doc = markdown_to_adf(r#"::card[]{url="https://example.com/a"}"#).unwrap();
10881 assert_eq!(doc.content[0].node_type, "blockCard");
10882 assert_eq!(
10883 doc.content[0].attrs.as_ref().unwrap()["url"],
10884 "https://example.com/a"
10885 );
10886 }
10887
10888 #[test]
10889 fn block_card_attr_form_url_overrides_content() {
10890 let doc =
10894 markdown_to_adf(r#"::card[https://old.example.com]{url="https://new.example.com"}"#)
10895 .unwrap();
10896 assert_eq!(doc.content[0].node_type, "blockCard");
10897 assert_eq!(
10898 doc.content[0].attrs.as_ref().unwrap()["url"],
10899 "https://new.example.com"
10900 );
10901 }
10902
10903 #[test]
10904 fn block_card_attr_form_with_layout_and_width() {
10905 let doc =
10908 markdown_to_adf(r#"::card[]{url="https://example.com/a]b" layout=wide width=80}"#)
10909 .unwrap();
10910 let attrs = doc.content[0].attrs.as_ref().unwrap();
10911 assert_eq!(attrs["url"], "https://example.com/a]b");
10912 assert_eq!(attrs["layout"], "wide");
10913 assert_eq!(attrs["width"], 80);
10914 }
10915
10916 #[test]
10917 fn inline_card_issue_553_reproducer() {
10918 let adf_json = r#"{
10922 "version": 1,
10923 "type": "doc",
10924 "content": [{
10925 "type": "paragraph",
10926 "content": [
10927 {"type": "text", "text": "See the related page: "},
10928 {"type": "inlineCard", "attrs": {
10929 "url": "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10930 }}
10931 ]
10932 }]
10933 }"#;
10934 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10935 let jfm = adf_to_markdown(&adf).unwrap();
10936 let rt = markdown_to_adf(&jfm).unwrap();
10937 let content = rt.content[0].content.as_ref().unwrap();
10938 assert_eq!(content.len(), 2);
10939 assert_eq!(content[0].node_type, "text");
10940 assert_eq!(content[1].node_type, "inlineCard");
10941 assert_eq!(
10942 content[1].attrs.as_ref().unwrap()["url"],
10943 "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10944 );
10945 }
10946
10947 #[test]
10948 fn inline_card_attr_form_parses_even_without_renderer() {
10949 let doc = markdown_to_adf(r#":card[]{url="https://example.com/a"}"#).unwrap();
10951 let node = &doc.content[0].content.as_ref().unwrap()[0];
10952 assert_eq!(node.node_type, "inlineCard");
10953 assert_eq!(node.attrs.as_ref().unwrap()["url"], "https://example.com/a");
10954 }
10955
10956 #[test]
10957 fn inline_card_attr_form_url_overrides_content() {
10958 let doc =
10962 markdown_to_adf(r#":card[https://old.example.com]{url="https://new.example.com"}"#)
10963 .unwrap();
10964 let node = &doc.content[0].content.as_ref().unwrap()[0];
10965 assert_eq!(node.node_type, "inlineCard");
10966 assert_eq!(
10967 node.attrs.as_ref().unwrap()["url"],
10968 "https://new.example.com"
10969 );
10970 }
10971
10972 #[test]
10975 fn url_with_link_and_underline_marks_round_trip() {
10976 let adf_json = r#"{
10980 "version": 1,
10981 "type": "doc",
10982 "content": [{
10983 "type": "paragraph",
10984 "content": [
10985 {"type": "text", "text": "See results at: "},
10986 {"type": "text",
10987 "text": "https://example.com/projects/abc123/analytics",
10988 "marks": [
10989 {"type": "link", "attrs": {"href": "https://example.com/projects/abc123/analytics"}},
10990 {"type": "underline"}
10991 ]},
10992 {"type": "text", "text": " for details."}
10993 ]
10994 }]
10995 }"#;
10996 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10997 let jfm = adf_to_markdown(&adf).unwrap();
10998 let rt = markdown_to_adf(&jfm).unwrap();
10999 let content = rt.content[0].content.as_ref().unwrap();
11000 assert_eq!(
11001 content.len(),
11002 3,
11003 "expected 3 inline nodes, got: {content:?}"
11004 );
11005 assert_eq!(content[0].node_type, "text");
11006 assert_eq!(
11007 content[1].node_type, "text",
11008 "must stay text, not inlineCard"
11009 );
11010 assert_eq!(
11011 content[1].text.as_deref(),
11012 Some("https://example.com/projects/abc123/analytics")
11013 );
11014 let mark_types: Vec<&str> = content[1]
11015 .marks
11016 .as_deref()
11017 .unwrap_or(&[])
11018 .iter()
11019 .map(|m| m.mark_type.as_str())
11020 .collect();
11021 assert_eq!(mark_types, vec!["link", "underline"], "marks lost");
11022 assert_eq!(content[2].node_type, "text");
11023 }
11024
11025 #[test]
11026 fn url_inside_bracketed_span_stays_text() {
11027 let doc = markdown_to_adf("[https://example.com]{underline}").unwrap();
11031 let node = &doc.content[0].content.as_ref().unwrap()[0];
11032 assert_eq!(node.node_type, "text");
11033 assert_eq!(node.text.as_deref(), Some("https://example.com"));
11034 let mark_types: Vec<&str> = node
11035 .marks
11036 .as_deref()
11037 .unwrap_or(&[])
11038 .iter()
11039 .map(|m| m.mark_type.as_str())
11040 .collect();
11041 assert_eq!(mark_types, vec!["underline"]);
11042 }
11043
11044 #[test]
11045 fn url_inside_emphasis_stays_text() {
11046 for (md, mark) in [
11049 ("**https://example.com**", "strong"),
11050 ("*https://example.com*", "em"),
11051 ("~~https://example.com~~", "strike"),
11052 ] {
11053 let doc = markdown_to_adf(md).unwrap();
11054 let node = &doc.content[0].content.as_ref().unwrap()[0];
11055 assert_eq!(node.node_type, "text", "md={md}: must be text");
11056 assert_eq!(node.text.as_deref(), Some("https://example.com"));
11057 let mark_types: Vec<&str> = node
11058 .marks
11059 .as_deref()
11060 .unwrap_or(&[])
11061 .iter()
11062 .map(|m| m.mark_type.as_str())
11063 .collect();
11064 assert_eq!(mark_types, vec![mark], "md={md}: wrong marks");
11065 }
11066 }
11067
11068 #[test]
11069 fn url_inside_span_directive_stays_text() {
11070 let doc = markdown_to_adf(":span[https://example.com]{color=red}").unwrap();
11072 let node = &doc.content[0].content.as_ref().unwrap()[0];
11073 assert_eq!(node.node_type, "text");
11074 assert_eq!(node.text.as_deref(), Some("https://example.com"));
11075 let mark = &node.marks.as_ref().unwrap()[0];
11076 assert_eq!(mark.mark_type, "textColor");
11077 }
11078
11079 #[test]
11080 fn url_as_link_text_with_underline_after_link_mark_order() {
11081 let adf_json = r#"{
11085 "version": 1,
11086 "type": "doc",
11087 "content": [{
11088 "type": "paragraph",
11089 "content": [
11090 {"type": "text",
11091 "text": "https://example.com",
11092 "marks": [
11093 {"type": "underline"},
11094 {"type": "link", "attrs": {"href": "https://example.com"}}
11095 ]}
11096 ]
11097 }]
11098 }"#;
11099 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
11100 let jfm = adf_to_markdown(&adf).unwrap();
11101 let rt = markdown_to_adf(&jfm).unwrap();
11102 let node = &rt.content[0].content.as_ref().unwrap()[0];
11103 assert_eq!(node.node_type, "text", "must stay text, got: {node:?}");
11104 assert_eq!(node.text.as_deref(), Some("https://example.com"));
11105 let mark_types: Vec<&str> = node
11106 .marks
11107 .as_deref()
11108 .unwrap_or(&[])
11109 .iter()
11110 .map(|m| m.mark_type.as_str())
11111 .collect();
11112 assert_eq!(mark_types, vec!["underline", "link"]);
11113 }
11114
11115 #[test]
11116 fn bare_url_at_top_level_still_becomes_inline_card() {
11117 let doc = markdown_to_adf("Visit https://example.com today").unwrap();
11121 let content = doc.content[0].content.as_ref().unwrap();
11122 assert_eq!(content.len(), 3);
11123 assert_eq!(content[0].node_type, "text");
11124 assert_eq!(content[1].node_type, "inlineCard");
11125 assert_eq!(
11126 content[1].attrs.as_ref().unwrap()["url"],
11127 "https://example.com"
11128 );
11129 assert_eq!(content[2].node_type, "text");
11130 }
11131
11132 #[test]
11135 fn paragraph_align_center() {
11136 let md = "Centered text.\n{align=center}";
11137 let doc = markdown_to_adf(md).unwrap();
11138 let marks = doc.content[0].marks.as_ref().unwrap();
11139 assert_eq!(marks[0].mark_type, "alignment");
11140 assert_eq!(marks[0].attrs.as_ref().unwrap()["align"], "center");
11141 }
11142
11143 #[test]
11144 fn adf_alignment_to_markdown() {
11145 let mut node = AdfNode::paragraph(vec![AdfNode::text("Centered.")]);
11146 node.marks = Some(vec![AdfMark::alignment("center")]);
11147 let doc = AdfDocument {
11148 version: 1,
11149 doc_type: "doc".to_string(),
11150 content: vec![node],
11151 };
11152 let md = adf_to_markdown(&doc).unwrap();
11153 assert!(md.contains("Centered."));
11154 assert!(md.contains("{align=center}"));
11155 }
11156
11157 #[test]
11158 fn round_trip_alignment() {
11159 let md = "Centered.\n{align=center}\n";
11160 let doc = markdown_to_adf(md).unwrap();
11161 let result = adf_to_markdown(&doc).unwrap();
11162 assert!(result.contains("{align=center}"));
11163 }
11164
11165 #[test]
11166 fn paragraph_indent() {
11167 let md = "Indented.\n{indent=2}";
11168 let doc = markdown_to_adf(md).unwrap();
11169 let marks = doc.content[0].marks.as_ref().unwrap();
11170 assert_eq!(marks[0].mark_type, "indentation");
11171 assert_eq!(marks[0].attrs.as_ref().unwrap()["level"], 2);
11172 }
11173
11174 #[test]
11175 fn code_block_breakout() {
11176 let md = "```python\ndef f(): pass\n```\n{breakout=wide}";
11177 let doc = markdown_to_adf(md).unwrap();
11178 let marks = doc.content[0].marks.as_ref().unwrap();
11179 assert_eq!(marks[0].mark_type, "breakout");
11180 assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
11181 assert!(marks[0].attrs.as_ref().unwrap().get("width").is_none());
11182 }
11183
11184 #[test]
11185 fn code_block_breakout_with_width() {
11186 let md = "```python\ndef f(): pass\n```\n{breakout=wide breakoutWidth=1200}";
11187 let doc = markdown_to_adf(md).unwrap();
11188 let marks = doc.content[0].marks.as_ref().unwrap();
11189 assert_eq!(marks[0].mark_type, "breakout");
11190 assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
11191 assert_eq!(marks[0].attrs.as_ref().unwrap()["width"], 1200);
11192 }
11193
11194 #[test]
11195 fn adf_breakout_to_markdown() {
11196 let mut node = AdfNode::code_block(Some("python"), "pass");
11197 node.marks = Some(vec![AdfMark::breakout("wide", None)]);
11198 let doc = AdfDocument {
11199 version: 1,
11200 doc_type: "doc".to_string(),
11201 content: vec![node],
11202 };
11203 let md = adf_to_markdown(&doc).unwrap();
11204 assert!(md.contains("{breakout=wide}"));
11205 assert!(!md.contains("breakoutWidth"));
11206 }
11207
11208 #[test]
11209 fn adf_breakout_with_width_to_markdown() {
11210 let mut node = AdfNode::code_block(Some("python"), "pass");
11211 node.marks = Some(vec![AdfMark::breakout("wide", Some(1200))]);
11212 let doc = AdfDocument {
11213 version: 1,
11214 doc_type: "doc".to_string(),
11215 content: vec![node],
11216 };
11217 let md = adf_to_markdown(&doc).unwrap();
11218 assert!(md.contains("breakout=wide"));
11219 assert!(md.contains("breakoutWidth=1200"));
11220 }
11221
11222 #[test]
11223 fn breakout_width_round_trip() {
11224 let adf_json = r#"{"version":1,"type":"doc","content":[{
11225 "type":"codeBlock",
11226 "attrs":{"language":"text"},
11227 "marks":[{"type":"breakout","attrs":{"mode":"wide","width":1200}}],
11228 "content":[{"type":"text","text":"some code"}]
11229 }]}"#;
11230 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11231 let md = adf_to_markdown(&doc).unwrap();
11232 assert!(md.contains("breakout=wide"));
11233 assert!(md.contains("breakoutWidth=1200"));
11234 let round_tripped = markdown_to_adf(&md).unwrap();
11235 let marks = round_tripped.content[0].marks.as_ref().unwrap();
11236 let breakout = marks.iter().find(|m| m.mark_type == "breakout").unwrap();
11237 assert_eq!(breakout.attrs.as_ref().unwrap()["mode"], "wide");
11238 assert_eq!(breakout.attrs.as_ref().unwrap()["width"], 1200);
11239 }
11240
11241 #[test]
11244 fn image_with_layout_attrs() {
11245 let doc = markdown_to_adf("{layout=wide width=80}").unwrap();
11246 let node = &doc.content[0];
11247 assert_eq!(node.node_type, "mediaSingle");
11248 let attrs = node.attrs.as_ref().unwrap();
11249 assert_eq!(attrs["layout"], "wide");
11250 assert_eq!(attrs["width"], 80);
11251 }
11252
11253 #[test]
11254 fn adf_image_with_layout_to_markdown() {
11255 let mut node = AdfNode::media_single("url", Some("alt"));
11256 node.attrs.as_mut().unwrap()["layout"] = serde_json::json!("wide");
11257 node.attrs.as_mut().unwrap()["width"] = serde_json::json!(80);
11258 let doc = AdfDocument {
11259 version: 1,
11260 doc_type: "doc".to_string(),
11261 content: vec![node],
11262 };
11263 let md = adf_to_markdown(&doc).unwrap();
11264 assert!(md.contains("{layout=wide width=80}"));
11265 }
11266
11267 #[test]
11268 fn table_with_layout_attrs() {
11269 let md = "| H |\n| --- |\n| C |\n{layout=wide numbered}";
11270 let doc = markdown_to_adf(md).unwrap();
11271 let table = &doc.content[0];
11272 assert_eq!(table.node_type, "table");
11273 let attrs = table.attrs.as_ref().unwrap();
11274 assert_eq!(attrs["layout"], "wide");
11275 assert_eq!(attrs["isNumberColumnEnabled"], true);
11276 }
11277
11278 #[test]
11279 fn adf_table_with_attrs_to_markdown() {
11280 let mut table = AdfNode::table(vec![
11281 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
11282 AdfNode::text("H"),
11283 ])])]),
11284 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
11285 AdfNode::text("C"),
11286 ])])]),
11287 ]);
11288 table.attrs = Some(serde_json::json!({"layout": "wide", "isNumberColumnEnabled": true}));
11289 let doc = AdfDocument {
11290 version: 1,
11291 doc_type: "doc".to_string(),
11292 content: vec![table],
11293 };
11294 let md = adf_to_markdown(&doc).unwrap();
11295 assert!(md.contains("{layout=wide numbered}"));
11296 }
11297
11298 #[test]
11301 fn underline_bracketed_span() {
11302 let doc = markdown_to_adf("This is [underlined text]{underline} here.").unwrap();
11303 let content = doc.content[0].content.as_ref().unwrap();
11304 assert_eq!(content[1].text.as_deref(), Some("underlined text"));
11305 let marks = content[1].marks.as_ref().unwrap();
11306 assert_eq!(marks[0].mark_type, "underline");
11307 }
11308
11309 #[test]
11310 fn adf_underline_to_markdown() {
11311 let doc = AdfDocument {
11312 version: 1,
11313 doc_type: "doc".to_string(),
11314 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11315 "underlined",
11316 vec![AdfMark::underline()],
11317 )])],
11318 };
11319 let md = adf_to_markdown(&doc).unwrap();
11320 assert!(md.contains("[underlined]{underline}"));
11321 }
11322
11323 #[test]
11324 fn round_trip_underline() {
11325 let md = "This is [underlined text]{underline} here.\n";
11326 let doc = markdown_to_adf(md).unwrap();
11327 let result = adf_to_markdown(&doc).unwrap();
11328 assert!(result.contains("[underlined text]{underline}"));
11329 }
11330
11331 #[test]
11332 fn mark_ordering_underline_strong_preserved() {
11333 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11335 {"type":"text","text":"bold and underlined","marks":[{"type":"underline"},{"type":"strong"}]}
11336 ]}]}"#;
11337 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11338 let md = adf_to_markdown(&doc).unwrap();
11339 let round_tripped = markdown_to_adf(&md).unwrap();
11340 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11341 let mark_types: Vec<&str> = node
11342 .marks
11343 .as_ref()
11344 .unwrap()
11345 .iter()
11346 .map(|m| m.mark_type.as_str())
11347 .collect();
11348 assert_eq!(
11349 mark_types,
11350 vec!["underline", "strong"],
11351 "mark order should be preserved, got: {mark_types:?}"
11352 );
11353 }
11354
11355 #[test]
11356 fn mark_ordering_link_strong_preserved() {
11357 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11359 {"type":"text","text":"bold link","marks":[
11360 {"type":"link","attrs":{"href":"https://example.com"}},
11361 {"type":"strong"}
11362 ]}
11363 ]}]}"#;
11364 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11365 let md = adf_to_markdown(&doc).unwrap();
11366 let round_tripped = markdown_to_adf(&md).unwrap();
11367 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11368 let mark_types: Vec<&str> = node
11369 .marks
11370 .as_ref()
11371 .unwrap()
11372 .iter()
11373 .map(|m| m.mark_type.as_str())
11374 .collect();
11375 assert_eq!(
11376 mark_types,
11377 vec!["link", "strong"],
11378 "mark order should be preserved, got: {mark_types:?}"
11379 );
11380 }
11381
11382 #[test]
11383 fn mark_ordering_link_textcolor_preserved() {
11384 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11386 {"type":"text","text":"red link","marks":[
11387 {"type":"link","attrs":{"href":"https://example.com"}},
11388 {"type":"textColor","attrs":{"color":"#ff0000"}}
11389 ]}
11390 ]}]}"##;
11391 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11392 let md = adf_to_markdown(&doc).unwrap();
11393 let round_tripped = markdown_to_adf(&md).unwrap();
11394 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11395 let mark_types: Vec<&str> = node
11396 .marks
11397 .as_ref()
11398 .unwrap()
11399 .iter()
11400 .map(|m| m.mark_type.as_str())
11401 .collect();
11402 assert_eq!(
11403 mark_types,
11404 vec!["link", "textColor"],
11405 "mark order should be preserved, got: {mark_types:?}"
11406 );
11407 }
11408
11409 #[test]
11410 fn mark_ordering_link_em_preserved() {
11411 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11413 {"type":"text","text":"italic link","marks":[
11414 {"type":"link","attrs":{"href":"https://example.com"}},
11415 {"type":"em"}
11416 ]}
11417 ]}]}"#;
11418 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11419 let md = adf_to_markdown(&doc).unwrap();
11420 let round_tripped = markdown_to_adf(&md).unwrap();
11421 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11422 let mark_types: Vec<&str> = node
11423 .marks
11424 .as_ref()
11425 .unwrap()
11426 .iter()
11427 .map(|m| m.mark_type.as_str())
11428 .collect();
11429 assert_eq!(
11430 mark_types,
11431 vec!["link", "em"],
11432 "mark order should be preserved, got: {mark_types:?}"
11433 );
11434 }
11435
11436 #[test]
11437 fn mark_ordering_link_strike_preserved() {
11438 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11440 {"type":"text","text":"struck link","marks":[
11441 {"type":"link","attrs":{"href":"https://example.com"}},
11442 {"type":"strike"}
11443 ]}
11444 ]}]}"#;
11445 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11446 let md = adf_to_markdown(&doc).unwrap();
11447 let round_tripped = markdown_to_adf(&md).unwrap();
11448 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11449 let mark_types: Vec<&str> = node
11450 .marks
11451 .as_ref()
11452 .unwrap()
11453 .iter()
11454 .map(|m| m.mark_type.as_str())
11455 .collect();
11456 assert_eq!(
11457 mark_types,
11458 vec!["link", "strike"],
11459 "mark order should be preserved, got: {mark_types:?}"
11460 );
11461 }
11462
11463 #[test]
11464 fn mark_ordering_strong_link_preserved() {
11465 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11467 {"type":"text","text":"bold link","marks":[
11468 {"type":"strong"},
11469 {"type":"link","attrs":{"href":"https://example.com"}}
11470 ]}
11471 ]}]}"#;
11472 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11473 let md = adf_to_markdown(&doc).unwrap();
11474 let round_tripped = markdown_to_adf(&md).unwrap();
11475 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11476 let mark_types: Vec<&str> = node
11477 .marks
11478 .as_ref()
11479 .unwrap()
11480 .iter()
11481 .map(|m| m.mark_type.as_str())
11482 .collect();
11483 assert_eq!(
11484 mark_types,
11485 vec!["strong", "link"],
11486 "mark order should be preserved, got: {mark_types:?}"
11487 );
11488 }
11489
11490 #[test]
11491 fn mark_ordering_em_link_preserved() {
11492 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11494 {"type":"text","text":"italic link","marks":[
11495 {"type":"em"},
11496 {"type":"link","attrs":{"href":"https://example.com"}}
11497 ]}
11498 ]}]}"#;
11499 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11500 let md = adf_to_markdown(&doc).unwrap();
11501 let round_tripped = markdown_to_adf(&md).unwrap();
11502 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11503 let mark_types: Vec<&str> = node
11504 .marks
11505 .as_ref()
11506 .unwrap()
11507 .iter()
11508 .map(|m| m.mark_type.as_str())
11509 .collect();
11510 assert_eq!(
11511 mark_types,
11512 vec!["em", "link"],
11513 "mark order should be preserved, got: {mark_types:?}"
11514 );
11515 }
11516
11517 #[test]
11518 fn mark_ordering_strike_link_preserved() {
11519 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11521 {"type":"text","text":"struck link","marks":[
11522 {"type":"strike"},
11523 {"type":"link","attrs":{"href":"https://example.com"}}
11524 ]}
11525 ]}]}"#;
11526 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11527 let md = adf_to_markdown(&doc).unwrap();
11528 let round_tripped = markdown_to_adf(&md).unwrap();
11529 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11530 let mark_types: Vec<&str> = node
11531 .marks
11532 .as_ref()
11533 .unwrap()
11534 .iter()
11535 .map(|m| m.mark_type.as_str())
11536 .collect();
11537 assert_eq!(
11538 mark_types,
11539 vec!["strike", "link"],
11540 "mark order should be preserved, got: {mark_types:?}"
11541 );
11542 }
11543
11544 #[test]
11545 fn mark_ordering_underline_link_preserved() {
11546 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11548 {"type":"text","text":"click here","marks":[
11549 {"type":"underline"},
11550 {"type":"link","attrs":{"href":"https://example.com"}}
11551 ]}
11552 ]}]}"#;
11553 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11554 let md = adf_to_markdown(&doc).unwrap();
11555 let round_tripped = markdown_to_adf(&md).unwrap();
11556 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11557 let mark_types: Vec<&str> = node
11558 .marks
11559 .as_ref()
11560 .unwrap()
11561 .iter()
11562 .map(|m| m.mark_type.as_str())
11563 .collect();
11564 assert_eq!(
11565 mark_types,
11566 vec!["underline", "link"],
11567 "mark order should be preserved, got: {mark_types:?}"
11568 );
11569 }
11570
11571 #[test]
11572 fn mark_ordering_textcolor_link_preserved() {
11573 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11575 {"type":"text","text":"red link","marks":[
11576 {"type":"textColor","attrs":{"color":"#ff0000"}},
11577 {"type":"link","attrs":{"href":"https://example.com"}}
11578 ]}
11579 ]}]}"##;
11580 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11581 let md = adf_to_markdown(&doc).unwrap();
11582 let round_tripped = markdown_to_adf(&md).unwrap();
11583 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11584 let mark_types: Vec<&str> = node
11585 .marks
11586 .as_ref()
11587 .unwrap()
11588 .iter()
11589 .map(|m| m.mark_type.as_str())
11590 .collect();
11591 assert_eq!(
11592 mark_types,
11593 vec!["textColor", "link"],
11594 "mark order should be preserved, got: {mark_types:?}"
11595 );
11596 }
11597
11598 #[test]
11599 fn mark_ordering_link_underline_preserved() {
11600 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11602 {"type":"text","text":"click here","marks":[
11603 {"type":"link","attrs":{"href":"https://example.com"}},
11604 {"type":"underline"}
11605 ]}
11606 ]}]}"#;
11607 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11608 let md = adf_to_markdown(&doc).unwrap();
11609 assert!(
11611 md.contains("](https://example.com)"),
11612 "should have link: {md}"
11613 );
11614 assert!(md.contains("underline"), "should have underline: {md}");
11615 let round_tripped = markdown_to_adf(&md).unwrap();
11616 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11617 let mark_types: Vec<&str> = node
11618 .marks
11619 .as_ref()
11620 .unwrap()
11621 .iter()
11622 .map(|m| m.mark_type.as_str())
11623 .collect();
11624 assert_eq!(
11625 mark_types,
11626 vec!["link", "underline"],
11627 "mark order should be preserved, got: {mark_types:?}"
11628 );
11629 }
11630
11631 #[test]
11632 fn mark_ordering_underline_strong_link_preserved() {
11633 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11635 {"type":"text","text":"bold underlined link","marks":[
11636 {"type":"underline"},
11637 {"type":"strong"},
11638 {"type":"link","attrs":{"href":"https://example.com/page"}}
11639 ]}
11640 ]}]}"#;
11641 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11642 let md = adf_to_markdown(&doc).unwrap();
11643 let round_tripped = markdown_to_adf(&md).unwrap();
11644 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11645 let mark_types: Vec<&str> = node
11646 .marks
11647 .as_ref()
11648 .unwrap()
11649 .iter()
11650 .map(|m| m.mark_type.as_str())
11651 .collect();
11652 assert_eq!(
11653 mark_types,
11654 vec!["underline", "strong", "link"],
11655 "mark order should be preserved, got: {mark_types:?}"
11656 );
11657 }
11658
11659 #[test]
11660 fn mark_ordering_strong_underline_link_preserved() {
11661 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11663 {"type":"text","text":"bold underlined link","marks":[
11664 {"type":"strong"},
11665 {"type":"underline"},
11666 {"type":"link","attrs":{"href":"https://example.com/page"}}
11667 ]}
11668 ]}]}"#;
11669 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11670 let md = adf_to_markdown(&doc).unwrap();
11671 let round_tripped = markdown_to_adf(&md).unwrap();
11672 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11673 let mark_types: Vec<&str> = node
11674 .marks
11675 .as_ref()
11676 .unwrap()
11677 .iter()
11678 .map(|m| m.mark_type.as_str())
11679 .collect();
11680 assert_eq!(
11681 mark_types,
11682 vec!["strong", "underline", "link"],
11683 "mark order should be preserved, got: {mark_types:?}"
11684 );
11685 }
11686
11687 #[test]
11688 fn mark_ordering_underline_em_link_preserved() {
11689 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11691 {"type":"text","text":"italic underlined link","marks":[
11692 {"type":"underline"},
11693 {"type":"em"},
11694 {"type":"link","attrs":{"href":"https://example.com/page"}}
11695 ]}
11696 ]}]}"#;
11697 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11698 let md = adf_to_markdown(&doc).unwrap();
11699 let round_tripped = markdown_to_adf(&md).unwrap();
11700 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
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!(
11709 mark_types,
11710 vec!["underline", "em", "link"],
11711 "mark order should be preserved, got: {mark_types:?}"
11712 );
11713 }
11714
11715 #[test]
11716 fn mark_ordering_underline_strike_link_preserved() {
11717 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11719 {"type":"text","text":"struck underlined link","marks":[
11720 {"type":"underline"},
11721 {"type":"strike"},
11722 {"type":"link","attrs":{"href":"https://example.com/page"}}
11723 ]}
11724 ]}]}"#;
11725 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11726 let md = adf_to_markdown(&doc).unwrap();
11727 let round_tripped = markdown_to_adf(&md).unwrap();
11728 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11729 let mark_types: Vec<&str> = node
11730 .marks
11731 .as_ref()
11732 .unwrap()
11733 .iter()
11734 .map(|m| m.mark_type.as_str())
11735 .collect();
11736 assert_eq!(
11737 mark_types,
11738 vec!["underline", "strike", "link"],
11739 "mark order should be preserved, got: {mark_types:?}"
11740 );
11741 }
11742
11743 #[test]
11744 fn mark_ordering_underline_strong_em_link_preserved() {
11745 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11747 {"type":"text","text":"all the marks","marks":[
11748 {"type":"underline"},
11749 {"type":"strong"},
11750 {"type":"em"},
11751 {"type":"link","attrs":{"href":"https://example.com/page"}}
11752 ]}
11753 ]}]}"#;
11754 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11755 let md = adf_to_markdown(&doc).unwrap();
11756 let round_tripped = markdown_to_adf(&md).unwrap();
11757 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11758 let mark_types: Vec<&str> = node
11759 .marks
11760 .as_ref()
11761 .unwrap()
11762 .iter()
11763 .map(|m| m.mark_type.as_str())
11764 .collect();
11765 assert_eq!(
11766 mark_types,
11767 vec!["underline", "strong", "em", "link"],
11768 "mark order should be preserved, got: {mark_types:?}"
11769 );
11770 }
11771
11772 #[test]
11773 fn em_strong_round_trip() {
11774 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11776 {"type":"text","text":"bold and italic","marks":[{"type":"strong"},{"type":"em"}]}
11777 ]}]}"#;
11778 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11779 let md = adf_to_markdown(&doc).unwrap();
11780 assert_eq!(md.trim(), "***bold and italic***");
11781 let round_tripped = markdown_to_adf(&md).unwrap();
11782 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11783 assert_eq!(node.text.as_deref(), Some("bold and italic"));
11784 let mark_types: Vec<&str> = node
11785 .marks
11786 .as_ref()
11787 .unwrap()
11788 .iter()
11789 .map(|m| m.mark_type.as_str())
11790 .collect();
11791 assert_eq!(
11792 mark_types,
11793 vec!["strong", "em"],
11794 "both strong and em marks should be preserved, got: {mark_types:?}"
11795 );
11796 }
11797
11798 #[test]
11799 fn em_strong_round_trip_em_first() {
11800 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11802 {"type":"text","text":"italic and bold","marks":[{"type":"em"},{"type":"strong"}]}
11803 ]}]}"#;
11804 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11805 let md = adf_to_markdown(&doc).unwrap();
11806 let round_tripped = markdown_to_adf(&md).unwrap();
11807 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11808 assert_eq!(node.text.as_deref(), Some("italic and bold"));
11809 let mark_types: Vec<&str> = node
11810 .marks
11811 .as_ref()
11812 .unwrap()
11813 .iter()
11814 .map(|m| m.mark_type.as_str())
11815 .collect();
11816 assert_eq!(
11817 mark_types,
11818 vec!["em", "strong"],
11819 "mark order [em, strong] should be preserved, got: {mark_types:?}"
11820 );
11821 }
11822
11823 fn assert_mark_order_round_trip(marks: Vec<AdfMark>, expected: &[&str]) {
11826 let doc = AdfDocument {
11827 version: 1,
11828 doc_type: "doc".to_string(),
11829 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11830 "text", marks,
11831 )])],
11832 };
11833 let md = adf_to_markdown(&doc).unwrap();
11834 let round_tripped = markdown_to_adf(&md).unwrap();
11835 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11836 let mark_types: Vec<&str> = node
11837 .marks
11838 .as_ref()
11839 .expect("round-tripped node should have marks")
11840 .iter()
11841 .map(|m| m.mark_type.as_str())
11842 .collect();
11843 assert_eq!(
11844 mark_types, expected,
11845 "marks should round-trip in order via {md:?}"
11846 );
11847 }
11848
11849 #[test]
11850 fn round_trip_em_strong_mark_order() {
11851 assert_mark_order_round_trip(vec![AdfMark::em(), AdfMark::strong()], &["em", "strong"]);
11853 assert_mark_order_round_trip(vec![AdfMark::strong(), AdfMark::em()], &["strong", "em"]);
11854 }
11855
11856 #[test]
11857 fn round_trip_strong_underline_mark_order() {
11858 assert_mark_order_round_trip(
11860 vec![AdfMark::strong(), AdfMark::underline()],
11861 &["strong", "underline"],
11862 );
11863 assert_mark_order_round_trip(
11864 vec![AdfMark::underline(), AdfMark::strong()],
11865 &["underline", "strong"],
11866 );
11867 }
11868
11869 #[test]
11870 fn round_trip_em_underline_mark_order() {
11871 assert_mark_order_round_trip(
11872 vec![AdfMark::em(), AdfMark::underline()],
11873 &["em", "underline"],
11874 );
11875 assert_mark_order_round_trip(
11876 vec![AdfMark::underline(), AdfMark::em()],
11877 &["underline", "em"],
11878 );
11879 }
11880
11881 #[test]
11882 fn round_trip_strike_strong_em_permutations() {
11883 assert_mark_order_round_trip(
11887 vec![AdfMark::strike(), AdfMark::strong(), AdfMark::em()],
11888 &["strike", "strong", "em"],
11889 );
11890 assert_mark_order_round_trip(
11891 vec![AdfMark::strike(), AdfMark::em(), AdfMark::strong()],
11892 &["strike", "em", "strong"],
11893 );
11894 assert_mark_order_round_trip(
11895 vec![AdfMark::strong(), AdfMark::strike(), AdfMark::em()],
11896 &["strong", "strike", "em"],
11897 );
11898 assert_mark_order_round_trip(
11899 vec![AdfMark::strong(), AdfMark::em(), AdfMark::strike()],
11900 &["strong", "em", "strike"],
11901 );
11902 assert_mark_order_round_trip(
11903 vec![AdfMark::em(), AdfMark::strike(), AdfMark::strong()],
11904 &["em", "strike", "strong"],
11905 );
11906 assert_mark_order_round_trip(
11907 vec![AdfMark::em(), AdfMark::strong(), AdfMark::strike()],
11908 &["em", "strong", "strike"],
11909 );
11910 }
11911
11912 #[test]
11913 fn round_trip_underline_nested_with_strong_em() {
11914 assert_mark_order_round_trip(
11917 vec![AdfMark::underline(), AdfMark::strong(), AdfMark::em()],
11918 &["underline", "strong", "em"],
11919 );
11920 assert_mark_order_round_trip(
11921 vec![AdfMark::strong(), AdfMark::underline(), AdfMark::em()],
11922 &["strong", "underline", "em"],
11923 );
11924 assert_mark_order_round_trip(
11925 vec![AdfMark::strong(), AdfMark::em(), AdfMark::underline()],
11926 &["strong", "em", "underline"],
11927 );
11928 }
11929
11930 #[test]
11931 fn round_trip_span_attr_order_preserved() {
11932 assert_mark_order_round_trip(
11936 vec![
11937 AdfMark::background_color("#ffff00"),
11938 AdfMark::text_color("#ff0000"),
11939 ],
11940 &["backgroundColor", "textColor"],
11941 );
11942 assert_mark_order_round_trip(
11943 vec![AdfMark::subsup("sub"), AdfMark::text_color("#ff0000")],
11944 &["subsup", "textColor"],
11945 );
11946 assert_mark_order_round_trip(
11947 vec![
11948 AdfMark::text_color("#ff0000"),
11949 AdfMark::background_color("#ffff00"),
11950 ],
11951 &["textColor", "backgroundColor"],
11952 );
11953 }
11954
11955 #[test]
11956 fn round_trip_annotation_before_underline() {
11957 assert_mark_order_round_trip(
11961 vec![
11962 AdfMark::annotation("ann-1", "inlineComment"),
11963 AdfMark::underline(),
11964 ],
11965 &["annotation", "underline"],
11966 );
11967 assert_mark_order_round_trip(
11968 vec![
11969 AdfMark::annotation("ann-1", "inlineComment"),
11970 AdfMark::underline(),
11971 AdfMark::annotation("ann-2", "inlineComment"),
11972 ],
11973 &["annotation", "underline", "annotation"],
11974 );
11975 }
11976
11977 #[test]
11978 fn round_trip_em_content_with_underscores() {
11979 let doc = AdfDocument {
11984 version: 1,
11985 doc_type: "doc".to_string(),
11986 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11987 "foo _bar_ baz",
11988 vec![AdfMark::em(), AdfMark::strong()],
11989 )])],
11990 };
11991 let md = adf_to_markdown(&doc).unwrap();
11992 let round_tripped = markdown_to_adf(&md).unwrap();
11993 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11994 assert_eq!(node.text.as_deref(), Some("foo _bar_ baz"));
11995 let mark_types: Vec<&str> = node
11996 .marks
11997 .as_ref()
11998 .unwrap()
11999 .iter()
12000 .map(|m| m.mark_type.as_str())
12001 .collect();
12002 assert_eq!(mark_types, vec!["em", "strong"]);
12003 }
12004
12005 #[test]
12006 fn round_trip_link_nested_with_formatting_marks() {
12007 assert_mark_order_round_trip(
12010 vec![
12011 AdfMark::link("https://example.com"),
12012 AdfMark::strong(),
12013 AdfMark::em(),
12014 ],
12015 &["link", "strong", "em"],
12016 );
12017 assert_mark_order_round_trip(
12018 vec![
12019 AdfMark::em(),
12020 AdfMark::strong(),
12021 AdfMark::link("https://example.com"),
12022 ],
12023 &["em", "strong", "link"],
12024 );
12025 assert_mark_order_round_trip(
12026 vec![
12027 AdfMark::underline(),
12028 AdfMark::link("https://example.com"),
12029 AdfMark::strong(),
12030 ],
12031 &["underline", "link", "strong"],
12032 );
12033 }
12034
12035 fn bare_mark(mark_type: &str) -> AdfMark {
12039 AdfMark {
12040 mark_type: mark_type.to_string(),
12041 attrs: None,
12042 }
12043 }
12044
12045 #[test]
12046 fn collect_span_attr_handles_missing_attrs() {
12047 let mut attrs = Vec::new();
12052 collect_span_attr(&bare_mark("textColor"), &mut attrs);
12053 collect_span_attr(&bare_mark("backgroundColor"), &mut attrs);
12054 collect_span_attr(&bare_mark("subsup"), &mut attrs);
12055 collect_span_attr(&bare_mark("link"), &mut attrs);
12056 assert!(attrs.is_empty(), "got: {attrs:?}");
12057 }
12058
12059 #[test]
12060 fn collect_bracketed_attr_handles_missing_attrs() {
12061 let mut attrs = Vec::new();
12064 collect_bracketed_attr(&bare_mark("annotation"), &mut attrs);
12065 collect_bracketed_attr(&bare_mark("strong"), &mut attrs);
12066 assert!(attrs.is_empty(), "got: {attrs:?}");
12067 }
12068
12069 #[test]
12070 fn collect_bracketed_attr_handles_annotation_without_id() {
12071 let mark = AdfMark {
12075 mark_type: "annotation".to_string(),
12076 attrs: Some(serde_json::json!({})),
12077 };
12078 let mut attrs = Vec::new();
12079 collect_bracketed_attr(&mark, &mut attrs);
12080 assert!(attrs.is_empty(), "got: {attrs:?}");
12081 }
12082
12083 #[test]
12084 fn span_attr_order_rejects_unknown_types() {
12085 assert_eq!(span_attr_order("textColor"), 0);
12089 assert_eq!(span_attr_order("backgroundColor"), 1);
12090 assert_eq!(span_attr_order("subsup"), 2);
12091 assert_eq!(span_attr_order("strong"), u8::MAX);
12092 assert!(!span_run_is_canonical(&[bare_mark("strong")]));
12093 }
12094
12095 #[test]
12096 fn bracketed_run_rejects_unknown_types() {
12097 assert!(bracketed_run_is_canonical(&[
12101 AdfMark::underline(),
12102 AdfMark::annotation("x", "inlineComment")
12103 ]));
12104 assert!(!bracketed_run_is_canonical(&[
12105 AdfMark::annotation("x", "inlineComment"),
12106 AdfMark::underline()
12107 ]));
12108 assert!(!bracketed_run_is_canonical(&[bare_mark("strong")]));
12109 }
12110
12111 #[test]
12112 fn render_marked_text_ignores_unknown_mark_types() {
12113 let doc = AdfDocument {
12117 version: 1,
12118 doc_type: "doc".to_string(),
12119 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12120 "hello",
12121 vec![bare_mark("futureMark"), AdfMark::strong()],
12122 )])],
12123 };
12124 let md = adf_to_markdown(&doc).unwrap();
12125 assert_eq!(md.trim(), "**hello**");
12126 let rt = markdown_to_adf(&md).unwrap();
12127 let node = &rt.content[0].content.as_ref().unwrap()[0];
12128 assert_eq!(node.text.as_deref(), Some("hello"));
12129 let mark_types: Vec<&str> = node
12130 .marks
12131 .as_ref()
12132 .unwrap()
12133 .iter()
12134 .map(|m| m.mark_type.as_str())
12135 .collect();
12136 assert_eq!(mark_types, vec!["strong"]);
12137 }
12138
12139 #[test]
12140 fn triple_asterisk_parse_to_adf() {
12141 let md = "***bold and italic***\n";
12143 let doc = markdown_to_adf(md).unwrap();
12144 let node = &doc.content[0].content.as_ref().unwrap()[0];
12145 assert_eq!(node.text.as_deref(), Some("bold and italic"));
12146 let mark_types: Vec<&str> = node
12147 .marks
12148 .as_ref()
12149 .unwrap()
12150 .iter()
12151 .map(|m| m.mark_type.as_str())
12152 .collect();
12153 assert!(
12154 mark_types.contains(&"strong") && mark_types.contains(&"em"),
12155 "***text*** should produce both strong and em marks, got: {mark_types:?}"
12156 );
12157 }
12158
12159 #[test]
12160 fn triple_asterisk_with_surrounding_text() {
12161 let md = "before ***bold italic*** after\n";
12163 let doc = markdown_to_adf(md).unwrap();
12164 let nodes = doc.content[0].content.as_ref().unwrap();
12165 assert!(
12167 nodes.len() >= 3,
12168 "expected at least 3 nodes, got {}",
12169 nodes.len()
12170 );
12171 assert_eq!(nodes[0].text.as_deref(), Some("before "));
12172 assert_eq!(nodes[1].text.as_deref(), Some("bold italic"));
12173 let mark_types: Vec<&str> = nodes[1]
12174 .marks
12175 .as_ref()
12176 .unwrap()
12177 .iter()
12178 .map(|m| m.mark_type.as_str())
12179 .collect();
12180 assert!(
12181 mark_types.contains(&"strong") && mark_types.contains(&"em"),
12182 "middle node should have strong+em, got: {mark_types:?}"
12183 );
12184 assert_eq!(nodes[2].text.as_deref(), Some(" after"));
12185 }
12186
12187 #[test]
12188 fn annotation_mark_round_trip() {
12189 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12191 {"type":"text","text":"highlighted text","marks":[
12192 {"type":"annotation","attrs":{"id":"abc123","annotationType":"inlineComment"}}
12193 ]}
12194 ]}]}"#;
12195 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12196
12197 let md = adf_to_markdown(&doc).unwrap();
12198 assert!(
12199 md.contains("annotation-id="),
12200 "JFM should contain annotation-id, got: {md}"
12201 );
12202
12203 let round_tripped = markdown_to_adf(&md).unwrap();
12204 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12205 assert_eq!(text_node.text.as_deref(), Some("highlighted text"));
12206 let marks = text_node.marks.as_ref().expect("should have marks");
12207 let ann = marks
12208 .iter()
12209 .find(|m| m.mark_type == "annotation")
12210 .expect("should have annotation mark");
12211 let attrs = ann.attrs.as_ref().unwrap();
12212 assert_eq!(attrs["id"], "abc123");
12213 assert_eq!(attrs["annotationType"], "inlineComment");
12214 }
12215
12216 #[test]
12217 fn annotation_mark_with_bold() {
12218 let doc = AdfDocument {
12220 version: 1,
12221 doc_type: "doc".to_string(),
12222 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12223 "bold comment",
12224 vec![
12225 AdfMark::strong(),
12226 AdfMark::annotation("def456", "inlineComment"),
12227 ],
12228 )])],
12229 };
12230 let md = adf_to_markdown(&doc).unwrap();
12231 let round_tripped = markdown_to_adf(&md).unwrap();
12232 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12233 let marks = text_node.marks.as_ref().expect("should have marks");
12234 assert!(
12235 marks.iter().any(|m| m.mark_type == "strong"),
12236 "should have strong mark"
12237 );
12238 assert!(
12239 marks.iter().any(|m| m.mark_type == "annotation"),
12240 "should have annotation mark"
12241 );
12242 }
12243
12244 #[test]
12245 fn annotation_and_link_marks_both_preserved() {
12246 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12248 {"type":"text","text":"HANGUL-8","marks":[
12249 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"5ca7425e-34cd-48d3-b4eb-9873ac8b20e0"}},
12250 {"type":"link","attrs":{"href":"https://zd.atlassian.net/browse/HANG-8"}}
12251 ]}
12252 ]}]}"#;
12253 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12254 let md = adf_to_markdown(&doc).unwrap();
12255 assert!(
12257 md.contains("annotation-id="),
12258 "JFM should contain annotation-id, got: {md}"
12259 );
12260 assert!(
12261 md.contains("](https://"),
12262 "JFM should contain link href, got: {md}"
12263 );
12264 let round_tripped = markdown_to_adf(&md).unwrap();
12265 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12266 let marks = text_node.marks.as_ref().expect("should have marks");
12267 assert!(
12268 marks.iter().any(|m| m.mark_type == "annotation"),
12269 "should have annotation mark, got: {:?}",
12270 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12271 );
12272 assert!(
12273 marks.iter().any(|m| m.mark_type == "link"),
12274 "should have link mark, got: {:?}",
12275 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12276 );
12277 }
12278
12279 #[test]
12280 fn annotation_and_code_marks_both_preserved() {
12281 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12283 {"type":"text","text":"some text with "},
12284 {"type":"text","text":"annotated code","marks":[
12285 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"aabbccdd-1234-5678-abcd-000000000001"}},
12286 {"type":"code"}
12287 ]},
12288 {"type":"text","text":" remaining text"}
12289 ]}]}"#;
12290 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12291 let md = adf_to_markdown(&doc).unwrap();
12292 assert!(
12293 md.contains("annotation-id="),
12294 "JFM should contain annotation-id, got: {md}"
12295 );
12296 assert!(
12297 md.contains('`'),
12298 "JFM should contain backticks for code, got: {md}"
12299 );
12300
12301 let round_tripped = markdown_to_adf(&md).unwrap();
12302 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12303 let code_node = nodes
12305 .iter()
12306 .find(|n| n.text.as_deref() == Some("annotated code"))
12307 .expect("should have 'annotated code' text node");
12308 let marks = code_node.marks.as_ref().expect("should have marks");
12309 assert!(
12310 marks.iter().any(|m| m.mark_type == "annotation"),
12311 "should have annotation mark, got: {:?}",
12312 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12313 );
12314 assert!(
12315 marks.iter().any(|m| m.mark_type == "code"),
12316 "should have code mark, got: {:?}",
12317 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12318 );
12319 let ann = marks.iter().find(|m| m.mark_type == "annotation").unwrap();
12320 let attrs = ann.attrs.as_ref().unwrap();
12321 assert_eq!(attrs["id"], "aabbccdd-1234-5678-abcd-000000000001");
12322 assert_eq!(attrs["annotationType"], "inlineComment");
12323 }
12324
12325 #[test]
12326 fn annotation_and_code_and_link_marks_all_preserved() {
12327 let doc = AdfDocument {
12329 version: 1,
12330 doc_type: "doc".to_string(),
12331 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12332 "linked code",
12333 vec![
12334 AdfMark::annotation("ann-001", "inlineComment"),
12335 AdfMark::code(),
12336 AdfMark::link("https://example.com"),
12337 ],
12338 )])],
12339 };
12340 let md = adf_to_markdown(&doc).unwrap();
12341 assert!(
12342 md.contains("annotation-id="),
12343 "JFM should contain annotation-id, got: {md}"
12344 );
12345 assert!(md.contains('`'), "JFM should contain backticks, got: {md}");
12346 assert!(
12347 md.contains("](https://example.com)"),
12348 "JFM should contain link, got: {md}"
12349 );
12350
12351 let round_tripped = markdown_to_adf(&md).unwrap();
12352 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12353 let marks = text_node.marks.as_ref().expect("should have marks");
12354 assert!(
12355 marks.iter().any(|m| m.mark_type == "annotation"),
12356 "should have annotation mark, got: {:?}",
12357 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12358 );
12359 assert!(
12360 marks.iter().any(|m| m.mark_type == "code"),
12361 "should have code mark, got: {:?}",
12362 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12363 );
12364 assert!(
12365 marks.iter().any(|m| m.mark_type == "link"),
12366 "should have link mark, got: {:?}",
12367 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12368 );
12369 }
12370
12371 #[test]
12372 fn multiple_annotations_and_code_mark_preserved() {
12373 let doc = AdfDocument {
12375 version: 1,
12376 doc_type: "doc".to_string(),
12377 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12378 "doubly annotated",
12379 vec![
12380 AdfMark::annotation("ann-aaa", "inlineComment"),
12381 AdfMark::annotation("ann-bbb", "inlineComment"),
12382 AdfMark::code(),
12383 ],
12384 )])],
12385 };
12386 let md = adf_to_markdown(&doc).unwrap();
12387 assert!(
12388 md.contains("ann-aaa"),
12389 "JFM should contain first annotation id, got: {md}"
12390 );
12391 assert!(
12392 md.contains("ann-bbb"),
12393 "JFM should contain second annotation id, got: {md}"
12394 );
12395
12396 let round_tripped = markdown_to_adf(&md).unwrap();
12397 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12398 let marks = text_node.marks.as_ref().expect("should have marks");
12399 let ann_marks: Vec<_> = marks
12400 .iter()
12401 .filter(|m| m.mark_type == "annotation")
12402 .collect();
12403 assert_eq!(
12404 ann_marks.len(),
12405 2,
12406 "should have 2 annotation marks, got: {}",
12407 ann_marks.len()
12408 );
12409 assert!(
12410 marks.iter().any(|m| m.mark_type == "code"),
12411 "should have code mark"
12412 );
12413 }
12414
12415 #[test]
12416 fn underline_and_link_marks_both_preserved() {
12417 let doc = AdfDocument {
12419 version: 1,
12420 doc_type: "doc".to_string(),
12421 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12422 "click here",
12423 vec![AdfMark::underline(), AdfMark::link("https://example.com")],
12424 )])],
12425 };
12426 let md = adf_to_markdown(&doc).unwrap();
12427 assert!(md.contains("underline"), "should have underline attr: {md}");
12428 assert!(
12429 md.contains("](https://example.com)"),
12430 "should have link: {md}"
12431 );
12432 let round_tripped = markdown_to_adf(&md).unwrap();
12433 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12434 let marks = text_node.marks.as_ref().expect("should have marks");
12435 assert!(marks.iter().any(|m| m.mark_type == "underline"));
12436 assert!(marks.iter().any(|m| m.mark_type == "link"));
12437 }
12438
12439 #[test]
12440 fn annotation_link_and_bold_all_preserved() {
12441 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12443 {"type":"text","text":"important","marks":[
12444 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"abc"}},
12445 {"type":"link","attrs":{"href":"https://example.com"}},
12446 {"type":"strong"}
12447 ]}
12448 ]}]}"#;
12449 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12450 let md = adf_to_markdown(&doc).unwrap();
12451 let round_tripped = markdown_to_adf(&md).unwrap();
12452 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12453 let marks = text_node.marks.as_ref().expect("should have marks");
12454 assert!(
12455 marks.iter().any(|m| m.mark_type == "annotation"),
12456 "should have annotation"
12457 );
12458 assert!(
12459 marks.iter().any(|m| m.mark_type == "link"),
12460 "should have link"
12461 );
12462 assert!(
12463 marks.iter().any(|m| m.mark_type == "strong"),
12464 "should have strong"
12465 );
12466 }
12467
12468 #[test]
12469 fn multiple_annotation_marks_round_trip() {
12470 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12472 {"type":"text","text":"some annotated text","marks":[
12473 {"type":"annotation","attrs":{"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","annotationType":"inlineComment"}},
12474 {"type":"annotation","attrs":{"id":"ffffffff-1111-2222-3333-444444444444","annotationType":"inlineComment"}}
12475 ]}
12476 ]}]}"#;
12477 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12478
12479 let md = adf_to_markdown(&doc).unwrap();
12480 assert!(
12481 md.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
12482 "JFM should contain first annotation id, got: {md}"
12483 );
12484 assert!(
12485 md.contains("ffffffff-1111-2222-3333-444444444444"),
12486 "JFM should contain second annotation id, got: {md}"
12487 );
12488
12489 let round_tripped = markdown_to_adf(&md).unwrap();
12490 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12491 assert_eq!(text_node.text.as_deref(), Some("some annotated text"));
12492 let marks = text_node.marks.as_ref().expect("should have marks");
12493 let annotations: Vec<_> = marks
12494 .iter()
12495 .filter(|m| m.mark_type == "annotation")
12496 .collect();
12497 assert_eq!(
12498 annotations.len(),
12499 2,
12500 "should have 2 annotation marks, got: {annotations:?}"
12501 );
12502 let ids: Vec<_> = annotations
12503 .iter()
12504 .map(|a| a.attrs.as_ref().unwrap()["id"].as_str().unwrap())
12505 .collect();
12506 assert!(ids.contains(&"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
12507 assert!(ids.contains(&"ffffffff-1111-2222-3333-444444444444"));
12508 }
12509
12510 #[test]
12511 fn three_annotation_marks_round_trip() {
12512 let doc = AdfDocument {
12514 version: 1,
12515 doc_type: "doc".to_string(),
12516 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12517 "triple annotated",
12518 vec![
12519 AdfMark::annotation("id-1", "inlineComment"),
12520 AdfMark::annotation("id-2", "inlineComment"),
12521 AdfMark::annotation("id-3", "inlineComment"),
12522 ],
12523 )])],
12524 };
12525 let md = adf_to_markdown(&doc).unwrap();
12526 let round_tripped = markdown_to_adf(&md).unwrap();
12527 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12528 let marks = text_node.marks.as_ref().expect("should have marks");
12529 let annotations: Vec<_> = marks
12530 .iter()
12531 .filter(|m| m.mark_type == "annotation")
12532 .collect();
12533 assert_eq!(
12534 annotations.len(),
12535 3,
12536 "should have 3 annotation marks, got: {annotations:?}"
12537 );
12538 }
12539
12540 #[test]
12541 fn multiple_annotations_with_bold_round_trip() {
12542 let doc = AdfDocument {
12544 version: 1,
12545 doc_type: "doc".to_string(),
12546 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12547 "bold double annotated",
12548 vec![
12549 AdfMark::strong(),
12550 AdfMark::annotation("ann-a", "inlineComment"),
12551 AdfMark::annotation("ann-b", "inlineComment"),
12552 ],
12553 )])],
12554 };
12555 let md = adf_to_markdown(&doc).unwrap();
12556 let round_tripped = markdown_to_adf(&md).unwrap();
12557 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12558 let marks = text_node.marks.as_ref().expect("should have marks");
12559 assert!(
12560 marks.iter().any(|m| m.mark_type == "strong"),
12561 "should have strong mark"
12562 );
12563 let annotations: Vec<_> = marks
12564 .iter()
12565 .filter(|m| m.mark_type == "annotation")
12566 .collect();
12567 assert_eq!(
12568 annotations.len(),
12569 2,
12570 "should have 2 annotation marks, got: {annotations:?}"
12571 );
12572 }
12573
12574 #[test]
12575 fn multiple_annotations_with_link_round_trip() {
12576 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12578 {"type":"text","text":"linked text","marks":[
12579 {"type":"annotation","attrs":{"id":"ann-x","annotationType":"inlineComment"}},
12580 {"type":"annotation","attrs":{"id":"ann-y","annotationType":"inlineComment"}},
12581 {"type":"link","attrs":{"href":"https://example.com"}}
12582 ]}
12583 ]}]}"#;
12584 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12585 let md = adf_to_markdown(&doc).unwrap();
12586 let round_tripped = markdown_to_adf(&md).unwrap();
12587 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12588 let marks = text_node.marks.as_ref().expect("should have marks");
12589 assert!(
12590 marks.iter().any(|m| m.mark_type == "link"),
12591 "should have link mark"
12592 );
12593 let annotations: Vec<_> = marks
12594 .iter()
12595 .filter(|m| m.mark_type == "annotation")
12596 .collect();
12597 assert_eq!(
12598 annotations.len(),
12599 2,
12600 "should have 2 annotation marks, got: {annotations:?}"
12601 );
12602 }
12603
12604 #[test]
12607 fn annotation_on_emoji_round_trip() {
12608 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12610 {"type":"emoji","attrs":{"id":"1f4dd","shortName":":memo:","text":"📝"},"marks":[
12611 {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12612 ]},
12613 {"type":"text","text":" annotated text","marks":[
12614 {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12615 ]}
12616 ]}]}"#;
12617 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12618 let md = adf_to_markdown(&doc).unwrap();
12619 assert!(
12620 md.contains("annotation-id="),
12621 "JFM should contain annotation-id for emoji, got: {md}"
12622 );
12623
12624 let round_tripped = markdown_to_adf(&md).unwrap();
12625 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12626
12627 let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12629 let emoji_marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12630 assert!(
12631 emoji_marks.iter().any(|m| m.mark_type == "annotation"),
12632 "emoji should have annotation mark, got: {emoji_marks:?}"
12633 );
12634 let ann = emoji_marks
12635 .iter()
12636 .find(|m| m.mark_type == "annotation")
12637 .unwrap();
12638 assert_eq!(
12639 ann.attrs.as_ref().unwrap()["id"],
12640 "ccddee11-2233-4455-aabb-ccddee112233"
12641 );
12642
12643 let text_node = nodes.iter().find(|n| n.node_type == "text").unwrap();
12645 let text_marks = text_node.marks.as_ref().expect("text should have marks");
12646 assert!(
12647 text_marks.iter().any(|m| m.mark_type == "annotation"),
12648 "text should have annotation mark"
12649 );
12650 }
12651
12652 #[test]
12653 fn annotation_on_status_round_trip() {
12654 let mut status = AdfNode::status("In Progress", "blue");
12655 status.marks = Some(vec![AdfMark::annotation("ann-status-1", "inlineComment")]);
12656
12657 let doc = AdfDocument {
12658 version: 1,
12659 doc_type: "doc".to_string(),
12660 content: vec![AdfNode::paragraph(vec![status])],
12661 };
12662 let md = adf_to_markdown(&doc).unwrap();
12663 assert!(
12664 md.contains("annotation-id="),
12665 "JFM should contain annotation-id for status, got: {md}"
12666 );
12667
12668 let round_tripped = markdown_to_adf(&md).unwrap();
12669 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12670 let status_node = nodes.iter().find(|n| n.node_type == "status").unwrap();
12671 let marks = status_node
12672 .marks
12673 .as_ref()
12674 .expect("status should have marks");
12675 assert!(
12676 marks.iter().any(|m| m.mark_type == "annotation"),
12677 "status should have annotation mark, got: {marks:?}"
12678 );
12679 }
12680
12681 #[test]
12682 fn annotation_on_date_round_trip() {
12683 let mut date = AdfNode::date("1704067200000");
12684 date.marks = Some(vec![AdfMark::annotation("ann-date-1", "inlineComment")]);
12685
12686 let doc = AdfDocument {
12687 version: 1,
12688 doc_type: "doc".to_string(),
12689 content: vec![AdfNode::paragraph(vec![date])],
12690 };
12691 let md = adf_to_markdown(&doc).unwrap();
12692 assert!(
12693 md.contains("annotation-id="),
12694 "JFM should contain annotation-id for date, got: {md}"
12695 );
12696
12697 let round_tripped = markdown_to_adf(&md).unwrap();
12698 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12699 let date_node = nodes.iter().find(|n| n.node_type == "date").unwrap();
12700 let marks = date_node.marks.as_ref().expect("date should have marks");
12701 assert!(
12702 marks.iter().any(|m| m.mark_type == "annotation"),
12703 "date should have annotation mark, got: {marks:?}"
12704 );
12705 }
12706
12707 #[test]
12708 fn annotation_on_mention_round_trip() {
12709 let mut mention = AdfNode::mention("user-123", "@Alice");
12710 mention.marks = Some(vec![AdfMark::annotation("ann-mention-1", "inlineComment")]);
12711
12712 let doc = AdfDocument {
12713 version: 1,
12714 doc_type: "doc".to_string(),
12715 content: vec![AdfNode::paragraph(vec![mention])],
12716 };
12717 let md = adf_to_markdown(&doc).unwrap();
12718 assert!(
12719 md.contains("annotation-id="),
12720 "JFM should contain annotation-id for mention, got: {md}"
12721 );
12722
12723 let round_tripped = markdown_to_adf(&md).unwrap();
12724 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12725 let mention_node = nodes.iter().find(|n| n.node_type == "mention").unwrap();
12726 let marks = mention_node
12727 .marks
12728 .as_ref()
12729 .expect("mention should have marks");
12730 assert!(
12731 marks.iter().any(|m| m.mark_type == "annotation"),
12732 "mention should have annotation mark, got: {marks:?}"
12733 );
12734 }
12735
12736 #[test]
12737 fn annotation_on_inline_card_round_trip() {
12738 let mut card = AdfNode::inline_card("https://example.com");
12739 card.marks = Some(vec![AdfMark::annotation("ann-card-1", "inlineComment")]);
12740
12741 let doc = AdfDocument {
12742 version: 1,
12743 doc_type: "doc".to_string(),
12744 content: vec![AdfNode::paragraph(vec![card])],
12745 };
12746 let md = adf_to_markdown(&doc).unwrap();
12747 assert!(
12748 md.contains("annotation-id="),
12749 "JFM should contain annotation-id for inlineCard, got: {md}"
12750 );
12751
12752 let round_tripped = markdown_to_adf(&md).unwrap();
12753 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12754 let card_node = nodes.iter().find(|n| n.node_type == "inlineCard").unwrap();
12755 let marks = card_node
12756 .marks
12757 .as_ref()
12758 .expect("inlineCard should have marks");
12759 assert!(
12760 marks.iter().any(|m| m.mark_type == "annotation"),
12761 "inlineCard should have annotation mark, got: {marks:?}"
12762 );
12763 }
12764
12765 #[test]
12766 fn annotation_on_placeholder_round_trip() {
12767 let mut placeholder = AdfNode::placeholder("Enter text here");
12768 placeholder.marks = Some(vec![AdfMark::annotation("ann-ph-1", "inlineComment")]);
12769
12770 let doc = AdfDocument {
12771 version: 1,
12772 doc_type: "doc".to_string(),
12773 content: vec![AdfNode::paragraph(vec![placeholder])],
12774 };
12775 let md = adf_to_markdown(&doc).unwrap();
12776 assert!(
12777 md.contains("annotation-id="),
12778 "JFM should contain annotation-id for placeholder, got: {md}"
12779 );
12780
12781 let round_tripped = markdown_to_adf(&md).unwrap();
12782 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12783 let ph_node = nodes.iter().find(|n| n.node_type == "placeholder").unwrap();
12784 let marks = ph_node
12785 .marks
12786 .as_ref()
12787 .expect("placeholder should have marks");
12788 assert!(
12789 marks.iter().any(|m| m.mark_type == "annotation"),
12790 "placeholder should have annotation mark, got: {marks:?}"
12791 );
12792 }
12793
12794 #[test]
12795 fn multiple_annotations_on_emoji_round_trip() {
12796 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12798 {"type":"emoji","attrs":{"shortName":":fire:","text":"🔥"},"marks":[
12799 {"type":"annotation","attrs":{"id":"ann-1","annotationType":"inlineComment"}},
12800 {"type":"annotation","attrs":{"id":"ann-2","annotationType":"inlineComment"}}
12801 ]}
12802 ]}]}"#;
12803 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12804 let md = adf_to_markdown(&doc).unwrap();
12805
12806 let round_tripped = markdown_to_adf(&md).unwrap();
12807 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12808 let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12809 let marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12810 let annotations: Vec<_> = marks
12811 .iter()
12812 .filter(|m| m.mark_type == "annotation")
12813 .collect();
12814 assert_eq!(
12815 annotations.len(),
12816 2,
12817 "emoji should have 2 annotation marks, got: {annotations:?}"
12818 );
12819 }
12820
12821 #[test]
12822 fn emoji_without_annotation_unchanged() {
12823 let doc = AdfDocument {
12825 version: 1,
12826 doc_type: "doc".to_string(),
12827 content: vec![AdfNode::paragraph(vec![AdfNode::emoji(":fire:")])],
12828 };
12829 let md = adf_to_markdown(&doc).unwrap();
12830 assert!(
12832 !md.contains('['),
12833 "emoji without annotation should not be wrapped in brackets, got: {md}"
12834 );
12835 assert!(md.contains(":fire:"));
12836 }
12837
12838 #[test]
12841 fn status_directive() {
12842 let doc = markdown_to_adf("The ticket is :status[In Progress]{color=blue}.").unwrap();
12843 let content = doc.content[0].content.as_ref().unwrap();
12844 assert_eq!(content[1].node_type, "status");
12845 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "In Progress");
12846 assert_eq!(content[1].attrs.as_ref().unwrap()["color"], "blue");
12847 }
12848
12849 #[test]
12850 fn adf_status_to_markdown() {
12851 let doc = AdfDocument {
12852 version: 1,
12853 doc_type: "doc".to_string(),
12854 content: vec![AdfNode::paragraph(vec![AdfNode::status("Done", "green")])],
12855 };
12856 let md = adf_to_markdown(&doc).unwrap();
12857 assert!(md.contains(":status[Done]{color=green}"));
12858 }
12859
12860 #[test]
12861 fn round_trip_status() {
12862 let md = "The ticket is :status[In Progress]{color=blue}.\n";
12863 let doc = markdown_to_adf(md).unwrap();
12864 let result = adf_to_markdown(&doc).unwrap();
12865 assert!(result.contains(":status[In Progress]{color=blue}"));
12866 }
12867
12868 #[test]
12869 fn status_with_style_and_localid_roundtrips() {
12870 let adf = AdfDocument {
12871 version: 1,
12872 doc_type: "doc".to_string(),
12873 content: vec![AdfNode::paragraph(vec![{
12874 let mut node = AdfNode::status("open", "green");
12875 node.attrs.as_mut().unwrap()["style"] =
12876 serde_json::Value::String("bold".to_string());
12877 node.attrs.as_mut().unwrap()["localId"] =
12878 serde_json::Value::String("d2205ca5-84b9-4950-a730-bfe550fc146b".to_string());
12879 node
12880 }])],
12881 };
12882
12883 let md = adf_to_markdown(&adf).unwrap();
12884 assert!(
12885 md.contains("style=bold"),
12886 "Markdown should contain style attr: {md}"
12887 );
12888 assert!(
12889 md.contains("localId=d2205ca5"),
12890 "Markdown should contain localId attr: {md}"
12891 );
12892
12893 let rt = markdown_to_adf(&md).unwrap();
12894 let status = &rt.content[0].content.as_ref().unwrap()[0];
12895 let attrs = status.attrs.as_ref().unwrap();
12896 assert_eq!(attrs["text"], "open");
12897 assert_eq!(attrs["color"], "green");
12898 assert_eq!(attrs["style"], "bold");
12899 assert_eq!(
12900 attrs["localId"], "d2205ca5-84b9-4950-a730-bfe550fc146b",
12901 "localId should be preserved, got: {}",
12902 attrs["localId"]
12903 );
12904 }
12905
12906 #[test]
12907 fn status_without_style_still_works() {
12908 let md = ":status[Done]{color=green}\n";
12909 let doc = markdown_to_adf(md).unwrap();
12910 let status = &doc.content[0].content.as_ref().unwrap()[0];
12911 let attrs = status.attrs.as_ref().unwrap();
12912 assert_eq!(attrs["text"], "Done");
12913 assert_eq!(attrs["color"], "green");
12914 assert!(
12916 attrs.get("style").is_none() || attrs["style"].is_null(),
12917 "style should not be set when not provided"
12918 );
12919 }
12920
12921 #[test]
12922 fn strip_local_ids_removes_localid_from_status() {
12923 let adf = AdfDocument {
12924 version: 1,
12925 doc_type: "doc".to_string(),
12926 content: vec![AdfNode::paragraph(vec![{
12927 let mut node = AdfNode::status("open", "green");
12928 node.attrs.as_mut().unwrap()["localId"] =
12929 serde_json::Value::String("real-uuid-here".to_string());
12930 node
12931 }])],
12932 };
12933 let opts = RenderOptions {
12934 strip_local_ids: true,
12935 };
12936 let md = adf_to_markdown_with_options(&adf, &opts).unwrap();
12937 assert!(
12938 !md.contains("localId"),
12939 "localId should be stripped, got: {md}"
12940 );
12941 assert!(md.contains("color=green"), "color should be preserved");
12942 }
12943
12944 #[test]
12945 fn strip_local_ids_removes_localid_from_table() {
12946 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"}]}]}]}]}]}"#;
12947 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12948 let opts = RenderOptions {
12949 strip_local_ids: true,
12950 };
12951 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12952 assert!(
12953 !md.contains("localId"),
12954 "localId should be stripped from table, got: {md}"
12955 );
12956 assert!(md.contains("layout=default"), "layout should be preserved");
12957 }
12958
12959 #[test]
12960 fn default_options_preserve_localid() {
12961 let adf = AdfDocument {
12962 version: 1,
12963 doc_type: "doc".to_string(),
12964 content: vec![AdfNode::paragraph(vec![{
12965 let mut node = AdfNode::status("open", "green");
12966 node.attrs.as_mut().unwrap()["localId"] =
12967 serde_json::Value::String("real-uuid-here".to_string());
12968 node
12969 }])],
12970 };
12971 let md = adf_to_markdown(&adf).unwrap();
12972 assert!(
12973 md.contains("localId=real-uuid-here"),
12974 "Default should preserve localId, got: {md}"
12975 );
12976 }
12977
12978 #[test]
12979 fn mention_localid_roundtrip() {
12980 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12981 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12982 let md = adf_to_markdown(&doc).unwrap();
12983 assert!(
12984 md.contains("localId=m-001"),
12985 "mention should have localId in md: {md}"
12986 );
12987 let rt = markdown_to_adf(&md).unwrap();
12988 let mention = &rt.content[0].content.as_ref().unwrap()[0];
12989 assert_eq!(mention.attrs.as_ref().unwrap()["localId"], "m-001");
12990 }
12991
12992 #[test]
12993 fn date_localid_roundtrip() {
12994 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
12995 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12996 let md = adf_to_markdown(&doc).unwrap();
12997 assert!(
12998 md.contains("localId=d-001"),
12999 "date should have localId in md: {md}"
13000 );
13001 let rt = markdown_to_adf(&md).unwrap();
13002 let date = &rt.content[0].content.as_ref().unwrap()[0];
13003 assert_eq!(date.attrs.as_ref().unwrap()["localId"], "d-001");
13004 }
13005
13006 #[test]
13007 fn emoji_localid_roundtrip() {
13008 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"emoji","attrs":{"shortName":":smile:","localId":"e-001"}}]}]}"#;
13009 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13010 let md = adf_to_markdown(&doc).unwrap();
13011 assert!(
13012 md.contains("localId=e-001"),
13013 "emoji should have localId in md: {md}"
13014 );
13015 let rt = markdown_to_adf(&md).unwrap();
13016 let emoji = &rt.content[0].content.as_ref().unwrap()[0];
13017 assert_eq!(emoji.attrs.as_ref().unwrap()["localId"], "e-001");
13018 }
13019
13020 #[test]
13021 fn inline_card_localid_roundtrip() {
13022 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"inlineCard","attrs":{"url":"https://example.com","localId":"c-001"}}]}]}"#;
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=c-001"),
13027 "inlineCard should have localId in md: {md}"
13028 );
13029 let rt = markdown_to_adf(&md).unwrap();
13030 let card = &rt.content[0].content.as_ref().unwrap()[0];
13031 assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-001");
13032 }
13033
13034 #[test]
13035 fn strip_local_ids_removes_from_mention() {
13036 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
13037 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13038 let opts = RenderOptions {
13039 strip_local_ids: true,
13040 };
13041 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13042 assert!(
13043 !md.contains("localId"),
13044 "localId should be stripped from mention: {md}"
13045 );
13046 assert!(md.contains("id=user123"), "other attrs should be preserved");
13047 }
13048
13049 #[test]
13050 fn strip_local_ids_removes_from_date() {
13051 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
13052 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13053 let opts = RenderOptions {
13054 strip_local_ids: true,
13055 };
13056 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13057 assert!(
13058 !md.contains("localId"),
13059 "localId should be stripped from date: {md}"
13060 );
13061 }
13062
13063 #[test]
13064 fn strip_local_ids_removes_from_block_attrs() {
13065 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"hello"}]}]}"#;
13066 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13067 let opts = RenderOptions {
13068 strip_local_ids: true,
13069 };
13070 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13071 assert!(
13072 !md.contains("localId"),
13073 "localId should be stripped from block attrs: {md}"
13074 );
13075 }
13076
13077 #[test]
13078 fn table_cell_localid_roundtrip() {
13079 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"}]}]}]}]}]}"#;
13080 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13081 let md = adf_to_markdown(&doc).unwrap();
13082 assert!(
13083 md.contains("localId=tc-001"),
13084 "tableCell should have localId in md: {md}"
13085 );
13086 let rt = markdown_to_adf(&md).unwrap();
13087 let cell = &rt.content[0].content.as_ref().unwrap()[0]
13088 .content
13089 .as_ref()
13090 .unwrap()[0];
13091 assert_eq!(
13092 cell.attrs.as_ref().unwrap()["localId"],
13093 "tc-001",
13094 "tableCell localId should round-trip"
13095 );
13096 }
13097
13098 #[test]
13099 fn table_cell_border_mark_roundtrip() {
13100 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"}]}]}]}]}]}"##;
13101 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13102 let md = adf_to_markdown(&doc).unwrap();
13103 assert!(
13104 md.contains("border-color=#ff000033"),
13105 "tableCell should have border-color in md: {md}"
13106 );
13107 assert!(
13108 md.contains("border-size=2"),
13109 "tableCell should have border-size in md: {md}"
13110 );
13111 let rt = markdown_to_adf(&md).unwrap();
13112 let cell = &rt.content[0].content.as_ref().unwrap()[0]
13113 .content
13114 .as_ref()
13115 .unwrap()[0];
13116 let marks = cell.marks.as_ref().expect("tableCell should have marks");
13117 assert_eq!(marks.len(), 1);
13118 assert_eq!(marks[0].mark_type, "border");
13119 let attrs = marks[0].attrs.as_ref().unwrap();
13120 assert_eq!(attrs["color"], "#ff000033");
13121 assert_eq!(attrs["size"], 2);
13122 }
13123
13124 #[test]
13125 fn table_header_border_mark_roundtrip() {
13126 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"}]}]}]}]}]}"##;
13127 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13128 let md = adf_to_markdown(&doc).unwrap();
13129 assert!(md.contains("border-color=#0000ff"), "md: {md}");
13130 assert!(md.contains("border-size=3"), "md: {md}");
13131 let rt = markdown_to_adf(&md).unwrap();
13132 let cell = &rt.content[0].content.as_ref().unwrap()[0]
13133 .content
13134 .as_ref()
13135 .unwrap()[0];
13136 assert_eq!(cell.node_type, "tableHeader");
13137 let marks = cell.marks.as_ref().expect("tableHeader should have marks");
13138 assert_eq!(marks[0].mark_type, "border");
13139 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#0000ff");
13140 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
13141 }
13142
13143 #[test]
13144 fn table_cell_border_mark_with_attrs_roundtrip() {
13145 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"}]}]}]}]}]}"##;
13146 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13147 let md = adf_to_markdown(&doc).unwrap();
13148 assert!(md.contains("bg=#e6fcff"), "md: {md}");
13149 assert!(md.contains("colspan=2"), "md: {md}");
13150 assert!(md.contains("border-color=#ff000033"), "md: {md}");
13151 let rt = markdown_to_adf(&md).unwrap();
13152 let cell = &rt.content[0].content.as_ref().unwrap()[0]
13153 .content
13154 .as_ref()
13155 .unwrap()[0];
13156 assert_eq!(cell.attrs.as_ref().unwrap()["background"], "#e6fcff");
13157 assert_eq!(cell.attrs.as_ref().unwrap()["colspan"], 2);
13158 let marks = cell.marks.as_ref().expect("should have marks");
13159 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff000033");
13160 }
13161
13162 #[test]
13163 fn table_cell_no_border_mark_unchanged() {
13164 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"}]}]}]}]}]}"#;
13165 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13166 let md = adf_to_markdown(&doc).unwrap();
13167 assert!(
13168 !md.contains("border-color"),
13169 "no border attrs expected: {md}"
13170 );
13171 let rt = markdown_to_adf(&md).unwrap();
13172 let cell = &rt.content[0].content.as_ref().unwrap()[0]
13173 .content
13174 .as_ref()
13175 .unwrap()[0];
13176 assert!(cell.marks.is_none(), "no marks expected on plain cell");
13177 }
13178
13179 #[test]
13180 fn table_cell_border_size_only_defaults_color() {
13181 let md = "::::table\n:::tr\n:::td{border-size=3}\ncell\n:::\n:::\n::::\n";
13184 let doc = markdown_to_adf(md).unwrap();
13185 let cell = &doc.content[0].content.as_ref().unwrap()[0]
13186 .content
13187 .as_ref()
13188 .unwrap()[0];
13189 let marks = cell.marks.as_ref().expect("should have border mark");
13190 assert_eq!(marks[0].mark_type, "border");
13191 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
13192 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
13193 }
13194
13195 #[test]
13196 fn table_cell_border_color_only_defaults_size() {
13197 let md = "::::table\n:::tr\n:::td{border-color=#ff0000}\ncell\n:::\n:::\n::::\n";
13199 let doc = markdown_to_adf(md).unwrap();
13200 let cell = &doc.content[0].content.as_ref().unwrap()[0]
13201 .content
13202 .as_ref()
13203 .unwrap()[0];
13204 let marks = cell.marks.as_ref().expect("should have border mark");
13205 assert_eq!(marks[0].mark_type, "border");
13206 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
13207 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
13208 }
13209
13210 #[test]
13211 fn media_file_border_mark_roundtrip() {
13212 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}}]}]}]}"##;
13213 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13214 let md = adf_to_markdown(&doc).unwrap();
13215 assert!(
13216 md.contains("border-color=#091e4224"),
13217 "media should have border-color in md: {md}"
13218 );
13219 assert!(
13220 md.contains("border-size=2"),
13221 "media should have border-size in md: {md}"
13222 );
13223 let rt = markdown_to_adf(&md).unwrap();
13224 let media_single = &rt.content[0];
13225 let media = &media_single.content.as_ref().unwrap()[0];
13226 assert_eq!(media.node_type, "media");
13227 let marks = media.marks.as_ref().expect("media should have marks");
13228 assert_eq!(marks.len(), 1);
13229 assert_eq!(marks[0].mark_type, "border");
13230 let attrs = marks[0].attrs.as_ref().unwrap();
13231 assert_eq!(attrs["color"], "#091e4224");
13232 assert_eq!(attrs["size"], 2);
13233 }
13234
13235 #[test]
13236 fn media_external_border_mark_roundtrip() {
13237 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}}]}]}]}"##;
13238 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13239 let md = adf_to_markdown(&doc).unwrap();
13240 assert!(
13241 md.contains("border-color=#ff0000"),
13242 "external media should have border-color in md: {md}"
13243 );
13244 assert!(
13245 md.contains("border-size=3"),
13246 "external media should have border-size in md: {md}"
13247 );
13248 let rt = markdown_to_adf(&md).unwrap();
13249 let media = &rt.content[0].content.as_ref().unwrap()[0];
13250 let marks = media.marks.as_ref().expect("media should have marks");
13251 assert_eq!(marks[0].mark_type, "border");
13252 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
13253 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
13254 }
13255
13256 #[test]
13257 fn media_file_no_border_mark_unchanged() {
13258 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}}]}]}"#;
13259 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13260 let md = adf_to_markdown(&doc).unwrap();
13261 assert!(
13262 !md.contains("border-color"),
13263 "no border attrs expected: {md}"
13264 );
13265 let rt = markdown_to_adf(&md).unwrap();
13266 let media = &rt.content[0].content.as_ref().unwrap()[0];
13267 assert!(media.marks.is_none(), "no marks expected on plain media");
13268 }
13269
13270 #[test]
13271 fn media_border_size_only_defaults_color() {
13272 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}}]}]}]}"#;
13273 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13274 let md = adf_to_markdown(&doc).unwrap();
13275 assert!(md.contains("border-size=4"), "md: {md}");
13276 let rt = markdown_to_adf(&md).unwrap();
13277 let media = &rt.content[0].content.as_ref().unwrap()[0];
13278 let marks = media.marks.as_ref().expect("should have border mark");
13279 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
13280 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 4);
13281 }
13282
13283 #[test]
13284 fn media_border_color_only_defaults_size() {
13285 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"}}]}]}]}"##;
13286 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13287 let md = adf_to_markdown(&doc).unwrap();
13288 assert!(md.contains("border-color=#00ff00"), "md: {md}");
13289 let rt = markdown_to_adf(&md).unwrap();
13290 let media = &rt.content[0].content.as_ref().unwrap()[0];
13291 let marks = media.marks.as_ref().expect("should have border mark");
13292 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#00ff00");
13293 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
13294 }
13295
13296 #[test]
13297 fn media_border_with_other_attrs_roundtrip() {
13298 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}}]}]}]}"##;
13299 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13300 let md = adf_to_markdown(&doc).unwrap();
13301 assert!(md.contains("layout=wide"), "md: {md}");
13302 assert!(md.contains("mediaWidth=600"), "md: {md}");
13303 assert!(md.contains("border-color=#091e4224"), "md: {md}");
13304 assert!(md.contains("border-size=2"), "md: {md}");
13305 let rt = markdown_to_adf(&md).unwrap();
13306 let ms = &rt.content[0];
13307 assert_eq!(ms.attrs.as_ref().unwrap()["layout"], "wide");
13308 let media = &ms.content.as_ref().unwrap()[0];
13309 let marks = media.marks.as_ref().expect("should have marks");
13310 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#091e4224");
13311 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 2);
13312 }
13313
13314 #[test]
13315 fn table_row_localid_roundtrip() {
13316 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"}]}]}]}]}]}"#;
13317 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13318 let md = adf_to_markdown(&doc).unwrap();
13319 assert!(
13320 md.contains("localId=tr-001"),
13321 "tableRow should have localId in md: {md}"
13322 );
13323 let rt = markdown_to_adf(&md).unwrap();
13324 let row = &rt.content[0].content.as_ref().unwrap()[0];
13325 assert_eq!(
13326 row.attrs.as_ref().unwrap()["localId"],
13327 "tr-001",
13328 "tableRow localId should round-trip"
13329 );
13330 }
13331
13332 #[test]
13333 fn list_item_localid_roundtrip() {
13334 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"}]}]}]}]}"#;
13336 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13337 let md = adf_to_markdown(&doc).unwrap();
13338 assert!(
13339 md.contains("localId=li-001"),
13340 "listItem should have localId in md: {md}"
13341 );
13342 let rt = markdown_to_adf(&md).unwrap();
13344 let list = &rt.content[0];
13345 assert!(
13346 list.attrs.is_none() || list.attrs.as_ref().unwrap().get("localId").is_none(),
13347 "bulletList should NOT have localId: {:?}",
13348 list.attrs
13349 );
13350 let item = &list.content.as_ref().unwrap()[0];
13351 assert_eq!(
13352 item.attrs.as_ref().unwrap()["localId"],
13353 "li-001",
13354 "listItem should have localId=li-001"
13355 );
13356 }
13357
13358 #[test]
13359 fn list_item_localid_not_promoted_to_parent() {
13360 let md = "- item {localId=li-002}\n";
13362 let doc = markdown_to_adf(md).unwrap();
13363 let list = &doc.content[0];
13364 assert!(
13365 list.attrs.is_none(),
13366 "bulletList should have no attrs: {:?}",
13367 list.attrs
13368 );
13369 let item = &list.content.as_ref().unwrap()[0];
13370 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "li-002");
13371 }
13372
13373 #[test]
13374 fn ordered_list_item_localid_roundtrip() {
13375 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"}]}]}]}]}"#;
13376 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13377 let md = adf_to_markdown(&doc).unwrap();
13378 assert!(md.contains("localId=oli-001"), "md: {md}");
13379 let rt = markdown_to_adf(&md).unwrap();
13380 let item = &rt.content[0].content.as_ref().unwrap()[0];
13381 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
13382 }
13383
13384 #[test]
13385 fn task_item_localid_roundtrip() {
13386 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"}]}]}]}]}"#;
13387 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13388 let md = adf_to_markdown(&doc).unwrap();
13389 assert!(md.contains("localId=ti-001"), "md: {md}");
13390 let rt = markdown_to_adf(&md).unwrap();
13391 let item = &rt.content[0].content.as_ref().unwrap()[0];
13392 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "ti-001");
13393 }
13394
13395 #[test]
13398 fn task_list_short_localid_roundtrip() {
13399 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"}]}]}]}"#;
13400 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13401 let md = adf_to_markdown(&doc).unwrap();
13402 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13404 assert!(md.contains("localId=99"), "localId=99 missing: {md}");
13405 assert!(
13407 !md.contains("localId=}"),
13408 "empty localId should not be emitted: {md}"
13409 );
13410 let rt = markdown_to_adf(&md).unwrap();
13411 let task_list = &rt.content[0];
13412 assert_eq!(task_list.node_type, "taskList");
13413 assert_eq!(rt.content.len(), 1, "should be exactly one top-level node");
13415 let items = task_list.content.as_ref().unwrap();
13416 assert_eq!(items.len(), 2);
13417 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13419 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
13420 assert!(
13421 items[0].content.is_none(),
13422 "empty taskItem should have no content: {:?}",
13423 items[0].content
13424 );
13425 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "99");
13427 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
13428 let content = items[1].content.as_ref().unwrap();
13429 assert_eq!(content.len(), 1);
13430 assert_eq!(content[0].text.as_deref(), Some("done task"));
13431 }
13432
13433 #[test]
13437 fn task_item_numeric_localid_with_hardbreak_roundtrip() {
13438 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!!)"}]}]}]}]}"#;
13439 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13440 let md = adf_to_markdown(&doc).unwrap();
13441 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13443 let rt = markdown_to_adf(&md).unwrap();
13445 assert_eq!(rt.content.len(), 1, "exactly one top-level node");
13446 let task_list = &rt.content[0];
13447 assert_eq!(task_list.node_type, "taskList");
13448 let items = task_list.content.as_ref().unwrap();
13449 assert_eq!(items.len(), 1);
13450 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13452 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
13453 let para = &items[0].content.as_ref().unwrap()[0];
13455 assert_eq!(para.node_type, "paragraph");
13456 let inlines = para.content.as_ref().unwrap();
13457 assert_eq!(inlines[0].node_type, "text");
13458 assert_eq!(
13459 inlines[0].text.as_deref(),
13460 Some("Engineering Onboarding Link")
13461 );
13462 assert_eq!(inlines[1].node_type, "hardBreak");
13463 assert_eq!(inlines[2].node_type, "text");
13464 assert_eq!(
13465 inlines[2].text.as_deref(),
13466 Some("(This has links to all the various useful tools!!)")
13467 );
13468 let rt_json = serde_json::to_string(&rt).unwrap();
13470 assert!(
13471 !rt_json.contains("{localId="),
13472 "localId attr syntax should not leak into ADF text: {rt_json}"
13473 );
13474 }
13475
13476 #[test]
13478 fn task_item_multiple_hardbreak_localids_roundtrip() {
13479 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"}]}]}]}]}"#;
13480 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13481 let md = adf_to_markdown(&doc).unwrap();
13482 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13483 assert!(md.contains("localId=67"), "localId=67 missing: {md}");
13484 let rt = markdown_to_adf(&md).unwrap();
13485 let items = rt.content[0].content.as_ref().unwrap();
13486 assert_eq!(items.len(), 2);
13487 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13488 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "67");
13489 for item in items {
13491 let para = &item.content.as_ref().unwrap()[0];
13492 assert_eq!(para.node_type, "paragraph");
13493 let inlines = para.content.as_ref().unwrap();
13494 assert_eq!(inlines[1].node_type, "hardBreak");
13495 }
13496 }
13497
13498 #[test]
13503 fn task_item_sibling_localid_hardbreak_unwrapped_roundtrip() {
13504 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"}]}]}]}"#;
13505 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13506 let md = adf_to_markdown(&doc).unwrap();
13507 assert!(
13509 md.contains(" (parenthetical"),
13510 "continuation line should be 2-space indented: {md}"
13511 );
13512 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13513 assert!(md.contains("localId=69"), "localId=69 missing: {md}");
13514 let rt = markdown_to_adf(&md).unwrap();
13515 assert_eq!(
13517 rt.content.len(),
13518 1,
13519 "should be one taskList: {:#?}",
13520 rt.content
13521 );
13522 assert_eq!(rt.content[0].node_type, "taskList");
13523 let items = rt.content[0].content.as_ref().unwrap();
13524 assert_eq!(items.len(), 2, "should have 2 taskItems");
13525 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13526 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13527 let first_content = items[0].content.as_ref().unwrap();
13529 assert!(
13530 first_content.iter().any(|n| n.node_type == "hardBreak"),
13531 "first item should contain hardBreak"
13532 );
13533 let second_content = items[1].content.as_ref().unwrap();
13535 assert_eq!(second_content[0].node_type, "text");
13536 assert_eq!(
13537 second_content[0].text.as_deref().unwrap(),
13538 "second task item"
13539 );
13540 }
13541
13542 #[test]
13545 fn task_item_sibling_localid_hardbreak_paragraph_roundtrip() {
13546 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"}]}]}]}]}"#;
13547 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13548 let md = adf_to_markdown(&doc).unwrap();
13549 let rt = markdown_to_adf(&md).unwrap();
13550 assert_eq!(
13551 rt.content.len(),
13552 1,
13553 "should be one taskList: {:#?}",
13554 rt.content
13555 );
13556 let items = rt.content[0].content.as_ref().unwrap();
13557 assert_eq!(items.len(), 2);
13558 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13559 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13560 }
13561
13562 #[test]
13565 fn task_item_three_siblings_middle_hardbreak_roundtrip() {
13566 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"}]}]}]}"#;
13567 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13568 let md = adf_to_markdown(&doc).unwrap();
13569 let rt = markdown_to_adf(&md).unwrap();
13570 assert_eq!(rt.content.len(), 1);
13571 let items = rt.content[0].content.as_ref().unwrap();
13572 assert_eq!(items.len(), 3);
13573 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "10");
13574 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "20");
13575 assert_eq!(items[2].attrs.as_ref().unwrap()["localId"], "30");
13576 let mid_content = items[1].content.as_ref().unwrap();
13578 assert!(mid_content.iter().any(|n| n.node_type == "hardBreak"));
13579 }
13580
13581 #[test]
13584 fn task_list_empty_localid_no_spurious_paragraph() {
13585 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"}]}]}]}"#;
13586 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13587 let md = adf_to_markdown(&doc).unwrap();
13588 assert!(
13589 !md.contains("{localId=}"),
13590 "empty localId should not be emitted: {md}"
13591 );
13592 let rt = markdown_to_adf(&md).unwrap();
13593 assert_eq!(
13594 rt.content.len(),
13595 1,
13596 "no spurious paragraph: {:#?}",
13597 rt.content
13598 );
13599 assert_eq!(rt.content[0].node_type, "taskList");
13600 }
13601
13602 #[test]
13604 fn task_list_localid_stripped() {
13605 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"}]}]}]}]}"#;
13606 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13607 let opts = RenderOptions {
13608 strip_local_ids: true,
13609 };
13610 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13611 assert!(!md.contains("localId"), "localId should be stripped: {md}");
13612 }
13613
13614 #[test]
13616 fn task_item_no_content_emits_localid() {
13617 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"}}]}]}"#;
13618 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13619 let md = adf_to_markdown(&doc).unwrap();
13620 assert!(
13621 md.contains("localId=abc"),
13622 "localId should be emitted even without content: {md}"
13623 );
13624 let rt = markdown_to_adf(&md).unwrap();
13625 let item = &rt.content[0].content.as_ref().unwrap()[0];
13626 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "abc");
13627 assert!(item.content.is_none(), "should have no content");
13628 }
13629
13630 #[test]
13632 fn task_list_localid_roundtrip() {
13633 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"}]}]}]}]}"#;
13634 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13635 let md = adf_to_markdown(&doc).unwrap();
13636 assert!(
13637 md.contains("localId=tl-xyz"),
13638 "taskList localId missing: {md}"
13639 );
13640 let rt = markdown_to_adf(&md).unwrap();
13641 assert_eq!(
13642 rt.content[0].attrs.as_ref().unwrap()["localId"],
13643 "tl-xyz",
13644 "taskList localId should survive round-trip"
13645 );
13646 }
13647
13648 #[test]
13650 fn task_item_paragraph_wrapper_roundtrip_no_localid() {
13651 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"}]}]}]}]}"#;
13652 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13653 let md = adf_to_markdown(&doc).unwrap();
13654 assert!(
13655 md.contains("paraLocalId=_"),
13656 "should emit paraLocalId=_ sentinel: {md}"
13657 );
13658 let rt = markdown_to_adf(&md).unwrap();
13659 let item = &rt.content[0].content.as_ref().unwrap()[0];
13660 let content = item.content.as_ref().unwrap();
13661 assert_eq!(content.len(), 1, "should have one child: {content:#?}");
13662 assert_eq!(
13663 content[0].node_type, "paragraph",
13664 "child should be a paragraph: {content:#?}"
13665 );
13666 let para_content = content[0].content.as_ref().unwrap();
13667 assert_eq!(
13668 para_content[0].text.as_deref(),
13669 Some("A task with paragraph wrapper")
13670 );
13671 assert!(
13673 content[0].attrs.is_none(),
13674 "paragraph should have no attrs: {:?}",
13675 content[0].attrs
13676 );
13677 }
13678
13679 #[test]
13681 fn task_item_paragraph_wrapper_roundtrip_with_localid() {
13682 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"}]}]}]}]}"#;
13683 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13684 let md = adf_to_markdown(&doc).unwrap();
13685 assert!(
13686 md.contains("paraLocalId=p-001"),
13687 "should emit paraLocalId=p-001: {md}"
13688 );
13689 let rt = markdown_to_adf(&md).unwrap();
13690 let item = &rt.content[0].content.as_ref().unwrap()[0];
13691 let content = item.content.as_ref().unwrap();
13692 assert_eq!(content[0].node_type, "paragraph");
13693 assert_eq!(
13694 content[0].attrs.as_ref().unwrap()["localId"],
13695 "p-001",
13696 "paragraph localId should be preserved"
13697 );
13698 }
13699
13700 #[test]
13702 fn task_item_unwrapped_inline_no_paragraph_on_roundtrip() {
13703 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"}]}]}]}"#;
13704 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13705 let md = adf_to_markdown(&doc).unwrap();
13706 assert!(
13707 !md.contains("paraLocalId"),
13708 "should NOT emit paraLocalId for unwrapped inline: {md}"
13709 );
13710 let rt = markdown_to_adf(&md).unwrap();
13711 let item = &rt.content[0].content.as_ref().unwrap()[0];
13712 let content = item.content.as_ref().unwrap();
13713 assert_eq!(
13714 content[0].node_type, "text",
13715 "should remain unwrapped: {content:#?}"
13716 );
13717 }
13718
13719 #[test]
13721 fn task_item_done_paragraph_wrapper_roundtrip() {
13722 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"}]}]}]}]}"#;
13723 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13724 let md = adf_to_markdown(&doc).unwrap();
13725 assert!(md.contains("- [x]"), "should render as done: {md}");
13726 let rt = markdown_to_adf(&md).unwrap();
13727 let item = &rt.content[0].content.as_ref().unwrap()[0];
13728 assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
13729 let content = item.content.as_ref().unwrap();
13730 assert_eq!(content[0].node_type, "paragraph");
13731 }
13732
13733 #[test]
13735 fn task_item_mixed_paragraph_and_unwrapped_roundtrip() {
13736 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"}]}]}]}"#;
13737 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13738 let md = adf_to_markdown(&doc).unwrap();
13739 let rt = markdown_to_adf(&md).unwrap();
13740 let items = rt.content[0].content.as_ref().unwrap();
13741 assert_eq!(items.len(), 2);
13742 let c1 = items[0].content.as_ref().unwrap();
13744 assert_eq!(
13745 c1[0].node_type, "paragraph",
13746 "first item should have paragraph wrapper"
13747 );
13748 let c2 = items[1].content.as_ref().unwrap();
13750 assert_eq!(
13751 c2[0].node_type, "text",
13752 "second item should remain unwrapped"
13753 );
13754 }
13755
13756 #[test]
13758 fn task_item_paragraph_wrapper_with_marks_roundtrip() {
13759 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"}]}]}]}]}]}"#;
13760 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13761 let md = adf_to_markdown(&doc).unwrap();
13762 let rt = markdown_to_adf(&md).unwrap();
13763 let item = &rt.content[0].content.as_ref().unwrap()[0];
13764 let content = item.content.as_ref().unwrap();
13765 assert_eq!(content[0].node_type, "paragraph");
13766 let para_children = content[0].content.as_ref().unwrap();
13767 assert!(
13768 para_children.len() >= 2,
13769 "paragraph should contain multiple inline nodes"
13770 );
13771 }
13772
13773 #[test]
13775 fn task_item_paragraph_wrapper_stripped_with_option() {
13776 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"}]}]}]}]}"#;
13777 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13778 let opts = RenderOptions {
13779 strip_local_ids: true,
13780 };
13781 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13782 assert!(
13783 !md.contains("paraLocalId"),
13784 "paraLocalId should be stripped: {md}"
13785 );
13786 assert!(
13787 !md.contains("localId"),
13788 "all localIds should be stripped: {md}"
13789 );
13790 }
13791
13792 #[test]
13793 fn trailing_space_preserved_with_hex_localid() {
13794 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 "}]}]}]}]}"#;
13797 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13798 let md = adf_to_markdown(&doc).unwrap();
13799 let rt = markdown_to_adf(&md).unwrap();
13800 let item = &rt.content[0].content.as_ref().unwrap()[0];
13801 assert_eq!(
13802 item.attrs.as_ref().unwrap()["localId"],
13803 "aabb112233cc",
13804 "localId should round-trip"
13805 );
13806 let para = &item.content.as_ref().unwrap()[0];
13807 let inlines = para.content.as_ref().unwrap();
13808 let last = inlines.last().unwrap();
13809 assert!(
13810 last.text.as_deref().unwrap_or("").ends_with(' '),
13811 "trailing space should be preserved, got nodes: {:?}",
13812 inlines
13813 .iter()
13814 .map(|n| (&n.node_type, &n.text))
13815 .collect::<Vec<_>>()
13816 );
13817 }
13818
13819 #[test]
13820 fn extract_trailing_local_id_preserves_trailing_space() {
13821 let (before, lid, _) = extract_trailing_local_id("trailing space {localId=aabb112233cc}");
13823 assert_eq!(before, "trailing space ");
13824 assert_eq!(lid.as_deref(), Some("aabb112233cc"));
13825 }
13826
13827 #[test]
13828 fn extract_trailing_local_id_no_trailing_space() {
13829 let (before, lid, _) = extract_trailing_local_id("text {localId=abc123}");
13830 assert_eq!(before, "text");
13831 assert_eq!(lid.as_deref(), Some("abc123"));
13832 }
13833
13834 #[test]
13835 fn extract_trailing_local_id_no_attrs() {
13836 let (before, lid, pid) = extract_trailing_local_id("plain text");
13837 assert_eq!(before, "plain text");
13838 assert!(lid.is_none());
13839 assert!(pid.is_none());
13840 }
13841
13842 #[test]
13843 fn list_item_localid_stripped() {
13844 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"}]}]}]}]}"#;
13845 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13846 let opts = RenderOptions {
13847 strip_local_ids: true,
13848 };
13849 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13850 assert!(!md.contains("localId"), "localId should be stripped: {md}");
13851 }
13852
13853 #[test]
13854 fn paragraph_localid_in_list_item_roundtrip() {
13855 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"}]}]}]}]}"#;
13857 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13858 let md = adf_to_markdown(&doc).unwrap();
13859 assert!(
13860 md.contains("paraLocalId=para-001"),
13861 "paragraph localId should be in md: {md}"
13862 );
13863 let rt = markdown_to_adf(&md).unwrap();
13864 let item = &rt.content[0].content.as_ref().unwrap()[0];
13865 assert_eq!(
13866 item.attrs.as_ref().unwrap()["localId"],
13867 "item-001",
13868 "listItem localId should survive"
13869 );
13870 let para = &item.content.as_ref().unwrap()[0];
13871 assert_eq!(
13872 para.attrs.as_ref().unwrap()["localId"],
13873 "para-001",
13874 "paragraph localId should survive round-trip"
13875 );
13876 }
13877
13878 #[test]
13879 fn paragraph_localid_in_ordered_list_item_roundtrip() {
13880 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"}]}]}]}]}"#;
13882 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13883 let md = adf_to_markdown(&doc).unwrap();
13884 assert!(md.contains("paraLocalId=op-001"), "md: {md}");
13885 let rt = markdown_to_adf(&md).unwrap();
13886 let item = &rt.content[0].content.as_ref().unwrap()[0];
13887 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
13888 let para = &item.content.as_ref().unwrap()[0];
13889 assert_eq!(para.attrs.as_ref().unwrap()["localId"], "op-001");
13890 }
13891
13892 #[test]
13893 fn paragraph_localid_only_in_list_item() {
13894 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"}]}]}]}]}"#;
13896 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13897 let md = adf_to_markdown(&doc).unwrap();
13898 assert!(
13899 md.contains("paraLocalId=para-only"),
13900 "paragraph localId should be emitted: {md}"
13901 );
13902 let rt = markdown_to_adf(&md).unwrap();
13903 let item = &rt.content[0].content.as_ref().unwrap()[0];
13904 assert!(item.attrs.is_none(), "listItem should have no attrs");
13905 let para = &item.content.as_ref().unwrap()[0];
13906 assert_eq!(para.attrs.as_ref().unwrap()["localId"], "para-only");
13907 }
13908
13909 #[test]
13910 fn paragraph_localid_in_table_header_roundtrip() {
13911 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"}]}]}]}]}]}"#;
13913 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13914 let md = adf_to_markdown(&doc).unwrap();
13915 assert!(
13917 md.contains("localId=aaaa-aaaa"),
13918 "paragraph localId should be in md: {md}"
13919 );
13920 let rt = markdown_to_adf(&md).unwrap();
13921 let cell = &rt.content[0].content.as_ref().unwrap()[0]
13922 .content
13923 .as_ref()
13924 .unwrap()[0];
13925 let para = &cell.content.as_ref().unwrap()[0];
13926 assert_eq!(
13927 para.attrs.as_ref().unwrap()["localId"],
13928 "aaaa-aaaa",
13929 "paragraph localId should survive round-trip in tableHeader"
13930 );
13931 }
13932
13933 #[test]
13934 fn paragraph_localid_in_table_cell_roundtrip() {
13935 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"}]}]}]}]}]}"#;
13937 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13938 let md = adf_to_markdown(&doc).unwrap();
13939 assert!(
13940 md.contains("localId=cell-para"),
13941 "paragraph localId should be in md: {md}"
13942 );
13943 let rt = markdown_to_adf(&md).unwrap();
13944 let cell = &rt.content[0].content.as_ref().unwrap()[1]
13946 .content
13947 .as_ref()
13948 .unwrap()[0];
13949 let para = &cell.content.as_ref().unwrap()[0];
13950 assert_eq!(
13951 para.attrs.as_ref().unwrap()["localId"],
13952 "cell-para",
13953 "paragraph localId should survive round-trip in tableCell"
13954 );
13955 }
13956
13957 #[test]
13958 fn nbsp_paragraph_with_localid_roundtrip() {
13959 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"nbsp-para"},"content":[{"type":"text","text":"\u00a0"}]}]}"#;
13961 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13962 let md = adf_to_markdown(&doc).unwrap();
13963 assert!(
13964 md.contains("::paragraph["),
13965 "nbsp should use directive form: {md}"
13966 );
13967 assert!(
13968 md.contains("localId=nbsp-para"),
13969 "localId should be in directive: {md}"
13970 );
13971 let rt = markdown_to_adf(&md).unwrap();
13972 let para = &rt.content[0];
13973 assert_eq!(
13974 para.attrs.as_ref().unwrap()["localId"],
13975 "nbsp-para",
13976 "localId should survive round-trip"
13977 );
13978 let text = para.content.as_ref().unwrap()[0].text.as_ref().unwrap();
13979 assert_eq!(text, "\u{00a0}", "nbsp should survive");
13980 }
13981
13982 #[test]
13983 fn empty_paragraph_with_localid_roundtrip() {
13984 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"empty-para"}}]}"#;
13986 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13987 let md = adf_to_markdown(&doc).unwrap();
13988 assert!(
13989 md.contains("::paragraph{localId=empty-para}"),
13990 "empty paragraph should include localId in directive: {md}"
13991 );
13992 let rt = markdown_to_adf(&md).unwrap();
13993 assert_eq!(
13994 rt.content[0].attrs.as_ref().unwrap()["localId"],
13995 "empty-para"
13996 );
13997 }
13998
13999 #[test]
14000 fn paragraph_localid_stripped_from_list_item() {
14001 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"}]}]}]}]}"#;
14003 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14004 let opts = RenderOptions {
14005 strip_local_ids: true,
14006 };
14007 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
14008 assert!(!md.contains("localId"), "localId should be stripped: {md}");
14009 assert!(
14010 !md.contains("paraLocalId"),
14011 "paraLocalId should be stripped: {md}"
14012 );
14013 }
14014
14015 #[test]
14016 fn date_directive() {
14017 let doc = markdown_to_adf("Due by :date[2026-04-15].").unwrap();
14018 let content = doc.content[0].content.as_ref().unwrap();
14019 assert_eq!(content[1].node_type, "date");
14020 assert_eq!(
14022 content[1].attrs.as_ref().unwrap()["timestamp"],
14023 "1776211200000"
14024 );
14025 }
14026
14027 #[test]
14028 fn adf_date_to_markdown() {
14029 let doc = AdfDocument {
14031 version: 1,
14032 doc_type: "doc".to_string(),
14033 content: vec![AdfNode::paragraph(vec![AdfNode::date("1776211200000")])],
14034 };
14035 let md = adf_to_markdown(&doc).unwrap();
14036 assert!(md.contains(":date[2026-04-15]{timestamp=1776211200000}"));
14037 }
14038
14039 #[test]
14040 fn adf_date_iso_passthrough() {
14041 let doc = AdfDocument {
14043 version: 1,
14044 doc_type: "doc".to_string(),
14045 content: vec![AdfNode::paragraph(vec![AdfNode::date("2026-04-15")])],
14046 };
14047 let md = adf_to_markdown(&doc).unwrap();
14048 assert!(md.contains(":date[2026-04-15]{timestamp=2026-04-15}"));
14049 }
14050
14051 #[test]
14052 fn round_trip_date() {
14053 let md = "Due by :date[2026-04-15].\n";
14054 let doc = markdown_to_adf(md).unwrap();
14055 let result = adf_to_markdown(&doc).unwrap();
14056 assert!(result.contains(":date[2026-04-15]"));
14057 }
14058
14059 #[test]
14060 fn round_trip_date_non_midnight_timestamp() {
14061 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000"}}]}]}"#;
14063 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14064 let md = adf_to_markdown(&doc).unwrap();
14065 assert!(
14067 md.contains("timestamp=1700000000000"),
14068 "JFM should preserve original timestamp: {md}"
14069 );
14070 let doc2 = markdown_to_adf(&md).unwrap();
14072 let content = doc2.content[0].content.as_ref().unwrap();
14073 assert_eq!(
14074 content[0].attrs.as_ref().unwrap()["timestamp"],
14075 "1700000000000",
14076 "Round-trip must preserve original non-midnight timestamp"
14077 );
14078 }
14079
14080 #[test]
14081 fn date_epoch_ms_passthrough() {
14082 let doc = markdown_to_adf("Due by :date[1776211200000].").unwrap();
14084 let content = doc.content[0].content.as_ref().unwrap();
14085 assert_eq!(
14086 content[1].attrs.as_ref().unwrap()["timestamp"],
14087 "1776211200000"
14088 );
14089 }
14090
14091 #[test]
14092 fn date_timestamp_attr_preferred_over_content() {
14093 let md = ":date[2023-11-14]{timestamp=1700000000000}\n";
14095 let doc = markdown_to_adf(md).unwrap();
14096 let content = doc.content[0].content.as_ref().unwrap();
14097 assert_eq!(
14098 content[0].attrs.as_ref().unwrap()["timestamp"],
14099 "1700000000000",
14100 "timestamp attr should be used directly"
14101 );
14102 }
14103
14104 #[test]
14105 fn date_without_timestamp_attr_backward_compat() {
14106 let md = ":date[2026-04-15]\n";
14108 let doc = markdown_to_adf(md).unwrap();
14109 let content = doc.content[0].content.as_ref().unwrap();
14110 assert_eq!(
14111 content[0].attrs.as_ref().unwrap()["timestamp"],
14112 "1776211200000",
14113 "Should fall back to computing timestamp from date string"
14114 );
14115 }
14116
14117 #[test]
14118 fn date_with_local_id_and_timestamp() {
14119 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
14121 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14122 let md = adf_to_markdown(&doc).unwrap();
14123 assert!(
14124 md.contains("timestamp=1700000000000"),
14125 "Should contain timestamp: {md}"
14126 );
14127 assert!(md.contains("localId=d-001"), "Should contain localId: {md}");
14128 let doc2 = markdown_to_adf(&md).unwrap();
14130 let content = doc2.content[0].content.as_ref().unwrap();
14131 let attrs = content[0].attrs.as_ref().unwrap();
14132 assert_eq!(attrs["timestamp"], "1700000000000");
14133 assert_eq!(attrs["localId"], "d-001");
14134 }
14135
14136 #[test]
14137 fn mention_directive() {
14138 let doc = markdown_to_adf("Assigned to :mention[Alice]{id=abc123}.").unwrap();
14139 let content = doc.content[0].content.as_ref().unwrap();
14140 assert_eq!(content[1].node_type, "mention");
14141 assert_eq!(content[1].attrs.as_ref().unwrap()["id"], "abc123");
14142 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "Alice");
14143 }
14144
14145 #[test]
14146 fn adf_mention_to_markdown() {
14147 let doc = AdfDocument {
14148 version: 1,
14149 doc_type: "doc".to_string(),
14150 content: vec![AdfNode::paragraph(vec![AdfNode::mention(
14151 "abc123", "Alice",
14152 )])],
14153 };
14154 let md = adf_to_markdown(&doc).unwrap();
14155 assert!(md.contains(":mention[Alice]{id=abc123}"));
14156 }
14157
14158 #[test]
14159 fn round_trip_mention() {
14160 let md = "Assigned to :mention[Alice]{id=abc123}.\n";
14161 let doc = markdown_to_adf(md).unwrap();
14162 let result = adf_to_markdown(&doc).unwrap();
14163 assert!(result.contains(":mention[Alice]{id=abc123}"));
14164 }
14165
14166 #[test]
14167 fn mention_with_empty_access_level_round_trips() {
14168 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14170 {"type":"mention","attrs":{"id":"61921b41c15977006af2b1d1","text":"@Javier Inchausti","accessLevel":""}}
14171 ]}]}"#;
14172 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14173
14174 let md = adf_to_markdown(&doc).unwrap();
14175 let round_tripped = markdown_to_adf(&md).unwrap();
14176 let mention = &round_tripped.content[0].content.as_ref().unwrap()[0];
14177 assert_eq!(
14178 mention.node_type, "mention",
14179 "mention with empty accessLevel was not parsed as mention, got: {}",
14180 mention.node_type
14181 );
14182 }
14183
14184 #[test]
14185 fn span_with_color() {
14186 let doc = markdown_to_adf("This is :span[red text]{color=#ff5630}.").unwrap();
14187 let content = doc.content[0].content.as_ref().unwrap();
14188 assert_eq!(content[1].node_type, "text");
14189 assert_eq!(content[1].text.as_deref(), Some("red text"));
14190 let marks = content[1].marks.as_ref().unwrap();
14191 assert_eq!(marks[0].mark_type, "textColor");
14192 }
14193
14194 #[test]
14195 fn adf_text_color_to_markdown() {
14196 let doc = AdfDocument {
14197 version: 1,
14198 doc_type: "doc".to_string(),
14199 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
14200 "red text",
14201 vec![AdfMark::text_color("#ff5630")],
14202 )])],
14203 };
14204 let md = adf_to_markdown(&doc).unwrap();
14205 assert!(md.contains(":span[red text]{color=#ff5630}"));
14206 }
14207
14208 #[test]
14209 fn round_trip_span_color() {
14210 let md = "This is :span[red text]{color=#ff5630}.\n";
14211 let doc = markdown_to_adf(md).unwrap();
14212 let result = adf_to_markdown(&doc).unwrap();
14213 assert!(result.contains(":span[red text]{color=#ff5630}"));
14214 }
14215
14216 #[test]
14217 fn text_color_and_link_marks_both_preserved() {
14218 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14220 {"type":"text","text":"red link","marks":[
14221 {"type":"link","attrs":{"href":"https://example.com"}},
14222 {"type":"textColor","attrs":{"color":"#ff0000"}}
14223 ]}
14224 ]}]}"##;
14225 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14226 let md = adf_to_markdown(&doc).unwrap();
14227 assert!(
14228 md.contains(":span[red link]{color=#ff0000}"),
14229 "JFM should contain span with color, got: {md}"
14230 );
14231 assert!(
14232 md.contains("](https://example.com)"),
14233 "JFM should contain link href, got: {md}"
14234 );
14235 let rt = markdown_to_adf(&md).unwrap();
14237 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
14238 let marks = text_node.marks.as_ref().expect("should have marks");
14239 assert!(
14240 marks.iter().any(|m| m.mark_type == "textColor"),
14241 "should have textColor mark, got: {:?}",
14242 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
14243 );
14244 assert!(
14245 marks.iter().any(|m| m.mark_type == "link"),
14246 "should have link mark, got: {:?}",
14247 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
14248 );
14249 let link_mark = marks.iter().find(|m| m.mark_type == "link").unwrap();
14251 assert_eq!(
14252 link_mark.attrs.as_ref().unwrap()["href"],
14253 "https://example.com"
14254 );
14255 let color_mark = marks.iter().find(|m| m.mark_type == "textColor").unwrap();
14256 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#ff0000");
14257 }
14258
14259 #[test]
14260 fn bg_color_and_link_marks_both_preserved() {
14261 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14262 {"type":"text","text":"highlighted link","marks":[
14263 {"type":"link","attrs":{"href":"https://example.com"}},
14264 {"type":"backgroundColor","attrs":{"color":"#ffff00"}}
14265 ]}
14266 ]}]}"##;
14267 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14268 let md = adf_to_markdown(&doc).unwrap();
14269 assert!(md.contains("bg=#ffff00"), "should have bg color: {md}");
14270 assert!(
14271 md.contains("](https://example.com)"),
14272 "should have link: {md}"
14273 );
14274 let rt = markdown_to_adf(&md).unwrap();
14275 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
14276 let marks = text_node.marks.as_ref().expect("should have marks");
14277 assert!(marks.iter().any(|m| m.mark_type == "backgroundColor"));
14278 assert!(marks.iter().any(|m| m.mark_type == "link"));
14279 }
14280
14281 #[test]
14282 fn text_color_link_and_strong_rendering() {
14283 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14285 {"type":"text","text":"bold red link","marks":[
14286 {"type":"strong"},
14287 {"type":"link","attrs":{"href":"https://example.com"}},
14288 {"type":"textColor","attrs":{"color":"#ff0000"}}
14289 ]}
14290 ]}]}"##;
14291 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14292 let md = adf_to_markdown(&doc).unwrap();
14293 assert!(
14294 md.starts_with("**") && md.trim().ends_with("**"),
14295 "should have bold wrapping: {md}"
14296 );
14297 assert!(md.contains("color=#ff0000"), "should have color: {md}");
14298 assert!(
14299 md.contains("](https://example.com)"),
14300 "should have link: {md}"
14301 );
14302 }
14303
14304 #[test]
14305 fn subsup_and_link_marks_both_preserved() {
14306 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14307 {"type":"text","text":"note","marks":[
14308 {"type":"link","attrs":{"href":"https://example.com"}},
14309 {"type":"subsup","attrs":{"type":"sup"}}
14310 ]}
14311 ]}]}"#;
14312 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14313 let md = adf_to_markdown(&doc).unwrap();
14314 assert!(md.contains("sup"), "should have sup: {md}");
14315 assert!(
14316 md.contains("](https://example.com)"),
14317 "should have link: {md}"
14318 );
14319 let rt = markdown_to_adf(&md).unwrap();
14320 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
14321 let marks = text_node.marks.as_ref().expect("should have marks");
14322 assert!(marks.iter().any(|m| m.mark_type == "subsup"));
14323 assert!(marks.iter().any(|m| m.mark_type == "link"));
14324 }
14325
14326 #[test]
14327 fn text_color_without_link_unchanged() {
14328 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14330 {"type":"text","text":"just red","marks":[
14331 {"type":"textColor","attrs":{"color":"#ff0000"}}
14332 ]}
14333 ]}]}"##;
14334 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14335 let md = adf_to_markdown(&doc).unwrap();
14336 assert!(md.contains(":span[just red]{color=#ff0000}"), "md: {md}");
14337 assert!(!md.contains("](http"), "should NOT have link syntax: {md}");
14338 }
14339
14340 #[test]
14341 fn inline_extension_directive() {
14342 let doc =
14343 markdown_to_adf("See :extension[fallback]{type=com.app key=widget} here.").unwrap();
14344 let content = doc.content[0].content.as_ref().unwrap();
14345 assert_eq!(content[1].node_type, "inlineExtension");
14346 assert_eq!(
14347 content[1].attrs.as_ref().unwrap()["extensionType"],
14348 "com.app"
14349 );
14350 assert_eq!(content[1].attrs.as_ref().unwrap()["extensionKey"], "widget");
14351 }
14352
14353 #[test]
14354 fn adf_inline_extension_to_markdown() {
14355 let doc = AdfDocument {
14356 version: 1,
14357 doc_type: "doc".to_string(),
14358 content: vec![AdfNode::paragraph(vec![AdfNode::inline_extension(
14359 "com.app",
14360 "widget",
14361 Some("fallback"),
14362 )])],
14363 };
14364 let md = adf_to_markdown(&doc).unwrap();
14365 assert!(md.contains(":extension[fallback]{type=com.app key=widget}"));
14366 }
14367
14368 #[test]
14371 fn parse_ordered_list_marker_valid() {
14372 let result = parse_ordered_list_marker("1. Hello");
14373 assert_eq!(result, Some((1, "Hello")));
14374 }
14375
14376 #[test]
14377 fn parse_ordered_list_marker_high_number() {
14378 let result = parse_ordered_list_marker("42. Item");
14379 assert_eq!(result, Some((42, "Item")));
14380 }
14381
14382 #[test]
14383 fn parse_ordered_list_marker_not_a_list() {
14384 assert!(parse_ordered_list_marker("not a list").is_none());
14385 assert!(parse_ordered_list_marker("1.no space").is_none());
14386 }
14387
14388 #[test]
14389 fn is_list_start_various() {
14390 assert!(is_list_start("- item"));
14391 assert!(is_list_start("* item"));
14392 assert!(is_list_start("+ item"));
14393 assert!(is_list_start("1. item"));
14394 assert!(!is_list_start("not a list"));
14395 }
14396
14397 #[test]
14398 fn is_horizontal_rule_various() {
14399 assert!(is_horizontal_rule("---"));
14400 assert!(is_horizontal_rule("***"));
14401 assert!(is_horizontal_rule("___"));
14402 assert!(is_horizontal_rule("------"));
14403 assert!(!is_horizontal_rule("--"));
14404 assert!(!is_horizontal_rule("abc"));
14405 }
14406
14407 #[test]
14408 fn is_table_separator_valid() {
14409 assert!(is_table_separator("| --- | --- |"));
14410 assert!(is_table_separator("|:---:|:---|"));
14411 assert!(!is_table_separator("no pipes here"));
14412 }
14413
14414 #[test]
14415 fn parse_table_row_cells() {
14416 let cells = parse_table_row("| A | B | C |");
14417 assert_eq!(cells, vec!["A", "B", "C"]);
14418 }
14419
14420 #[test]
14421 fn parse_table_row_escaped_pipe_in_cell() {
14422 let cells = parse_table_row(r"| a\|b | c |");
14424 assert_eq!(cells, vec!["a|b", "c"]);
14425 }
14426
14427 #[test]
14428 fn parse_table_row_escaped_pipe_in_code_span() {
14429 let cells = parse_table_row(r"| `parser.decode[T\|json]` | other |");
14431 assert_eq!(cells, vec!["`parser.decode[T|json]`", "other"]);
14432 }
14433
14434 #[test]
14435 fn parse_table_row_preserves_other_backslashes() {
14436 let cells = parse_table_row(r"| a\\b | c\*d |");
14438 assert_eq!(cells, vec![r"a\\b", r"c\*d"]);
14439 }
14440
14441 #[test]
14442 fn parse_image_syntax_valid() {
14443 let result = parse_image_syntax("");
14444 assert_eq!(result, Some(("alt", "url")));
14445 }
14446
14447 #[test]
14448 fn parse_image_syntax_not_image() {
14449 assert!(parse_image_syntax("not an image").is_none());
14450 }
14451
14452 #[test]
14455 fn find_closing_paren_simple() {
14456 assert_eq!(find_closing_paren("(hello)", 0), Some(6));
14457 }
14458
14459 #[test]
14460 fn find_closing_paren_nested() {
14461 assert_eq!(find_closing_paren("(a(b)c)", 0), Some(6));
14462 }
14463
14464 #[test]
14465 fn find_closing_paren_unmatched() {
14466 assert_eq!(find_closing_paren("(no close", 0), None);
14467 }
14468
14469 #[test]
14470 fn find_closing_paren_offset() {
14471 assert_eq!(find_closing_paren("xx(inner)", 2), Some(8));
14473 }
14474
14475 #[test]
14478 fn try_parse_link_url_with_parens() {
14479 let input = "[here](https://example.com/faq#access-(permissions)-rest)";
14480 let result = try_parse_link(input, 0);
14481 assert_eq!(
14482 result,
14483 Some((
14484 input.len(),
14485 "here",
14486 "https://example.com/faq#access-(permissions)-rest"
14487 ))
14488 );
14489 }
14490
14491 #[test]
14492 fn try_parse_link_url_no_parens() {
14493 let input = "[text](https://example.com)";
14494 let result = try_parse_link(input, 0);
14495 assert_eq!(result, Some((input.len(), "text", "https://example.com")));
14496 }
14497
14498 #[test]
14499 fn try_parse_link_url_with_multiple_nested_parens() {
14500 let input = "[x](http://en.wikipedia.org/wiki/Foo_(bar_(baz)))";
14501 let result = try_parse_link(input, 0);
14502 assert_eq!(
14503 result,
14504 Some((
14505 input.len(),
14506 "x",
14507 "http://en.wikipedia.org/wiki/Foo_(bar_(baz))"
14508 ))
14509 );
14510 }
14511
14512 #[test]
14513 fn parse_image_syntax_url_with_parens() {
14514 let result = parse_image_syntax(")");
14515 assert_eq!(result, Some(("alt", "https://example.com/page_(1)")));
14516 }
14517
14518 #[test]
14519 fn parse_image_syntax_url_no_parens() {
14520 let result = parse_image_syntax("");
14521 assert_eq!(result, Some(("alt", "https://example.com")));
14522 }
14523
14524 #[test]
14525 fn link_with_parens_round_trip() {
14526 let href = "https://example.com/faq#I-need-access-(permissions)-added-in-Monitor";
14527 let mut text_node = AdfNode::text("here");
14528 text_node.marks = Some(vec![AdfMark::link(href)]);
14529 let adf_input = AdfDocument {
14530 version: 1,
14531 doc_type: "doc".to_string(),
14532 content: vec![AdfNode::paragraph(vec![text_node])],
14533 };
14534
14535 let jfm = adf_to_markdown(&adf_input).unwrap();
14536 let adf_output = markdown_to_adf(&jfm).unwrap();
14537
14538 let para = &adf_output.content[0];
14540 let text_node = ¶.content.as_ref().unwrap()[0];
14541 let mark = &text_node.marks.as_ref().unwrap()[0];
14542 let result_href = mark.attrs.as_ref().unwrap()["href"].as_str().unwrap();
14543
14544 assert_eq!(result_href, href);
14545 }
14546
14547 #[test]
14548 fn flush_plain_empty_range() {
14549 let mut nodes = Vec::new();
14550 flush_plain("hello", 3, 3, &mut nodes);
14551 assert!(nodes.is_empty());
14552 }
14553
14554 #[test]
14555 fn add_mark_to_unmarked_node() {
14556 let mut node = AdfNode::text("test");
14557 add_mark(&mut node, AdfMark::strong());
14558 assert_eq!(node.marks.as_ref().unwrap().len(), 1);
14559 }
14560
14561 #[test]
14562 fn add_mark_to_marked_node() {
14563 let mut node = AdfNode::text_with_marks("test", vec![AdfMark::strong()]);
14564 add_mark(&mut node, AdfMark::em());
14565 assert_eq!(node.marks.as_ref().unwrap().len(), 2);
14566 }
14567
14568 #[test]
14571 fn directive_table_basic() {
14572 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";
14573 let doc = markdown_to_adf(md).unwrap();
14574 assert_eq!(doc.content[0].node_type, "table");
14575 let rows = doc.content[0].content.as_ref().unwrap();
14576 assert_eq!(rows.len(), 2);
14577 assert_eq!(
14578 rows[0].content.as_ref().unwrap()[0].node_type,
14579 "tableHeader"
14580 );
14581 assert_eq!(rows[1].content.as_ref().unwrap()[0].node_type, "tableCell");
14582 }
14583
14584 #[test]
14585 fn directive_table_with_block_content() {
14586 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";
14587 let doc = markdown_to_adf(md).unwrap();
14588 let rows = doc.content[0].content.as_ref().unwrap();
14589 let cell = &rows[0].content.as_ref().unwrap()[0];
14590 let content = cell.content.as_ref().unwrap();
14592 assert!(content.len() >= 2);
14593 assert_eq!(content[1].node_type, "bulletList");
14594 }
14595
14596 #[test]
14597 fn directive_table_with_cell_attrs() {
14598 let md = "::::table\n:::tr\n:::td{colspan=2 bg=#DEEBFF}\nSpanning cell\n:::\n:::\n::::\n";
14599 let doc = markdown_to_adf(md).unwrap();
14600 let cell = &doc.content[0].content.as_ref().unwrap()[0]
14601 .content
14602 .as_ref()
14603 .unwrap()[0];
14604 let attrs = cell.attrs.as_ref().unwrap();
14605 assert_eq!(attrs["colspan"], 2);
14606 assert_eq!(attrs["background"], "#DEEBFF");
14607 }
14608
14609 #[test]
14610 fn directive_table_with_css_var_background() {
14611 let bg = "var(--ds-background-accent-gray-subtlest, var(--ds-background-accent-gray-subtlest, #F1F2F4))";
14612 let md = format!("::::table\n:::tr\n:::th{{bg=\"{bg}\"}}\nHeader\n:::\n:::\n::::\n");
14613 let doc = markdown_to_adf(&md).unwrap();
14614 let row = &doc.content[0].content.as_ref().unwrap()[0];
14615 let cells = row.content.as_ref().unwrap();
14616 assert_eq!(cells.len(), 1, "row must have at least one cell");
14617 let attrs = cells[0].attrs.as_ref().unwrap();
14618 assert_eq!(attrs["background"], bg);
14619 }
14620
14621 #[test]
14622 fn css_var_background_round_trips() {
14623 let bg = "var(--ds-background-accent-gray-subtlest, #F1F2F4)";
14624 let adf = AdfDocument {
14625 version: 1,
14626 doc_type: "doc".to_string(),
14627 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14628 AdfNode::table_header_with_attrs(
14629 vec![AdfNode::paragraph(vec![AdfNode::text("Header")])],
14630 serde_json::json!({"background": bg}),
14631 ),
14632 ])])],
14633 };
14634 let md = adf_to_markdown(&adf).unwrap();
14635 assert!(
14636 md.contains(&format!("bg=\"{bg}\"")),
14637 "bg value must be quoted in markdown: {md}"
14638 );
14639
14640 let round_tripped = markdown_to_adf(&md).unwrap();
14641 let row = &round_tripped.content[0].content.as_ref().unwrap()[0];
14642 let cells = row.content.as_ref().unwrap();
14643 assert_eq!(cells.len(), 1, "round-tripped row must have one cell");
14644 let rt_attrs = cells[0].attrs.as_ref().unwrap();
14645 assert_eq!(rt_attrs["background"], bg);
14646 }
14647
14648 #[test]
14649 fn directive_table_with_table_attrs() {
14650 let md = "::::table{layout=wide numbered}\n:::tr\n:::td\nCell\n:::\n:::\n::::\n";
14651 let doc = markdown_to_adf(md).unwrap();
14652 let attrs = doc.content[0].attrs.as_ref().unwrap();
14653 assert_eq!(attrs["layout"], "wide");
14654 assert_eq!(attrs["isNumberColumnEnabled"], true);
14655 }
14656
14657 #[test]
14658 fn adf_table_with_block_content_renders_directive_form() {
14659 let doc = AdfDocument {
14661 version: 1,
14662 doc_type: "doc".to_string(),
14663 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14664 AdfNode::table_cell(vec![
14665 AdfNode::paragraph(vec![AdfNode::text("Cell with list:")]),
14666 AdfNode::bullet_list(vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
14667 AdfNode::text("Item 1"),
14668 ])])]),
14669 ]),
14670 ])])],
14671 };
14672 let md = adf_to_markdown(&doc).unwrap();
14673 assert!(md.contains("::::table"));
14674 assert!(md.contains(":::td"));
14675 assert!(md.contains("- Item 1"));
14676 }
14677
14678 #[test]
14679 fn adf_table_inline_only_renders_pipe_form() {
14680 let doc = AdfDocument {
14682 version: 1,
14683 doc_type: "doc".to_string(),
14684 content: vec![AdfNode::table(vec![
14685 AdfNode::table_row(vec![
14686 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
14687 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14688 ]),
14689 AdfNode::table_row(vec![
14690 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
14691 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14692 ]),
14693 ])],
14694 };
14695 let md = adf_to_markdown(&doc).unwrap();
14696 assert!(md.contains("| H1 | H2 |"));
14697 assert!(!md.contains("::::table"));
14698 }
14699
14700 #[test]
14701 fn adf_table_header_outside_first_row_renders_directive() {
14702 let doc = AdfDocument {
14703 version: 1,
14704 doc_type: "doc".to_string(),
14705 content: vec![AdfNode::table(vec![
14706 AdfNode::table_row(vec![
14707 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H")])]),
14708 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C")])]),
14709 ]),
14710 AdfNode::table_row(vec![
14711 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14712 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14713 ]),
14714 ])],
14715 };
14716 let md = adf_to_markdown(&doc).unwrap();
14717 assert!(md.contains("::::table"));
14718 assert!(md.contains(":::th"));
14719 }
14720
14721 #[test]
14722 fn adf_table_cell_attrs_rendered() {
14723 let doc = AdfDocument {
14724 version: 1,
14725 doc_type: "doc".to_string(),
14726 content: vec![AdfNode::table(vec![
14727 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
14728 AdfNode::text("H"),
14729 ])])]),
14730 AdfNode::table_row(vec![AdfNode::table_cell_with_attrs(
14731 vec![AdfNode::paragraph(vec![AdfNode::text("C")])],
14732 serde_json::json!({"background": "#DEEBFF", "colspan": 2}),
14733 )]),
14734 ])],
14735 };
14736 let md = adf_to_markdown(&doc).unwrap();
14737 assert!(md.contains("{colspan=2 bg=#DEEBFF}"));
14738 }
14739
14740 #[test]
14743 fn pipe_table_cell_attrs() {
14744 let md = "| H1 | H2 |\n|---|---|\n| {bg=#DEEBFF} highlighted | normal |\n";
14745 let doc = markdown_to_adf(md).unwrap();
14746 let rows = doc.content[0].content.as_ref().unwrap();
14747 let cell = &rows[1].content.as_ref().unwrap()[0];
14748 let attrs = cell.attrs.as_ref().unwrap();
14749 assert_eq!(attrs["background"], "#DEEBFF");
14750 }
14751
14752 #[test]
14753 fn pipe_table_cell_colspan() {
14754 let md = "| H1 | H2 |\n|---|---|\n| {colspan=2} spanning |\n";
14755 let doc = markdown_to_adf(md).unwrap();
14756 let rows = doc.content[0].content.as_ref().unwrap();
14757 let cell = &rows[1].content.as_ref().unwrap()[0];
14758 let attrs = cell.attrs.as_ref().unwrap();
14759 assert_eq!(attrs["colspan"], 2);
14760 }
14761
14762 #[test]
14763 fn trailing_space_after_mention_in_table_cell_preserved() {
14764 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":[
14766 {"type":"mention","attrs":{"id":"aaa","text":"@Rob"}},
14767 {"type":"text","text":" "}
14768 ]}]}]}]}]}"#;
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 para = &cell.content.as_ref().unwrap()[0];
14777 let inlines = para.content.as_ref().unwrap();
14778 assert!(
14779 inlines.len() >= 2,
14780 "expected mention + text(' ') nodes, got {} nodes: {:?}",
14781 inlines.len(),
14782 inlines.iter().map(|n| &n.node_type).collect::<Vec<_>>()
14783 );
14784 assert_eq!(inlines[0].node_type, "mention");
14785 assert_eq!(inlines[1].node_type, "text");
14786 assert_eq!(inlines[1].text.as_deref(), Some(" "));
14787 }
14788
14789 #[test]
14792 fn pipe_table_column_alignment() {
14793 let md = "| Left | Center | Right |\n|:---|:---:|---:|\n| L | C | R |\n";
14794 let doc = markdown_to_adf(md).unwrap();
14795 let rows = doc.content[0].content.as_ref().unwrap();
14796 let h_cells = rows[0].content.as_ref().unwrap();
14798 assert!(h_cells[0].content.as_ref().unwrap()[0].marks.is_none());
14800 let center_marks = h_cells[1].content.as_ref().unwrap()[0]
14802 .marks
14803 .as_ref()
14804 .unwrap();
14805 assert_eq!(center_marks[0].attrs.as_ref().unwrap()["align"], "center");
14806 let right_marks = h_cells[2].content.as_ref().unwrap()[0]
14808 .marks
14809 .as_ref()
14810 .unwrap();
14811 assert_eq!(right_marks[0].attrs.as_ref().unwrap()["align"], "end");
14812 }
14813
14814 #[test]
14815 fn adf_table_alignment_roundtrip() {
14816 let doc = AdfDocument {
14817 version: 1,
14818 doc_type: "doc".to_string(),
14819 content: vec![AdfNode::table(vec![
14820 AdfNode::table_row(vec![
14821 AdfNode::table_header(vec![{
14822 let mut p = AdfNode::paragraph(vec![AdfNode::text("Center")]);
14823 p.marks = Some(vec![AdfMark::alignment("center")]);
14824 p
14825 }]),
14826 AdfNode::table_header(vec![{
14827 let mut p = AdfNode::paragraph(vec![AdfNode::text("Right")]);
14828 p.marks = Some(vec![AdfMark::alignment("end")]);
14829 p
14830 }]),
14831 ]),
14832 AdfNode::table_row(vec![
14833 AdfNode::table_cell(vec![{
14834 let mut p = AdfNode::paragraph(vec![AdfNode::text("C")]);
14835 p.marks = Some(vec![AdfMark::alignment("center")]);
14836 p
14837 }]),
14838 AdfNode::table_cell(vec![{
14839 let mut p = AdfNode::paragraph(vec![AdfNode::text("R")]);
14840 p.marks = Some(vec![AdfMark::alignment("end")]);
14841 p
14842 }]),
14843 ]),
14844 ])],
14845 };
14846 let md = adf_to_markdown(&doc).unwrap();
14847 assert!(md.contains(":---:"));
14848 assert!(md.contains("---:"));
14849 }
14850
14851 #[test]
14854 fn panel_custom_attrs_round_trip() {
14855 let md = ":::panel{type=custom icon=\":star:\" color=\"#DEEBFF\"}\nContent\n:::\n";
14856 let doc = markdown_to_adf(md).unwrap();
14857 let panel = &doc.content[0];
14858 let attrs = panel.attrs.as_ref().unwrap();
14859 assert_eq!(attrs["panelType"], "custom");
14860 assert_eq!(attrs["panelIcon"], ":star:");
14861 assert_eq!(attrs["panelColor"], "#DEEBFF");
14862
14863 let result = adf_to_markdown(&doc).unwrap();
14864 assert!(result.contains("type=custom"));
14865 assert!(result.contains("icon="));
14866 assert!(result.contains("color="));
14867 }
14868
14869 #[test]
14872 fn block_card_with_layout() {
14873 let md = "::card[https://example.com]{layout=wide}\n";
14874 let doc = markdown_to_adf(md).unwrap();
14875 let attrs = doc.content[0].attrs.as_ref().unwrap();
14876 assert_eq!(attrs["layout"], "wide");
14877
14878 let result = adf_to_markdown(&doc).unwrap();
14879 assert!(result.contains("::card[https://example.com]{layout=wide}"));
14880 }
14881
14882 #[test]
14885 fn extension_with_params() {
14886 let md = r#"::extension{type=com.atlassian.macro key=jira-chart params='{"jql":"project=PROJ"}'}"#;
14887 let doc = markdown_to_adf(&format!("{md}\n")).unwrap();
14888 let attrs = doc.content[0].attrs.as_ref().unwrap();
14889 assert_eq!(attrs["parameters"]["jql"], "project=PROJ");
14890 }
14891
14892 #[test]
14893 fn leaf_extension_layout_preserved_in_roundtrip() {
14894 let adf_json = r#"{"version":1,"type":"doc","content":[
14896 {"type":"extension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"toc","layout":"default","parameters":{}}}
14897 ]}"#;
14898 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14899 let md = adf_to_markdown(&doc).unwrap();
14900 assert!(
14901 md.contains("layout=default"),
14902 "JFM should contain layout=default, got: {md}"
14903 );
14904 let round_tripped = markdown_to_adf(&md).unwrap();
14905 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14906 assert_eq!(attrs["layout"], "default", "layout should be preserved");
14907 assert_eq!(attrs["extensionKey"], "toc");
14908 }
14909
14910 #[test]
14911 fn bodied_extension_layout_preserved_in_roundtrip() {
14912 let adf_json = r#"{"version":1,"type":"doc","content":[
14914 {"type":"bodiedExtension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"expand","layout":"wide"},
14915 "content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}
14916 ]}"#;
14917 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14918 let md = adf_to_markdown(&doc).unwrap();
14919 assert!(
14920 md.contains("layout=wide"),
14921 "JFM should contain layout=wide, got: {md}"
14922 );
14923 let round_tripped = markdown_to_adf(&md).unwrap();
14924 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14925 assert_eq!(attrs["layout"], "wide", "layout should be preserved");
14926 }
14927
14928 #[test]
14929 fn bodied_extension_parameters_preserved_in_roundtrip() {
14930 let adf_json = r#"{"version":1,"type":"doc","content":[
14932 {"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":{}}},
14933 "content":[{"type":"paragraph","content":[{"type":"text","text":"Content inside bodied extension"}]}]}
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("params="),
14939 "JFM should contain params attribute, 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!(
14944 attrs["parameters"]["macroMetadata"]["title"], "Page Properties",
14945 "parameters should be preserved in round-trip"
14946 );
14947 assert_eq!(attrs["extensionKey"], "details");
14948 assert_eq!(attrs["layout"], "default");
14949 assert_eq!(attrs["localId"], "aabbccdd-1234");
14950 }
14951
14952 #[test]
14953 fn bodied_extension_malformed_params_ignored() {
14954 let md = ":::extension{type=com.atlassian.macro key=details params='not-valid-json'}\nContent\n:::\n";
14956 let doc = markdown_to_adf(md).unwrap();
14957 let attrs = doc.content[0].attrs.as_ref().unwrap();
14958 assert_eq!(attrs["extensionKey"], "details");
14959 assert!(attrs.get("parameters").is_none());
14961 }
14962
14963 #[test]
14964 fn leaf_extension_localid_preserved_in_roundtrip() {
14965 let adf_json = r#"{"version":1,"type":"doc","content":[
14967 {"type":"extension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"toc","layout":"default","localId":"abc-123"}}
14968 ]}"#;
14969 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14970 let md = adf_to_markdown(&doc).unwrap();
14971 let round_tripped = markdown_to_adf(&md).unwrap();
14972 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14973 assert_eq!(attrs["layout"], "default");
14974 assert_eq!(attrs["localId"], "abc-123");
14975 }
14976
14977 #[test]
14980 fn mention_with_user_type() {
14981 let md = "Hi :mention[Alice]{id=abc123 userType=DEFAULT}.\n";
14982 let doc = markdown_to_adf(md).unwrap();
14983 let mention = &doc.content[0].content.as_ref().unwrap()[1];
14984 assert_eq!(mention.attrs.as_ref().unwrap()["userType"], "DEFAULT");
14985
14986 let result = adf_to_markdown(&doc).unwrap();
14987 assert!(result.contains("userType=DEFAULT"));
14988 }
14989
14990 #[test]
14993 fn directive_table_colwidth() {
14994 let md = "::::table\n:::tr\n:::td{colwidth=100,200}\nCell\n:::\n:::\n::::\n";
14995 let doc = markdown_to_adf(md).unwrap();
14996 let cell = &doc.content[0].content.as_ref().unwrap()[0]
14997 .content
14998 .as_ref()
14999 .unwrap()[0];
15000 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
15001 assert_eq!(colwidth, &[serde_json::json!(100), serde_json::json!(200)]);
15002 }
15003
15004 #[test]
15005 fn directive_table_colwidth_float_roundtrip() {
15006 let adf_doc = serde_json::json!({
15009 "type": "doc",
15010 "version": 1,
15011 "content": [{
15012 "type": "table",
15013 "content": [{
15014 "type": "tableRow",
15015 "content": [
15016 {
15017 "type": "tableHeader",
15018 "attrs": { "colwidth": [157.0] },
15019 "content": [{ "type": "paragraph" }]
15020 },
15021 {
15022 "type": "tableHeader",
15023 "attrs": { "colwidth": [863.0] },
15024 "content": [{ "type": "paragraph" }]
15025 }
15026 ]
15027 }]
15028 }]
15029 });
15030 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15031 let md = adf_to_markdown(&doc).unwrap();
15032 assert!(
15033 md.contains("colwidth=157.0"),
15034 "expected colwidth=157.0 in markdown, got: {md}"
15035 );
15036 assert!(
15037 md.contains("colwidth=863.0"),
15038 "expected colwidth=863.0 in markdown, got: {md}"
15039 );
15040 let doc2 = markdown_to_adf(&md).unwrap();
15042 let row = &doc2.content[0].content.as_ref().unwrap()[0];
15043 let header1 = &row.content.as_ref().unwrap()[0];
15044 let header2 = &row.content.as_ref().unwrap()[1];
15045 assert_eq!(
15046 header1.attrs.as_ref().unwrap()["colwidth"]
15047 .as_array()
15048 .unwrap(),
15049 &[serde_json::json!(157.0)]
15050 );
15051 assert_eq!(
15052 header2.attrs.as_ref().unwrap()["colwidth"]
15053 .as_array()
15054 .unwrap(),
15055 &[serde_json::json!(863.0)]
15056 );
15057 }
15058
15059 #[test]
15060 fn colwidth_float_preserved_in_roundtrip() {
15061 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":[]}]}]}]}]}"#;
15063 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15064 let md = adf_to_markdown(&doc).unwrap();
15065 let round_tripped = markdown_to_adf(&md).unwrap();
15066 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
15067 .content
15068 .as_ref()
15069 .unwrap()[0];
15070 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
15071 assert_eq!(
15072 colwidth,
15073 &[serde_json::json!(254.0), serde_json::json!(416.0)],
15074 "colwidth should preserve float values"
15075 );
15076 }
15077
15078 #[test]
15079 fn colwidth_integer_preserved_in_roundtrip() {
15080 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"}]}]}]}]}]}"#;
15082 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15083 let md = adf_to_markdown(&doc).unwrap();
15084 assert!(
15085 md.contains("colwidth=150"),
15086 "expected colwidth=150 (no decimal) in markdown, got: {md}"
15087 );
15088 assert!(
15089 !md.contains("colwidth=150.0"),
15090 "colwidth should not have .0 suffix for integers, got: {md}"
15091 );
15092 let round_tripped = markdown_to_adf(&md).unwrap();
15094 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
15095 .content
15096 .as_ref()
15097 .unwrap()[0];
15098 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
15099 assert_eq!(
15100 colwidth,
15101 &[serde_json::json!(150)],
15102 "colwidth should preserve integer values"
15103 );
15104 let json_output = serde_json::to_string(&round_tripped).unwrap();
15106 assert!(
15107 json_output.contains(r#""colwidth":[150]"#),
15108 "JSON should contain integer colwidth, got: {json_output}"
15109 );
15110 }
15111
15112 #[test]
15113 fn colwidth_mixed_int_and_float_roundtrip() {
15114 let int_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100,200]}}]}]}]}"#;
15117 let float_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100.0,200.0]}}]}]}]}"#;
15118
15119 let int_doc: AdfDocument = serde_json::from_str(int_json).unwrap();
15121 let int_md = adf_to_markdown(&int_doc).unwrap();
15122 assert!(
15123 int_md.contains("colwidth=100,200"),
15124 "integer colwidth in md: {int_md}"
15125 );
15126 let int_rt = markdown_to_adf(&int_md).unwrap();
15127 let int_serial = serde_json::to_string(&int_rt).unwrap();
15128 assert!(
15129 int_serial.contains(r#""colwidth":[100,200]"#),
15130 "integer colwidth in JSON: {int_serial}"
15131 );
15132
15133 let float_doc: AdfDocument = serde_json::from_str(float_json).unwrap();
15135 let float_md = adf_to_markdown(&float_doc).unwrap();
15136 assert!(
15137 float_md.contains("colwidth=100.0,200.0"),
15138 "float colwidth in md: {float_md}"
15139 );
15140 let float_rt = markdown_to_adf(&float_md).unwrap();
15141 let float_serial = serde_json::to_string(&float_rt).unwrap();
15142 assert!(
15143 float_serial.contains(r#""colwidth":[100.0,200.0]"#),
15144 "float colwidth in JSON: {float_serial}"
15145 );
15146 }
15147
15148 #[test]
15149 fn colwidth_fractional_float_preserved() {
15150 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"}]}]}]}]}]}"#;
15152 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15153 let md = adf_to_markdown(&doc).unwrap();
15154 assert!(
15155 md.contains("colwidth=100.5"),
15156 "expected colwidth=100.5 in markdown, got: {md}"
15157 );
15158 }
15159
15160 #[test]
15161 fn colwidth_non_numeric_values_skipped() {
15162 let adf_doc = serde_json::json!({
15164 "type": "doc",
15165 "version": 1,
15166 "content": [{
15167 "type": "table",
15168 "content": [{
15169 "type": "tableRow",
15170 "content": [{
15171 "type": "tableCell",
15172 "attrs": { "colwidth": ["invalid"] },
15173 "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "cell" }] }]
15174 }]
15175 }]
15176 }]
15177 });
15178 let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
15179 let md = adf_to_markdown(&doc).unwrap();
15180 assert!(
15182 !md.contains("colwidth"),
15183 "non-numeric colwidth should be filtered out, got: {md}"
15184 );
15185 }
15186
15187 #[test]
15188 fn default_rowspan_colspan_preserved_in_roundtrip() {
15189 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"}]}]}]}]}]}"#;
15191 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15192 let md = adf_to_markdown(&doc).unwrap();
15193 let round_tripped = markdown_to_adf(&md).unwrap();
15194 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
15195 .content
15196 .as_ref()
15197 .unwrap()[0];
15198 let attrs = cell.attrs.as_ref().unwrap();
15199 assert_eq!(attrs["rowspan"], 1, "rowspan=1 should be preserved");
15200 assert_eq!(attrs["colspan"], 1, "colspan=1 should be preserved");
15201 }
15202
15203 #[test]
15206 fn table_localid_preserved_in_roundtrip() {
15207 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"}]}]}]}]}]}"#;
15209 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15210 let md = adf_to_markdown(&doc).unwrap();
15211 assert!(
15212 md.contains("localId="),
15213 "JFM should contain localId, got: {md}"
15214 );
15215 let round_tripped = markdown_to_adf(&md).unwrap();
15216 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15217 assert_eq!(
15218 attrs["localId"], "7afd4550-e66c-4b12-875f-a91c6c7b62c7",
15219 "localId should be preserved"
15220 );
15221 }
15222
15223 #[test]
15224 fn paragraph_localid_preserved_in_roundtrip() {
15225 let adf_json = r#"{"version":1,"type":"doc","content":[
15227 {"type":"paragraph","attrs":{"localId":"abc-123"},"content":[{"type":"text","text":"hello"}]}
15228 ]}"#;
15229 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15230 let md = adf_to_markdown(&doc).unwrap();
15231 assert!(
15232 md.contains("localId=abc-123"),
15233 "JFM should contain localId, got: {md}"
15234 );
15235 let round_tripped = markdown_to_adf(&md).unwrap();
15236 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15237 assert_eq!(attrs["localId"], "abc-123", "localId should be preserved");
15238 }
15239
15240 #[test]
15241 fn heading_localid_preserved_in_roundtrip() {
15242 let adf_json = r#"{"version":1,"type":"doc","content":[
15243 {"type":"heading","attrs":{"level":2,"localId":"h-456"},"content":[{"type":"text","text":"Title"}]}
15244 ]}"#;
15245 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15246 let md = adf_to_markdown(&doc).unwrap();
15247 let round_tripped = markdown_to_adf(&md).unwrap();
15248 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15249 assert_eq!(attrs["localId"], "h-456");
15250 }
15251
15252 #[test]
15253 fn localid_with_alignment_preserved() {
15254 let adf_json = r#"{"version":1,"type":"doc","content":[
15256 {"type":"paragraph","attrs":{"localId":"p-789"},"marks":[{"type":"alignment","attrs":{"align":"center"}}],
15257 "content":[{"type":"text","text":"centered"}]}
15258 ]}"#;
15259 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15260 let md = adf_to_markdown(&doc).unwrap();
15261 assert!(md.contains("localId=p-789"), "should have localId: {md}");
15262 assert!(md.contains("align=center"), "should have align: {md}");
15263 let round_tripped = markdown_to_adf(&md).unwrap();
15264 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15265 assert_eq!(attrs["localId"], "p-789");
15266 let marks = round_tripped.content[0].marks.as_ref().unwrap();
15267 assert!(marks.iter().any(|m| m.mark_type == "alignment"));
15268 }
15269
15270 #[test]
15271 fn table_layout_default_preserved_in_roundtrip() {
15272 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"}]}]}]}]}]}"#;
15274 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15275 let md = adf_to_markdown(&doc).unwrap();
15276 let round_tripped = markdown_to_adf(&md).unwrap();
15277 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15278 assert_eq!(
15279 attrs["layout"], "default",
15280 "layout='default' should be preserved"
15281 );
15282 }
15283
15284 #[test]
15285 fn table_is_number_column_enabled_false_preserved() {
15286 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"}]}]}]}]}]}"#;
15288 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15289 let md = adf_to_markdown(&doc).unwrap();
15290 let round_tripped = markdown_to_adf(&md).unwrap();
15291 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15292 assert_eq!(
15293 attrs["isNumberColumnEnabled"], false,
15294 "isNumberColumnEnabled=false should be preserved"
15295 );
15296 }
15297
15298 #[test]
15299 fn table_is_number_column_enabled_true_preserved() {
15300 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"}]}]}]}]}]}"#;
15302 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15303 let md = adf_to_markdown(&doc).unwrap();
15304 let round_tripped = markdown_to_adf(&md).unwrap();
15305 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15306 assert_eq!(
15307 attrs["isNumberColumnEnabled"], true,
15308 "isNumberColumnEnabled=true should be preserved"
15309 );
15310 }
15311
15312 #[test]
15313 fn directive_table_is_number_column_enabled_false_preserved() {
15314 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
15317 {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
15318 {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
15319 ]}]}]}]}"#;
15320 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15321 let md = adf_to_markdown(&doc).unwrap();
15322 assert!(md.contains("::::table"), "should use directive table form");
15323 assert!(
15324 md.contains("numbered=false"),
15325 "should contain numbered=false, got: {md}"
15326 );
15327 let round_tripped = markdown_to_adf(&md).unwrap();
15328 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15329 assert_eq!(attrs["isNumberColumnEnabled"], false);
15330 assert_eq!(attrs["layout"], "default");
15331 }
15332
15333 #[test]
15334 fn directive_table_is_number_column_enabled_true_preserved() {
15335 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":true,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
15337 {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
15338 {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
15339 ]}]}]}]}"#;
15340 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15341 let md = adf_to_markdown(&doc).unwrap();
15342 assert!(md.contains("::::table"), "should use directive table form");
15343 assert!(
15344 md.contains("numbered}") || md.contains("numbered "),
15345 "should contain numbered flag, got: {md}"
15346 );
15347 let round_tripped = markdown_to_adf(&md).unwrap();
15348 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15349 assert_eq!(attrs["isNumberColumnEnabled"], true);
15350 }
15351
15352 #[test]
15353 fn trailing_space_in_bullet_list_item_preserved() {
15354 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15356 {"type":"listItem","content":[{"type":"paragraph","content":[
15357 {"type":"text","text":"Before link "},
15358 {"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
15359 {"type":"text","text":" "}
15360 ]}]}
15361 ]}]}"#;
15362 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15363 let md = adf_to_markdown(&doc).unwrap();
15364 let round_tripped = markdown_to_adf(&md).unwrap();
15365 let list = &round_tripped.content[0];
15366 let item = &list.content.as_ref().unwrap()[0];
15367 let para = &item.content.as_ref().unwrap()[0];
15368 let inlines = para.content.as_ref().unwrap();
15369 let last = inlines.last().unwrap();
15370 assert_eq!(
15371 last.text.as_deref(),
15372 Some(" "),
15373 "trailing space text node should be preserved, got nodes: {:?}",
15374 inlines
15375 .iter()
15376 .map(|n| (&n.node_type, &n.text))
15377 .collect::<Vec<_>>()
15378 );
15379 }
15380
15381 #[test]
15382 fn trailing_space_after_mention_in_bullet_list_preserved() {
15383 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15385 {"type":"listItem","content":[{"type":"paragraph","content":[
15386 {"type":"mention","attrs":{"id":"abc","text":"@Alice"}},
15387 {"type":"text","text":" "}
15388 ]}]}
15389 ]}]}"#;
15390 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15391 let md = adf_to_markdown(&doc).unwrap();
15392 let round_tripped = markdown_to_adf(&md).unwrap();
15393 let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
15394 .content
15395 .as_ref()
15396 .unwrap()[0];
15397 let inlines = para.content.as_ref().unwrap();
15398 assert!(
15399 inlines.len() >= 2,
15400 "should have mention + trailing space, got {} nodes",
15401 inlines.len()
15402 );
15403 assert_eq!(inlines.last().unwrap().text.as_deref(), Some(" "));
15404 }
15405
15406 #[test]
15407 fn trailing_space_in_ordered_list_item_preserved() {
15408 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15410 {"type":"listItem","content":[{"type":"paragraph","content":[
15411 {"type":"text","text":"item "},
15412 {"type":"text","text":"link","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
15413 {"type":"text","text":" "}
15414 ]}]}
15415 ]}]}"#;
15416 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15417 let md = adf_to_markdown(&doc).unwrap();
15418 let round_tripped = markdown_to_adf(&md).unwrap();
15419 let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
15420 .content
15421 .as_ref()
15422 .unwrap()[0];
15423 let inlines = para.content.as_ref().unwrap();
15424 let last = inlines.last().unwrap();
15425 assert_eq!(
15426 last.text.as_deref(),
15427 Some(" "),
15428 "trailing space should be preserved in ordered list item"
15429 );
15430 }
15431
15432 #[test]
15433 fn trailing_space_in_heading_text_preserved() {
15434 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[
15436 {"type":"text","text":"Firefighting Engineers "}
15437 ]}]}"#;
15438 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15439 let md = adf_to_markdown(&doc).unwrap();
15440 let round_tripped = markdown_to_adf(&md).unwrap();
15441 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15442 assert_eq!(
15443 inlines[0].text.as_deref(),
15444 Some("Firefighting Engineers "),
15445 "trailing space in heading should be preserved"
15446 );
15447 }
15448
15449 #[test]
15450 fn trailing_space_in_heading_before_bold_preserved() {
15451 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
15453 {"type":"text","text":"Classic "},
15454 {"type":"text","text":"bold","marks":[{"type":"strong"}]}
15455 ]}]}"#;
15456 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15457 let md = adf_to_markdown(&doc).unwrap();
15458 let round_tripped = markdown_to_adf(&md).unwrap();
15459 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15460 assert_eq!(
15461 inlines[0].text.as_deref(),
15462 Some("Classic "),
15463 "trailing space in heading text before bold should be preserved"
15464 );
15465 }
15466
15467 #[test]
15468 fn leading_space_in_heading_text_preserved() {
15469 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":3},"content":[
15471 {"type":"text","text":" #general-channel"}
15472 ]}]}"#;
15473 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15474 let md = adf_to_markdown(&doc).unwrap();
15475 let round_tripped = markdown_to_adf(&md).unwrap();
15476 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15477 assert_eq!(
15478 inlines[0].text.as_deref(),
15479 Some(" #general-channel"),
15480 "leading spaces in heading text should be preserved"
15481 );
15482 }
15483
15484 #[test]
15485 fn leading_space_in_heading_before_bold_preserved() {
15486 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
15488 {"type":"text","text":" indented"},
15489 {"type":"text","text":" bold","marks":[{"type":"strong"}]}
15490 ]}]}"#;
15491 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15492 let md = adf_to_markdown(&doc).unwrap();
15493 let round_tripped = markdown_to_adf(&md).unwrap();
15494 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15495 assert_eq!(
15496 inlines[0].text.as_deref(),
15497 Some(" indented"),
15498 "leading spaces in heading text before bold should be preserved"
15499 );
15500 }
15501
15502 #[test]
15503 fn heading_multiple_leading_spaces_markdown_parse() {
15504 let md = "### \t #general-channel";
15506 let doc = markdown_to_adf(md).unwrap();
15507 let inlines = doc.content[0].content.as_ref().unwrap();
15508 assert_eq!(
15509 inlines[0].text.as_deref(),
15510 Some("\t #general-channel"),
15511 "leading whitespace in heading text should be preserved during JFM parsing"
15512 );
15513 }
15514
15515 #[test]
15516 fn trailing_space_in_paragraph_text_preserved() {
15517 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15519 {"type":"text","text":"word followed by space "},
15520 {"type":"text","text":"next node","marks":[{"type":"strong"}]}
15521 ]}]}"#;
15522 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15523 let md = adf_to_markdown(&doc).unwrap();
15524 let round_tripped = markdown_to_adf(&md).unwrap();
15525 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15526 assert_eq!(
15527 inlines[0].text.as_deref(),
15528 Some("word followed by space "),
15529 "trailing space in paragraph text should be preserved"
15530 );
15531 }
15532
15533 #[test]
15534 fn nested_bullet_list_roundtrip() {
15535 let adf_doc = serde_json::json!({
15537 "type": "doc",
15538 "version": 1,
15539 "content": [{
15540 "type": "bulletList",
15541 "content": [{
15542 "type": "listItem",
15543 "content": [
15544 {
15545 "type": "paragraph",
15546 "content": [{"type": "text", "text": "parent item"}]
15547 },
15548 {
15549 "type": "bulletList",
15550 "content": [
15551 {
15552 "type": "listItem",
15553 "content": [{
15554 "type": "paragraph",
15555 "content": [{"type": "text", "text": "sub item 1"}]
15556 }]
15557 },
15558 {
15559 "type": "listItem",
15560 "content": [{
15561 "type": "paragraph",
15562 "content": [{"type": "text", "text": "sub item 2"}]
15563 }]
15564 }
15565 ]
15566 }
15567 ]
15568 }]
15569 }]
15570 });
15571 let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
15572 let md = adf_to_markdown(&doc).unwrap();
15573 assert!(
15574 md.contains("- parent item\n"),
15575 "expected top-level item in markdown, got: {md}"
15576 );
15577 assert!(
15578 md.contains(" - sub item 1\n"),
15579 "expected indented sub item 1 in markdown, got: {md}"
15580 );
15581 assert!(
15582 md.contains(" - sub item 2\n"),
15583 "expected indented sub item 2 in markdown, got: {md}"
15584 );
15585
15586 let doc2 = markdown_to_adf(&md).unwrap();
15588 let list = &doc2.content[0];
15589 assert_eq!(list.node_type, "bulletList");
15590 let item = &list.content.as_ref().unwrap()[0];
15591 assert_eq!(item.node_type, "listItem");
15592 let item_content = item.content.as_ref().unwrap();
15593 assert_eq!(
15594 item_content.len(),
15595 2,
15596 "listItem should have paragraph + nested list"
15597 );
15598 assert_eq!(item_content[0].node_type, "paragraph");
15599 assert_eq!(item_content[1].node_type, "bulletList");
15600 let sub_items = item_content[1].content.as_ref().unwrap();
15601 assert_eq!(sub_items.len(), 2);
15602 }
15603
15604 #[test]
15605 fn nested_bullet_in_table_cell_roundtrip() {
15606 let md = "::::table\n:::tr\n:::td\n- parent\n - child\n:::\n:::\n::::\n";
15607 let doc = markdown_to_adf(md).unwrap();
15608 let table = &doc.content[0];
15609 let row = &table.content.as_ref().unwrap()[0];
15610 let cell = &row.content.as_ref().unwrap()[0];
15611 let list = &cell.content.as_ref().unwrap()[0];
15612 assert_eq!(list.node_type, "bulletList");
15613 let item = &list.content.as_ref().unwrap()[0];
15614 let item_content = item.content.as_ref().unwrap();
15615 assert_eq!(
15616 item_content.len(),
15617 2,
15618 "listItem should have paragraph + nested list"
15619 );
15620 assert_eq!(item_content[1].node_type, "bulletList");
15621
15622 let md2 = adf_to_markdown(&doc).unwrap();
15624 assert!(
15625 md2.contains(" - child"),
15626 "expected indented child in round-tripped markdown, got: {md2}"
15627 );
15628 }
15629
15630 #[test]
15631 fn nested_ordered_list_roundtrip() {
15632 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15634 {"type":"listItem","content":[
15635 {"type":"paragraph","content":[{"type":"text","text":"Top level"}]},
15636 {"type":"orderedList","attrs":{"order":1},"content":[
15637 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 1"}]}]},
15638 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 2"}]}]}
15639 ]}
15640 ]},
15641 {"type":"listItem","content":[
15642 {"type":"paragraph","content":[{"type":"text","text":"Second top"}]}
15643 ]}
15644 ]}]}"#;
15645 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15646 let md = adf_to_markdown(&doc).unwrap();
15647 let round_tripped = markdown_to_adf(&md).unwrap();
15648
15649 let outer = &round_tripped.content[0];
15651 assert_eq!(outer.node_type, "orderedList");
15652 assert_eq!(
15653 outer.attrs.as_ref().unwrap()["order"],
15654 1,
15655 "explicit order=1 must be preserved via trailing {{order=1}} (issue #547)"
15656 );
15657 let outer_items = outer.content.as_ref().unwrap();
15658 assert_eq!(
15659 outer_items.len(),
15660 2,
15661 "outer list should have 2 items, got {}",
15662 outer_items.len()
15663 );
15664
15665 let first_item = &outer_items[0];
15667 let first_content = first_item.content.as_ref().unwrap();
15668 assert_eq!(
15669 first_content.len(),
15670 2,
15671 "first listItem should have paragraph + nested list, got {}",
15672 first_content.len()
15673 );
15674 assert_eq!(first_content[0].node_type, "paragraph");
15675 assert_eq!(first_content[1].node_type, "orderedList");
15676 let nested_items = first_content[1].content.as_ref().unwrap();
15677 assert_eq!(nested_items.len(), 2, "nested list should have 2 items");
15678 }
15679
15680 #[test]
15681 fn nested_ordered_list_markdown_parsing() {
15682 let md = "1. Top level\n 1. Nested 1\n 2. Nested 2\n2. Second top\n";
15684 let doc = markdown_to_adf(md).unwrap();
15685 let outer = &doc.content[0];
15686 assert_eq!(outer.node_type, "orderedList");
15687 let outer_items = outer.content.as_ref().unwrap();
15688 assert_eq!(outer_items.len(), 2, "should have 2 top-level items");
15689
15690 let first_content = outer_items[0].content.as_ref().unwrap();
15691 assert_eq!(
15692 first_content.len(),
15693 2,
15694 "first item should have paragraph + nested list"
15695 );
15696 assert_eq!(first_content[1].node_type, "orderedList");
15697 }
15698
15699 #[test]
15700 fn bullet_list_nested_inside_ordered_list() {
15701 let md = "1. Ordered item\n - Bullet child 1\n - Bullet child 2\n2. Second ordered\n";
15703 let doc = markdown_to_adf(md).unwrap();
15704 let outer = &doc.content[0];
15705 assert_eq!(outer.node_type, "orderedList");
15706 let outer_items = outer.content.as_ref().unwrap();
15707 assert_eq!(outer_items.len(), 2);
15708
15709 let first_content = outer_items[0].content.as_ref().unwrap();
15710 assert_eq!(
15711 first_content.len(),
15712 2,
15713 "first item should have paragraph + nested list"
15714 );
15715 assert_eq!(first_content[1].node_type, "bulletList");
15716 let sub_items = first_content[1].content.as_ref().unwrap();
15717 assert_eq!(sub_items.len(), 2, "nested bullet list should have 2 items");
15718 }
15719
15720 #[test]
15721 fn ordered_list_order_attr_one_is_elided() {
15722 let md = "1. A\n2. B\n";
15726 let doc = markdown_to_adf(md).unwrap();
15727 assert!(
15728 doc.content[0].attrs.is_none(),
15729 "attrs should be elided when order=1"
15730 );
15731
15732 let md2 = adf_to_markdown(&doc).unwrap();
15734 let doc2 = markdown_to_adf(&md2).unwrap();
15735 assert!(
15736 doc2.content[0].attrs.is_none(),
15737 "attrs should remain elided after round-trip"
15738 );
15739 }
15740
15741 #[test]
15742 fn issue_547_ordered_list_no_attrs_roundtrip_byte_identical() {
15743 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"}]}]}]}]}"#;
15746 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15747 let md = adf_to_markdown(&doc).unwrap();
15748 let rt = markdown_to_adf(&md).unwrap();
15749 assert!(
15750 rt.content[0].attrs.is_none(),
15751 "round-tripped orderedList should not have attrs, got: {:?}",
15752 rt.content[0].attrs
15753 );
15754
15755 let rt_json = serde_json::to_string(&rt).unwrap();
15757 assert!(
15758 !rt_json.contains("\"order\""),
15759 "round-tripped JSON should not contain \"order\", got: {rt_json}"
15760 );
15761 }
15762
15763 fn assert_roundtrip_byte_identical(adf_json: &str) {
15769 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15770 let md = adf_to_markdown(&doc).unwrap();
15771 let rt = markdown_to_adf(&md).unwrap();
15772
15773 let canonical_src: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15774 let canonical_rt: serde_json::Value =
15775 serde_json::from_str(&serde_json::to_string(&rt).unwrap()).unwrap();
15776 assert_eq!(
15777 canonical_src, canonical_rt,
15778 "round-trip diverged\n src: {canonical_src}\n rt: {canonical_rt}\n md: {md:?}"
15779 );
15780 }
15781
15782 #[test]
15783 fn issue_547_single_item_no_attrs_roundtrip() {
15784 assert_roundtrip_byte_identical(
15785 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"only"}]}]}]}]}"#,
15786 );
15787 }
15788
15789 #[test]
15790 fn issue_547_many_items_no_attrs_roundtrip() {
15791 assert_roundtrip_byte_identical(
15792 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"}]}]}]}]}"#,
15793 );
15794 }
15795
15796 #[test]
15797 fn issue_547_non_default_order_preserved() {
15798 assert_roundtrip_byte_identical(
15801 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":5},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"fifth"}]}]}]}]}"#,
15802 );
15803 }
15804
15805 #[test]
15806 fn issue_547_nested_ordered_in_ordered_no_attrs_roundtrip() {
15807 assert_roundtrip_byte_identical(
15809 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"}]}]}]}]}]}]}"#,
15810 );
15811 }
15812
15813 #[test]
15814 fn issue_547_ordered_nested_in_bullet_no_attrs_roundtrip() {
15815 assert_roundtrip_byte_identical(
15816 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"}]}]}]}]}]}]}"#,
15817 );
15818 }
15819
15820 #[test]
15821 fn issue_547_bullet_nested_in_ordered_no_attrs_roundtrip() {
15822 assert_roundtrip_byte_identical(
15823 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"}]}]}]}]}]}]}"#,
15824 );
15825 }
15826
15827 #[test]
15828 fn issue_547_ordered_list_between_paragraphs_roundtrip() {
15829 assert_roundtrip_byte_identical(
15830 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"}]}]}"#,
15831 );
15832 }
15833
15834 #[test]
15835 fn issue_547_ordered_list_with_marked_text_roundtrip() {
15836 assert_roundtrip_byte_identical(
15837 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"bold","marks":[{"type":"strong"}]}]}]}]}]}"#,
15838 );
15839 }
15840
15841 #[test]
15842 fn issue_547_ordered_list_with_link_roundtrip() {
15843 assert_roundtrip_byte_identical(
15844 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"}}]}]}]}]}]}"#,
15845 );
15846 }
15847
15848 #[test]
15849 fn issue_547_ordered_list_with_hardbreak_roundtrip() {
15850 assert_roundtrip_byte_identical(
15851 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"}]}]}]}]}"#,
15852 );
15853 }
15854
15855 #[test]
15856 fn issue_547_triple_nested_ordered_roundtrip() {
15857 assert_roundtrip_byte_identical(
15858 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"}]}]}]}]}]}]}]}]}"#,
15859 );
15860 }
15861
15862 #[test]
15863 fn issue_547_ordered_list_heading_rule_mix_roundtrip() {
15864 assert_roundtrip_byte_identical(
15865 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"}]}"#,
15866 );
15867 }
15868
15869 #[test]
15870 fn issue_547_ordered_list_listitem_localid_roundtrip() {
15871 assert_roundtrip_byte_identical(
15873 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]}]}"#,
15874 );
15875 }
15876
15877 #[test]
15878 fn issue_547_explicit_order_one_preserved_roundtrip() {
15879 assert_roundtrip_byte_identical(
15884 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"First item"}]}]}]}]}"#,
15885 );
15886 }
15887
15888 #[test]
15889 fn issue_547_explicit_order_one_nested_preserved_roundtrip() {
15890 assert_roundtrip_byte_identical(
15893 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"}]}]}]}]}]}]}"#,
15894 );
15895 }
15896
15897 #[test]
15898 fn issue_547_mixed_explicit_and_implicit_order_roundtrip() {
15899 assert_roundtrip_byte_identical(
15902 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"}]}]}]}]}"#,
15903 );
15904 }
15905
15906 #[test]
15907 fn issue_547_explicit_order_one_with_listitem_localid_roundtrip() {
15908 assert_roundtrip_byte_identical(
15912 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"}]}]}]}]}"#,
15913 );
15914 }
15915
15916 #[test]
15917 fn issue_547_order_attr_signal_appears_only_for_explicit_one() {
15918 let no_attrs = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
15922 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"}]}]}]}]}"#;
15923 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"}]}]}]}]}"#;
15924
15925 let md_no =
15926 adf_to_markdown(&serde_json::from_str::<AdfDocument>(no_attrs).unwrap()).unwrap();
15927 let md_one =
15928 adf_to_markdown(&serde_json::from_str::<AdfDocument>(explicit_one).unwrap()).unwrap();
15929 let md_five =
15930 adf_to_markdown(&serde_json::from_str::<AdfDocument>(order_five).unwrap()).unwrap();
15931
15932 assert!(
15933 !md_no.contains("{order="),
15934 "no-attrs source must not emit order signal, got: {md_no:?}"
15935 );
15936 assert!(
15937 md_one.contains("{order=1}"),
15938 "explicit order=1 must emit trailing signal, got: {md_one:?}"
15939 );
15940 assert!(
15941 !md_five.contains("{order="),
15942 "order=5 is already encoded by marker; must not emit signal, got: {md_five:?}"
15943 );
15944 }
15945
15946 #[test]
15949 fn file_media_roundtrip() {
15950 let adf_doc = serde_json::json!({
15952 "type": "doc",
15953 "version": 1,
15954 "content": [{
15955 "type": "mediaSingle",
15956 "attrs": {"layout": "center"},
15957 "content": [{
15958 "type": "media",
15959 "attrs": {
15960 "type": "file",
15961 "id": "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d",
15962 "collection": "contentId-8220672100",
15963 "height": 56,
15964 "width": 312,
15965 "alt": "Screenshot.png"
15966 }
15967 }]
15968 }]
15969 });
15970 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15971 let md = adf_to_markdown(&doc).unwrap();
15972 assert!(
15973 md.contains("type=file"),
15974 "expected type=file in markdown, got: {md}"
15975 );
15976 assert!(
15977 md.contains("id=6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d"),
15978 "expected id in markdown, got: {md}"
15979 );
15980 assert!(
15981 md.contains("collection=contentId-8220672100"),
15982 "expected collection in markdown, got: {md}"
15983 );
15984 let doc2 = markdown_to_adf(&md).unwrap();
15986 let ms = &doc2.content[0];
15987 assert_eq!(ms.node_type, "mediaSingle");
15988 let media = &ms.content.as_ref().unwrap()[0];
15989 assert_eq!(media.node_type, "media");
15990 let attrs = media.attrs.as_ref().unwrap();
15991 assert_eq!(attrs["type"], "file");
15992 assert_eq!(attrs["id"], "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d");
15993 assert_eq!(attrs["collection"], "contentId-8220672100");
15994 assert_eq!(attrs["height"], 56);
15995 assert_eq!(attrs["width"], 312);
15996 assert_eq!(attrs["alt"], "Screenshot.png");
15997 }
15998
15999 #[test]
16003 fn file_media_roundtrip_issue_550_reproducer() {
16004 let adf_json = r#"{
16005 "version": 1,
16006 "type": "doc",
16007 "content": [
16008 {
16009 "type": "mediaSingle",
16010 "attrs": {"layout": "center"},
16011 "content": [
16012 {
16013 "type": "media",
16014 "attrs": {
16015 "type": "file",
16016 "id": "abc-123-def-456",
16017 "collection": "my-collection",
16018 "width": 941,
16019 "height": 655
16020 }
16021 }
16022 ]
16023 }
16024 ]
16025 }"#;
16026 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16027 let md = adf_to_markdown(&doc).unwrap();
16028 let rt = markdown_to_adf(&md).unwrap();
16029 let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
16030 let actual = serde_json::to_value(&rt).unwrap();
16031 assert_eq!(
16032 actual, expected,
16033 "roundtrip should preserve file media attrs; md was:\n{md}"
16034 );
16035 }
16036
16037 #[test]
16043 fn file_media_roundtrip_id_with_spaces() {
16044 let adf_json = r#"{
16045 "version": 1,
16046 "type": "doc",
16047 "content": [
16048 {
16049 "type": "mediaSingle",
16050 "attrs": {"layout": "center"},
16051 "content": [
16052 {
16053 "type": "media",
16054 "attrs": {
16055 "type": "file",
16056 "id": "abc 123 def 456",
16057 "collection": "my-collection",
16058 "width": 800,
16059 "height": 600
16060 }
16061 }
16062 ]
16063 }
16064 ]
16065 }"#;
16066 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16067 let md = adf_to_markdown(&doc).unwrap();
16068 assert!(
16069 md.contains(r#"id="abc 123 def 456""#),
16070 "id with spaces should be quoted in JFM, got:\n{md}"
16071 );
16072 let rt = markdown_to_adf(&md).unwrap();
16073 let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
16074 let actual = serde_json::to_value(&rt).unwrap();
16075 assert_eq!(
16076 actual, expected,
16077 "space-containing id must round-trip; md was:\n{md}"
16078 );
16079 }
16080
16081 #[test]
16083 fn file_media_roundtrip_collection_with_spaces() {
16084 let adf_json = r#"{
16085 "version": 1,
16086 "type": "doc",
16087 "content": [
16088 {
16089 "type": "mediaSingle",
16090 "attrs": {"layout": "center"},
16091 "content": [
16092 {
16093 "type": "media",
16094 "attrs": {
16095 "type": "file",
16096 "id": "abc-123",
16097 "collection": "my collection with spaces"
16098 }
16099 }
16100 ]
16101 }
16102 ]
16103 }"#;
16104 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16105 let md = adf_to_markdown(&doc).unwrap();
16106 let rt = markdown_to_adf(&md).unwrap();
16107 let media = &rt.content[0].content.as_ref().unwrap()[0];
16108 assert_eq!(
16109 media.attrs.as_ref().unwrap()["collection"],
16110 "my collection with spaces"
16111 );
16112 }
16113
16114 #[test]
16116 fn file_media_roundtrip_occurrence_key_with_spaces() {
16117 let adf_json = r#"{
16118 "version": 1,
16119 "type": "doc",
16120 "content": [
16121 {
16122 "type": "mediaSingle",
16123 "attrs": {"layout": "center"},
16124 "content": [
16125 {
16126 "type": "media",
16127 "attrs": {
16128 "type": "file",
16129 "id": "x",
16130 "collection": "y",
16131 "occurrenceKey": "key with spaces"
16132 }
16133 }
16134 ]
16135 }
16136 ]
16137 }"#;
16138 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16139 let md = adf_to_markdown(&doc).unwrap();
16140 let rt = markdown_to_adf(&md).unwrap();
16141 let media = &rt.content[0].content.as_ref().unwrap()[0];
16142 assert_eq!(
16143 media.attrs.as_ref().unwrap()["occurrenceKey"],
16144 "key with spaces"
16145 );
16146 }
16147
16148 #[test]
16150 fn file_media_roundtrip_id_with_quote_char() {
16151 let adf_json = r#"{
16152 "version": 1,
16153 "type": "doc",
16154 "content": [
16155 {
16156 "type": "mediaSingle",
16157 "attrs": {"layout": "center"},
16158 "content": [
16159 {
16160 "type": "media",
16161 "attrs": {
16162 "type": "file",
16163 "id": "a\"b\"c",
16164 "collection": "col"
16165 }
16166 }
16167 ]
16168 }
16169 ]
16170 }"#;
16171 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16172 let md = adf_to_markdown(&doc).unwrap();
16173 let rt = markdown_to_adf(&md).unwrap();
16174 let media = &rt.content[0].content.as_ref().unwrap()[0];
16175 assert_eq!(media.attrs.as_ref().unwrap()["id"], "a\"b\"c");
16176 }
16177
16178 #[test]
16181 fn media_inline_roundtrip_id_with_spaces() {
16182 let adf_json = r#"{
16183 "version": 1,
16184 "type": "doc",
16185 "content": [
16186 {
16187 "type": "paragraph",
16188 "content": [
16189 {"type": "text", "text": "before "},
16190 {
16191 "type": "mediaInline",
16192 "attrs": {
16193 "type": "file",
16194 "id": "a b c",
16195 "collection": "my col"
16196 }
16197 },
16198 {"type": "text", "text": " after"}
16199 ]
16200 }
16201 ]
16202 }"#;
16203 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16204 let md = adf_to_markdown(&doc).unwrap();
16205 let rt = markdown_to_adf(&md).unwrap();
16206 let inline = &rt.content[0].content.as_ref().unwrap()[1];
16207 assert_eq!(inline.node_type, "mediaInline");
16208 let attrs = inline.attrs.as_ref().unwrap();
16209 assert_eq!(attrs["id"], "a b c");
16210 assert_eq!(attrs["collection"], "my col");
16211 }
16212
16213 #[test]
16216 fn file_media_roundtrip_preserves_occurrence_key() {
16217 let adf_json = r#"{
16218 "version": 1,
16219 "type": "doc",
16220 "content": [
16221 {
16222 "type": "mediaSingle",
16223 "attrs": {"layout": "center"},
16224 "content": [
16225 {
16226 "type": "media",
16227 "attrs": {
16228 "type": "file",
16229 "id": "abc-123",
16230 "collection": "my-collection",
16231 "occurrenceKey": "unique-key-xyz",
16232 "width": 200,
16233 "height": 100
16234 }
16235 }
16236 ]
16237 }
16238 ]
16239 }"#;
16240 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16241 let md = adf_to_markdown(&doc).unwrap();
16242 assert!(
16243 md.contains("occurrenceKey=unique-key-xyz"),
16244 "expected occurrenceKey in markdown, got: {md}"
16245 );
16246 let rt = markdown_to_adf(&md).unwrap();
16247 let media = &rt.content[0].content.as_ref().unwrap()[0];
16248 let attrs = media.attrs.as_ref().unwrap();
16249 assert_eq!(attrs["occurrenceKey"], "unique-key-xyz");
16250 assert_eq!(attrs["type"], "file");
16251 assert_eq!(attrs["id"], "abc-123");
16252 assert_eq!(attrs["collection"], "my-collection");
16253 }
16254
16255 #[test]
16258 fn media_single_caption_adf_to_markdown() {
16259 let adf_doc = serde_json::json!({
16260 "type": "doc",
16261 "version": 1,
16262 "content": [{
16263 "type": "mediaSingle",
16264 "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
16265 "content": [
16266 {
16267 "type": "media",
16268 "attrs": {
16269 "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
16270 "type": "file",
16271 "collection": "contentId-123456",
16272 "width": 800,
16273 "height": 600
16274 }
16275 },
16276 {
16277 "type": "caption",
16278 "content": [{"type": "text", "text": "An image caption here"}]
16279 }
16280 ]
16281 }]
16282 });
16283 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16284 let md = adf_to_markdown(&doc).unwrap();
16285 assert!(
16286 md.contains(":::caption"),
16287 "expected :::caption in markdown, got: {md}"
16288 );
16289 assert!(
16290 md.contains("An image caption here"),
16291 "expected caption text in markdown, got: {md}"
16292 );
16293 }
16294
16295 #[test]
16296 fn media_single_caption_markdown_to_adf() {
16297 let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nAn image caption here\n:::\n";
16298 let doc = markdown_to_adf(md).unwrap();
16299 let ms = &doc.content[0];
16300 assert_eq!(ms.node_type, "mediaSingle");
16301 let content = ms.content.as_ref().unwrap();
16302 assert_eq!(content.len(), 2, "expected media + caption children");
16303 assert_eq!(content[0].node_type, "media");
16304 assert_eq!(content[1].node_type, "caption");
16305 let caption_content = content[1].content.as_ref().unwrap();
16306 assert_eq!(
16307 caption_content[0].text.as_deref(),
16308 Some("An image caption here")
16309 );
16310 }
16311
16312 #[test]
16313 fn media_single_caption_round_trip() {
16314 let adf_doc = serde_json::json!({
16316 "type": "doc",
16317 "version": 1,
16318 "content": [{
16319 "type": "mediaSingle",
16320 "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
16321 "content": [
16322 {
16323 "type": "media",
16324 "attrs": {
16325 "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
16326 "type": "file",
16327 "collection": "contentId-123456",
16328 "width": 800,
16329 "height": 600
16330 }
16331 },
16332 {
16333 "type": "caption",
16334 "content": [{"type": "text", "text": "An image caption here"}]
16335 }
16336 ]
16337 }]
16338 });
16339 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16340 let md = adf_to_markdown(&doc).unwrap();
16341 let doc2 = markdown_to_adf(&md).unwrap();
16342 let ms = &doc2.content[0];
16343 assert_eq!(ms.node_type, "mediaSingle");
16344 let content = ms.content.as_ref().unwrap();
16345 assert_eq!(
16346 content.len(),
16347 2,
16348 "expected media + caption after round-trip"
16349 );
16350 assert_eq!(content[1].node_type, "caption");
16351 let caption_content = content[1].content.as_ref().unwrap();
16352 assert_eq!(
16353 caption_content[0].text.as_deref(),
16354 Some("An image caption here")
16355 );
16356 }
16357
16358 #[test]
16359 fn media_single_caption_with_inline_marks() {
16360 let adf_doc = serde_json::json!({
16361 "type": "doc",
16362 "version": 1,
16363 "content": [{
16364 "type": "mediaSingle",
16365 "attrs": {"layout": "center"},
16366 "content": [
16367 {
16368 "type": "media",
16369 "attrs": {"type": "external", "url": "https://example.com/img.png"}
16370 },
16371 {
16372 "type": "caption",
16373 "content": [
16374 {"type": "text", "text": "A "},
16375 {"type": "text", "text": "bold", "marks": [{"type": "strong"}]},
16376 {"type": "text", "text": " caption"}
16377 ]
16378 }
16379 ]
16380 }]
16381 });
16382 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16383 let md = adf_to_markdown(&doc).unwrap();
16384 assert!(
16385 md.contains("**bold**"),
16386 "expected bold in caption, got: {md}"
16387 );
16388
16389 let doc2 = markdown_to_adf(&md).unwrap();
16390 let content = doc2.content[0].content.as_ref().unwrap();
16391 assert_eq!(content.len(), 2, "expected media + caption");
16392 assert_eq!(content[1].node_type, "caption");
16393 let caption_inlines = content[1].content.as_ref().unwrap();
16394 let bold_node = caption_inlines
16395 .iter()
16396 .find(|n| n.text.as_deref() == Some("bold"))
16397 .unwrap();
16398 let marks = bold_node.marks.as_ref().unwrap();
16399 assert_eq!(marks[0].mark_type, "strong");
16400 }
16401
16402 #[test]
16403 fn media_single_no_caption_unaffected() {
16404 let adf_doc = serde_json::json!({
16406 "type": "doc",
16407 "version": 1,
16408 "content": [{
16409 "type": "mediaSingle",
16410 "attrs": {"layout": "center"},
16411 "content": [{
16412 "type": "media",
16413 "attrs": {"type": "external", "url": "https://example.com/img.png"}
16414 }]
16415 }]
16416 });
16417 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16418 let md = adf_to_markdown(&doc).unwrap();
16419 assert!(
16420 !md.contains(":::caption"),
16421 "should not emit caption when none present"
16422 );
16423 let doc2 = markdown_to_adf(&md).unwrap();
16424 let content = doc2.content[0].content.as_ref().unwrap();
16425 assert_eq!(content.len(), 1, "should only have media child");
16426 assert_eq!(content[0].node_type, "media");
16427 }
16428
16429 #[test]
16430 fn media_single_empty_caption_round_trip() {
16431 let adf_doc = serde_json::json!({
16433 "type": "doc",
16434 "version": 1,
16435 "content": [{
16436 "type": "mediaSingle",
16437 "attrs": {"layout": "center"},
16438 "content": [
16439 {
16440 "type": "media",
16441 "attrs": {"type": "external", "url": "https://example.com/img.png"}
16442 },
16443 {
16444 "type": "caption"
16445 }
16446 ]
16447 }]
16448 });
16449 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16450 let md = adf_to_markdown(&doc).unwrap();
16451 assert!(
16452 md.contains(":::caption"),
16453 "expected :::caption even for empty caption, got: {md}"
16454 );
16455 assert!(
16456 md.contains(":::\n"),
16457 "expected closing ::: fence, got: {md}"
16458 );
16459 }
16460
16461 #[test]
16462 fn media_single_external_caption_round_trip() {
16463 let md = "\n:::caption\nImage description\n:::\n";
16465 let doc = markdown_to_adf(md).unwrap();
16466 let ms = &doc.content[0];
16467 assert_eq!(ms.node_type, "mediaSingle");
16468 let content = ms.content.as_ref().unwrap();
16469 assert_eq!(content.len(), 2);
16470 assert_eq!(content[0].node_type, "media");
16471 assert_eq!(content[1].node_type, "caption");
16472
16473 let md2 = adf_to_markdown(&doc).unwrap();
16474 let doc2 = markdown_to_adf(&md2).unwrap();
16475 let content2 = doc2.content[0].content.as_ref().unwrap();
16476 assert_eq!(content2.len(), 2);
16477 assert_eq!(content2[1].node_type, "caption");
16478 let caption_text = content2[1].content.as_ref().unwrap();
16479 assert_eq!(caption_text[0].text.as_deref(), Some("Image description"));
16480 }
16481
16482 #[test]
16485 fn media_single_caption_localid_roundtrip() {
16486 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"}]}]}]}"#;
16487 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16488 let md = adf_to_markdown(&doc).unwrap();
16489 assert!(
16490 md.contains("localId=9da8c2104471"),
16491 "caption localId should appear in markdown: {md}"
16492 );
16493 let rt = markdown_to_adf(&md).unwrap();
16494 let content = rt.content[0].content.as_ref().unwrap();
16495 let caption = &content[1];
16496 assert_eq!(caption.node_type, "caption");
16497 assert_eq!(
16498 caption.attrs.as_ref().unwrap()["localId"],
16499 "9da8c2104471",
16500 "caption localId should round-trip"
16501 );
16502 }
16503
16504 #[test]
16505 fn media_single_caption_without_localid() {
16506 let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nPlain caption\n:::\n";
16507 let doc = markdown_to_adf(md).unwrap();
16508 let caption = &doc.content[0].content.as_ref().unwrap()[1];
16509 assert_eq!(caption.node_type, "caption");
16510 assert!(
16511 caption.attrs.is_none(),
16512 "caption without localId should not gain attrs"
16513 );
16514 let md2 = adf_to_markdown(&doc).unwrap();
16515 assert!(
16516 !md2.contains("localId"),
16517 "no localId should appear in output: {md2}"
16518 );
16519 }
16520
16521 #[test]
16522 fn media_single_caption_localid_stripped_when_option_set() {
16523 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"}]}]}]}"#;
16524 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16525 let opts = RenderOptions {
16526 strip_local_ids: true,
16527 ..Default::default()
16528 };
16529 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16530 assert!(!md.contains("localId"), "localId should be stripped: {md}");
16531 }
16532
16533 #[test]
16534 fn table_width_roundtrip() {
16535 let adf_doc = serde_json::json!({
16537 "type": "doc",
16538 "version": 1,
16539 "content": [{
16540 "type": "table",
16541 "attrs": {"layout": "default", "width": 760.0},
16542 "content": [{
16543 "type": "tableRow",
16544 "content": [{
16545 "type": "tableHeader",
16546 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16547 }]
16548 }]
16549 }]
16550 });
16551 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16552 let md = adf_to_markdown(&doc).unwrap();
16553 assert!(
16554 md.contains("width=760.0"),
16555 "expected width=760.0 in markdown (float preserved), got: {md}"
16556 );
16557 let doc2 = markdown_to_adf(&md).unwrap();
16559 let table = &doc2.content[0];
16560 assert_eq!(table.node_type, "table");
16561 let table_attrs = table.attrs.as_ref().unwrap();
16562 assert_eq!(table_attrs["width"], 760.0);
16563 assert!(
16564 table_attrs["width"].is_f64(),
16565 "expected float width to be preserved as f64, got: {:?}",
16566 table_attrs["width"]
16567 );
16568 }
16569
16570 #[test]
16571 fn table_integer_width_roundtrip_preserves_integer() {
16572 let adf_doc = serde_json::json!({
16575 "type": "doc",
16576 "version": 1,
16577 "content": [{
16578 "type": "table",
16579 "attrs": {
16580 "isNumberColumnEnabled": false,
16581 "layout": "center",
16582 "localId": "abc-123",
16583 "width": 1420
16584 },
16585 "content": [{
16586 "type": "tableRow",
16587 "content": [{
16588 "type": "tableCell",
16589 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Cell"}]}]
16590 }]
16591 }]
16592 }]
16593 });
16594 let doc: crate::atlassian::adf::AdfDocument =
16595 serde_json::from_value(adf_doc.clone()).unwrap();
16596 let md = adf_to_markdown(&doc).unwrap();
16597 assert!(
16598 md.contains("width=1420"),
16599 "expected width=1420 in markdown, got: {md}"
16600 );
16601 assert!(
16602 !md.contains("width=1420.0"),
16603 "integer width should not be rendered with decimal: {md}"
16604 );
16605
16606 let doc2 = markdown_to_adf(&md).unwrap();
16607 let table = &doc2.content[0];
16608 assert_eq!(table.node_type, "table");
16609 let table_attrs = table.attrs.as_ref().unwrap();
16610 assert_eq!(table_attrs["width"], 1420);
16611 assert!(
16612 table_attrs["width"].is_u64() || table_attrs["width"].is_i64(),
16613 "width should remain an integer, got: {:?}",
16614 table_attrs["width"]
16615 );
16616 assert!(
16617 !table_attrs["width"].is_f64(),
16618 "width should not be a float, got: {:?}",
16619 table_attrs["width"]
16620 );
16621
16622 let roundtripped = serde_json::to_value(&doc2).unwrap();
16624 let orig_width = &adf_doc["content"][0]["attrs"]["width"];
16625 let rt_width = &roundtripped["content"][0]["attrs"]["width"];
16626 assert_eq!(
16627 orig_width, rt_width,
16628 "width value must roundtrip byte-for-byte"
16629 );
16630 }
16631
16632 #[test]
16633 fn table_fractional_width_roundtrip() {
16634 let adf_doc = serde_json::json!({
16636 "type": "doc",
16637 "version": 1,
16638 "content": [{
16639 "type": "table",
16640 "attrs": {"layout": "default", "width": 760.5},
16641 "content": [{
16642 "type": "tableRow",
16643 "content": [{
16644 "type": "tableHeader",
16645 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16646 }]
16647 }]
16648 }]
16649 });
16650 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16651 let md = adf_to_markdown(&doc).unwrap();
16652 assert!(
16653 md.contains("width=760.5"),
16654 "expected width=760.5 in markdown, got: {md}"
16655 );
16656 let doc2 = markdown_to_adf(&md).unwrap();
16657 let table_attrs = doc2.content[0].attrs.as_ref().unwrap();
16658 assert_eq!(table_attrs["width"], 760.5);
16659 assert!(table_attrs["width"].is_f64());
16660 }
16661
16662 #[test]
16663 fn pipe_table_integer_width_roundtrip() {
16664 let md = "| A | B |\n|---|---|\n| 1 | 2 |\n{layout=default width=1420}\n";
16666 let doc = markdown_to_adf(md).unwrap();
16667 let table = &doc.content[0];
16668 assert_eq!(table.node_type, "table");
16669 let attrs = table.attrs.as_ref().unwrap();
16670 assert_eq!(attrs["width"], 1420);
16671 assert!(
16672 attrs["width"].is_u64() || attrs["width"].is_i64(),
16673 "pipe-table width must stay integer, got: {:?}",
16674 attrs["width"]
16675 );
16676 }
16677
16678 #[test]
16679 fn file_media_width_type_roundtrip() {
16680 let adf_doc = serde_json::json!({
16682 "type": "doc",
16683 "version": 1,
16684 "content": [{
16685 "type": "mediaSingle",
16686 "attrs": {"layout": "center", "width": 312, "widthType": "pixel"},
16687 "content": [{
16688 "type": "media",
16689 "attrs": {
16690 "type": "file",
16691 "id": "abc123",
16692 "collection": "contentId-999",
16693 "height": 56,
16694 "width": 312
16695 }
16696 }]
16697 }]
16698 });
16699 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16700 let md = adf_to_markdown(&doc).unwrap();
16701 assert!(
16702 md.contains("widthType=pixel"),
16703 "expected widthType=pixel in markdown, got: {md}"
16704 );
16705 let doc2 = markdown_to_adf(&md).unwrap();
16706 let ms = &doc2.content[0];
16707 let ms_attrs = ms.attrs.as_ref().unwrap();
16708 assert_eq!(ms_attrs["widthType"], "pixel");
16709 assert_eq!(ms_attrs["width"], 312);
16710 }
16711
16712 #[test]
16713 fn file_media_pixel_width_above_100_passes_validation() {
16714 let md = "![alt](){type=file id=11111111-2222-3333-4444-555555555555 collection=contentId-98765 height=904 width=900 mediaWidth=900 widthType=pixel}\n";
16723 let adf = markdown_to_adf(md).unwrap();
16724
16725 let ms_attrs = adf.content[0].attrs.as_ref().unwrap();
16726 assert_eq!(ms_attrs["width"], 900);
16727 assert_eq!(ms_attrs["widthType"], "pixel");
16728
16729 crate::atlassian::adf_validated::validate(&adf).unwrap();
16730 }
16731
16732 #[test]
16733 fn file_media_mode_roundtrip() {
16734 let adf_doc = serde_json::json!({
16736 "type": "doc",
16737 "version": 1,
16738 "content": [{
16739 "type": "mediaSingle",
16740 "attrs": {"layout": "wide", "mode": "wide", "width": 1200},
16741 "content": [{
16742 "type": "media",
16743 "attrs": {
16744 "type": "file",
16745 "id": "abc123",
16746 "collection": "test",
16747 "width": 1200,
16748 "height": 600
16749 }
16750 }]
16751 }]
16752 });
16753 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16754 let md = adf_to_markdown(&doc).unwrap();
16755 assert!(
16756 md.contains("mode=wide"),
16757 "expected mode=wide in markdown, got: {md}"
16758 );
16759 let doc2 = markdown_to_adf(&md).unwrap();
16760 let ms = &doc2.content[0];
16761 let ms_attrs = ms.attrs.as_ref().unwrap();
16762 assert_eq!(ms_attrs["mode"], "wide");
16763 assert_eq!(ms_attrs["layout"], "wide");
16764 assert_eq!(ms_attrs["width"], 1200);
16765 }
16766
16767 #[test]
16768 fn external_media_mode_roundtrip() {
16769 let adf_doc = serde_json::json!({
16771 "type": "doc",
16772 "version": 1,
16773 "content": [{
16774 "type": "mediaSingle",
16775 "attrs": {"layout": "wide", "mode": "wide"},
16776 "content": [{
16777 "type": "media",
16778 "attrs": {
16779 "type": "external",
16780 "url": "https://example.com/image.png"
16781 }
16782 }]
16783 }]
16784 });
16785 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16786 let md = adf_to_markdown(&doc).unwrap();
16787 assert!(
16788 md.contains("mode=wide"),
16789 "expected mode=wide in markdown, got: {md}"
16790 );
16791 let doc2 = markdown_to_adf(&md).unwrap();
16792 let ms = &doc2.content[0];
16793 let ms_attrs = ms.attrs.as_ref().unwrap();
16794 assert_eq!(ms_attrs["mode"], "wide");
16795 assert_eq!(ms_attrs["layout"], "wide");
16796 }
16797
16798 #[test]
16799 fn media_mode_only_roundtrip() {
16800 let adf_doc = serde_json::json!({
16802 "type": "doc",
16803 "version": 1,
16804 "content": [{
16805 "type": "mediaSingle",
16806 "attrs": {"layout": "center", "mode": "default"},
16807 "content": [{
16808 "type": "media",
16809 "attrs": {
16810 "type": "external",
16811 "url": "https://example.com/image.png"
16812 }
16813 }]
16814 }]
16815 });
16816 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16817 let md = adf_to_markdown(&doc).unwrap();
16818 assert!(
16819 md.contains("mode=default"),
16820 "expected mode=default in markdown, got: {md}"
16821 );
16822 let doc2 = markdown_to_adf(&md).unwrap();
16823 let ms = &doc2.content[0];
16824 let ms_attrs = ms.attrs.as_ref().unwrap();
16825 assert_eq!(ms_attrs["mode"], "default");
16826 }
16827
16828 #[test]
16829 fn file_media_hex_localid_roundtrip() {
16830 let adf_doc = serde_json::json!({
16832 "type": "doc",
16833 "version": 1,
16834 "content": [{
16835 "type": "mediaSingle",
16836 "attrs": {"layout": "wide", "width": 1200, "widthType": "pixel"},
16837 "content": [{
16838 "type": "media",
16839 "attrs": {
16840 "type": "file",
16841 "id": "eb7a9c3b-314e-4458-8200-4b22b67b122e",
16842 "collection": "contentId-123",
16843 "height": 484,
16844 "width": 915,
16845 "alt": "image.png",
16846 "localId": "0e79f58ac382"
16847 }
16848 }]
16849 }]
16850 });
16851 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16852 let md = adf_to_markdown(&doc).unwrap();
16853 assert!(
16854 md.contains("localId=0e79f58ac382"),
16855 "expected localId=0e79f58ac382 in markdown, got: {md}"
16856 );
16857 let doc2 = markdown_to_adf(&md).unwrap();
16858 let ms = &doc2.content[0];
16859 let media = &ms.content.as_ref().unwrap()[0];
16860 let attrs = media.attrs.as_ref().unwrap();
16861 assert_eq!(attrs["localId"], "0e79f58ac382");
16862 }
16863
16864 #[test]
16865 fn file_media_uuid_localid_roundtrip() {
16866 let adf_doc = serde_json::json!({
16868 "type": "doc",
16869 "version": 1,
16870 "content": [{
16871 "type": "mediaSingle",
16872 "attrs": {"layout": "center"},
16873 "content": [{
16874 "type": "media",
16875 "attrs": {
16876 "type": "file",
16877 "id": "abc-123",
16878 "collection": "contentId-456",
16879 "height": 100,
16880 "width": 200,
16881 "localId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
16882 }
16883 }]
16884 }]
16885 });
16886 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16887 let md = adf_to_markdown(&doc).unwrap();
16888 assert!(
16889 md.contains("localId=a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
16890 "expected UUID localId in markdown, got: {md}"
16891 );
16892 let doc2 = markdown_to_adf(&md).unwrap();
16893 let media = &doc2.content[0].content.as_ref().unwrap()[0];
16894 let attrs = media.attrs.as_ref().unwrap();
16895 assert_eq!(attrs["localId"], "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
16896 }
16897
16898 #[test]
16899 fn file_media_null_uuid_localid_stripped() {
16900 let adf_doc = serde_json::json!({
16902 "type": "doc",
16903 "version": 1,
16904 "content": [{
16905 "type": "mediaSingle",
16906 "attrs": {"layout": "center"},
16907 "content": [{
16908 "type": "media",
16909 "attrs": {
16910 "type": "file",
16911 "id": "abc-123",
16912 "collection": "contentId-456",
16913 "height": 100,
16914 "width": 200,
16915 "localId": "00000000-0000-0000-0000-000000000000"
16916 }
16917 }]
16918 }]
16919 });
16920 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16921 let md = adf_to_markdown(&doc).unwrap();
16922 assert!(
16923 !md.contains("localId="),
16924 "null UUID localId should be stripped, got: {md}"
16925 );
16926 }
16927
16928 #[test]
16929 fn file_media_localid_stripped_when_option_set() {
16930 let adf_doc = serde_json::json!({
16932 "type": "doc",
16933 "version": 1,
16934 "content": [{
16935 "type": "mediaSingle",
16936 "attrs": {"layout": "center"},
16937 "content": [{
16938 "type": "media",
16939 "attrs": {
16940 "type": "file",
16941 "id": "abc-123",
16942 "collection": "contentId-456",
16943 "height": 100,
16944 "width": 200,
16945 "localId": "0e79f58ac382"
16946 }
16947 }]
16948 }]
16949 });
16950 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16951 let opts = RenderOptions {
16952 strip_local_ids: true,
16953 ..Default::default()
16954 };
16955 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16956 assert!(
16957 !md.contains("localId="),
16958 "localId should be stripped with strip_local_ids, got: {md}"
16959 );
16960 }
16961
16962 #[test]
16963 fn external_media_localid_roundtrip() {
16964 let adf_doc = serde_json::json!({
16966 "type": "doc",
16967 "version": 1,
16968 "content": [{
16969 "type": "mediaSingle",
16970 "attrs": {"layout": "center"},
16971 "content": [{
16972 "type": "media",
16973 "attrs": {
16974 "type": "external",
16975 "url": "https://example.com/image.png",
16976 "alt": "test",
16977 "localId": "deadbeef1234"
16978 }
16979 }]
16980 }]
16981 });
16982 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16983 let md = adf_to_markdown(&doc).unwrap();
16984 assert!(
16985 md.contains("localId=deadbeef1234"),
16986 "expected localId in markdown for external media, got: {md}"
16987 );
16988 let doc2 = markdown_to_adf(&md).unwrap();
16989 let media = &doc2.content[0].content.as_ref().unwrap()[0];
16990 let attrs = media.attrs.as_ref().unwrap();
16991 assert_eq!(attrs["localId"], "deadbeef1234");
16992 }
16993
16994 #[test]
16995 fn bracket_in_text_not_parsed_as_link() {
16996 let md = ":check_mark: [Task] Unable to start trial ([Link](https://example.com/link))";
16998 let doc = markdown_to_adf(md).unwrap();
16999 let para = &doc.content[0];
17000 assert_eq!(para.node_type, "paragraph");
17001 let content = para.content.as_ref().unwrap();
17002 let text_nodes: Vec<_> = content.iter().filter(|n| n.node_type == "text").collect();
17004 let has_task_bracket = text_nodes
17005 .iter()
17006 .any(|n| n.text.as_deref().unwrap_or("").contains("[Task]"));
17007 assert!(
17008 has_task_bracket,
17009 "expected [Task] in plain text, nodes: {content:?}"
17010 );
17011 let link_nodes: Vec<_> = content
17013 .iter()
17014 .filter(|n| {
17015 n.marks
17016 .as_ref()
17017 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
17018 })
17019 .collect();
17020 assert!(!link_nodes.is_empty(), "expected a link node");
17021 assert_eq!(
17022 link_nodes[0].text.as_deref(),
17023 Some("Link"),
17024 "link text should be 'Link'"
17025 );
17026 }
17027
17028 #[test]
17029 fn empty_paragraph_roundtrip() {
17030 let mut adf_in = AdfDocument::new();
17032 adf_in.content = vec![
17033 AdfNode::paragraph(vec![AdfNode::text("before")]),
17034 AdfNode::paragraph(vec![]),
17035 AdfNode::paragraph(vec![AdfNode::text("after")]),
17036 ];
17037 let md = adf_to_markdown(&adf_in).unwrap();
17038 let adf_out = markdown_to_adf(&md).unwrap();
17039 assert_eq!(
17040 adf_out.content.len(),
17041 3,
17042 "should have 3 blocks, markdown:\n{md}"
17043 );
17044 assert_eq!(adf_out.content[0].node_type, "paragraph");
17045 assert_eq!(adf_out.content[1].node_type, "paragraph");
17046 assert!(
17047 adf_out.content[1].content.is_none(),
17048 "middle paragraph should be empty"
17049 );
17050 assert_eq!(adf_out.content[2].node_type, "paragraph");
17051 }
17052
17053 #[test]
17054 fn nbsp_paragraph_roundtrip() {
17055 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}";
17057 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17058 let md = adf_to_markdown(&doc).unwrap();
17059 assert!(
17060 md.contains("::paragraph["),
17061 "NBSP paragraph should use directive form: {md}"
17062 );
17063 let rt = markdown_to_adf(&md).unwrap();
17064 assert_eq!(rt.content.len(), 1, "should have 1 block");
17065 assert_eq!(rt.content[0].node_type, "paragraph");
17066 let text = rt.content[0].content.as_ref().unwrap()[0]
17067 .text
17068 .as_deref()
17069 .unwrap_or("");
17070 assert_eq!(text, "\u{00a0}", "NBSP should survive round-trip");
17071 }
17072
17073 #[test]
17074 fn nbsp_in_nested_expand_roundtrip() {
17075 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"nestedExpand\",\"attrs\":{\"title\":\"Section\"},\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}]}";
17077 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17078 let md = adf_to_markdown(&doc).unwrap();
17079 let rt = markdown_to_adf(&md).unwrap();
17080 let ne = &rt.content[0];
17081 assert_eq!(ne.node_type, "nestedExpand");
17082 let inner = ne.content.as_ref().unwrap();
17083 assert_eq!(inner.len(), 1, "should have 1 inner block");
17084 assert_eq!(inner[0].node_type, "paragraph");
17085 let content = inner[0].content.as_ref().unwrap();
17086 assert!(!content.is_empty(), "paragraph should not be empty");
17087 let text = content[0].text.as_deref().unwrap_or("");
17088 assert_eq!(text, "\u{00a0}", "NBSP should survive in nestedExpand");
17089 }
17090
17091 #[test]
17092 fn nbsp_followed_by_content() {
17093 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\"}]}]}";
17095 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17096 let md = adf_to_markdown(&doc).unwrap();
17097 let rt = markdown_to_adf(&md).unwrap();
17098 assert!(rt.content.len() >= 2, "should have at least 2 blocks");
17099 let after_para = rt.content.iter().find(|n| {
17101 n.node_type == "paragraph"
17102 && n.content
17103 .as_ref()
17104 .and_then(|c| c.first())
17105 .and_then(|n| n.text.as_deref())
17106 .is_some_and(|t| t.contains("after"))
17107 });
17108 assert!(after_para.is_some(), "should have paragraph with 'after'");
17109 }
17110
17111 #[test]
17112 fn nbsp_paragraph_with_marks_survives() {
17113 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\",\"marks\":[{\"type\":\"strong\"}]}]}]}";
17116 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17117 let md = adf_to_markdown(&doc).unwrap();
17118 assert!(md.contains("**"), "should have bold markers: {md}");
17119 let rt = markdown_to_adf(&md).unwrap();
17120 let content = rt.content[0].content.as_ref().unwrap();
17121 assert!(!content.is_empty(), "should preserve content");
17122 }
17123
17124 #[test]
17125 fn regular_paragraph_unchanged() {
17126 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}"#;
17128 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17129 let md = adf_to_markdown(&doc).unwrap();
17130 assert!(
17131 !md.contains("::paragraph"),
17132 "regular paragraphs should not use directive form: {md}"
17133 );
17134 assert!(md.contains("hello"));
17135 }
17136
17137 #[test]
17138 fn paragraph_directive_with_content_parsed() {
17139 let md = "::paragraph[\u{00a0}]\n";
17141 let doc = markdown_to_adf(md).unwrap();
17142 assert_eq!(doc.content.len(), 1);
17143 assert_eq!(doc.content[0].node_type, "paragraph");
17144 let content = doc.content[0].content.as_ref().unwrap();
17145 assert!(!content.is_empty(), "should have inline content");
17146 assert_eq!(content[0].text.as_deref().unwrap(), "\u{00a0}");
17147 }
17148
17149 #[test]
17150 fn nbsp_paragraph_in_list_item_with_nested_list() {
17151 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"}]}]}]}]}]}]}"#;
17153 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17154 let md = adf_to_markdown(&doc).unwrap();
17155 let rt = markdown_to_adf(&md).unwrap();
17156 let list = &rt.content[0];
17157 assert_eq!(list.node_type, "bulletList");
17158 let item = &list.content.as_ref().unwrap()[0];
17159 let item_content = item.content.as_ref().unwrap();
17160 assert_eq!(
17161 item_content.len(),
17162 2,
17163 "listItem should have paragraph + nested list, got: {item_content:?}"
17164 );
17165 let para = &item_content[0];
17166 assert_eq!(para.node_type, "paragraph");
17167 let para_content = para
17168 .content
17169 .as_ref()
17170 .expect("paragraph should have content");
17171 assert!(
17172 !para_content.is_empty(),
17173 "NBSP paragraph content should not be empty"
17174 );
17175 assert_eq!(
17176 para_content[0].text.as_deref().unwrap(),
17177 "\u{00a0}",
17178 "NBSP should survive round-trip inside listItem"
17179 );
17180 }
17181
17182 #[test]
17183 fn nbsp_paragraph_in_list_item_with_local_ids() {
17184 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"}]}]}]}]}]}]}"#;
17186 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17187 let md = adf_to_markdown(&doc).unwrap();
17188 let rt = markdown_to_adf(&md).unwrap();
17189 let list = &rt.content[0];
17190 let item = &list.content.as_ref().unwrap()[0];
17191 assert_eq!(
17193 item.attrs.as_ref().unwrap()["localId"],
17194 "li-001",
17195 "listItem localId should survive"
17196 );
17197 let item_content = item.content.as_ref().unwrap();
17198 assert_eq!(item_content.len(), 2);
17199 let para = &item_content[0];
17201 assert_eq!(
17202 para.attrs.as_ref().unwrap()["localId"],
17203 "p-001",
17204 "paragraph localId should survive"
17205 );
17206 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
17207 assert_eq!(text, "\u{00a0}", "NBSP should survive with localIds");
17208 }
17209
17210 #[test]
17211 fn nbsp_paragraph_in_list_item_without_nested_list() {
17212 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"}]}]}]}]}"#;
17214 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17215 let md = adf_to_markdown(&doc).unwrap();
17216 let rt = markdown_to_adf(&md).unwrap();
17217 let list = &rt.content[0];
17218 let item = &list.content.as_ref().unwrap()[0];
17219 let para = &item.content.as_ref().unwrap()[0];
17220 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
17221 assert_eq!(text, "\u{00a0}", "NBSP should survive in simple list item");
17222 }
17223
17224 #[test]
17225 fn nbsp_paragraph_in_ordered_list_item_with_nested_list() {
17226 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"}]}]}]}]}]}]}"#;
17228 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17229 let md = adf_to_markdown(&doc).unwrap();
17230 let rt = markdown_to_adf(&md).unwrap();
17231 let list = &rt.content[0];
17232 let item = &list.content.as_ref().unwrap()[0];
17233 let item_content = item.content.as_ref().unwrap();
17234 assert_eq!(item_content.len(), 2);
17235 let para = &item_content[0];
17236 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
17237 assert_eq!(text, "\u{00a0}", "NBSP should survive in ordered list item");
17238 }
17239
17240 #[test]
17241 fn list_item_leading_space_preserved() {
17242 let md = "- hello world\n- - text";
17244 let doc = markdown_to_adf(md).unwrap();
17245 let list = &doc.content[0];
17246 assert_eq!(list.node_type, "bulletList");
17247 let items = list.content.as_ref().unwrap();
17248 let first_para = &items[0].content.as_ref().unwrap()[0];
17250 let first_text = &first_para.content.as_ref().unwrap()[0];
17251 assert_eq!(first_text.text.as_deref(), Some("hello world"));
17252 }
17253
17254 #[test]
17255 fn list_item_leading_space_not_stripped() {
17256 let md = "- leading space text";
17259 let doc = markdown_to_adf(md).unwrap();
17260 let list = &doc.content[0];
17261 let items = list.content.as_ref().unwrap();
17262 let para = &items[0].content.as_ref().unwrap()[0];
17263 let text_node = ¶.content.as_ref().unwrap()[0];
17264 assert_eq!(
17266 text_node.text.as_deref(),
17267 Some(" leading space text"),
17268 "leading space should be preserved"
17269 );
17270 }
17271
17272 #[test]
17277 fn hardbreak_in_cell_uses_directive_table() {
17278 let adf = AdfDocument {
17281 version: 1,
17282 doc_type: "doc".to_string(),
17283 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17284 AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17285 AdfNode::text("before"),
17286 AdfNode::hard_break(),
17287 AdfNode::text("after"),
17288 ])]),
17289 ])])],
17290 };
17291 let md = adf_to_markdown(&adf).unwrap();
17292 assert!(
17294 md.contains(":::td") || md.contains("::::table"),
17295 "Table with hardBreak should use directive form, got:\n{md}"
17296 );
17297 assert!(
17298 !md.contains("| before"),
17299 "Should NOT use pipe syntax with hardBreak"
17300 );
17301 }
17302
17303 #[test]
17304 fn hardbreak_in_cell_roundtrips() {
17305 let adf = AdfDocument {
17307 version: 1,
17308 doc_type: "doc".to_string(),
17309 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17310 AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17311 AdfNode::text("line one"),
17312 AdfNode::hard_break(),
17313 AdfNode::text("line two"),
17314 ])]),
17315 ])])],
17316 };
17317 let md = adf_to_markdown(&adf).unwrap();
17318 let roundtripped = markdown_to_adf(&md).unwrap();
17319
17320 assert_eq!(roundtripped.content.len(), 1);
17322 assert_eq!(roundtripped.content[0].node_type, "table");
17323 let rows = roundtripped.content[0].content.as_ref().unwrap();
17324 assert_eq!(
17325 rows.len(),
17326 1,
17327 "Should have exactly 1 row, got {}",
17328 rows.len()
17329 );
17330 }
17331
17332 #[test]
17333 fn hardbreak_in_paragraph_roundtrips() {
17334 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17336 {"type":"text","text":"line one"},
17337 {"type":"hardBreak"},
17338 {"type":"text","text":"line two"}
17339 ]}]}"#;
17340 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17341 let md = adf_to_markdown(&doc).unwrap();
17342 let round_tripped = markdown_to_adf(&md).unwrap();
17343 let inlines = round_tripped.content[0].content.as_ref().unwrap();
17344 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17345 assert_eq!(
17346 types,
17347 vec!["text", "hardBreak", "text"],
17348 "hardBreak should be preserved, got: {types:?}"
17349 );
17350 assert_eq!(inlines[0].text.as_deref(), Some("line one"));
17351 assert_eq!(inlines[2].text.as_deref(), Some("line two"));
17352 }
17353
17354 #[test]
17355 fn consecutive_hardbreaks_in_paragraph_roundtrip() {
17356 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17358 {"type":"text","text":"before"},
17359 {"type":"hardBreak"},
17360 {"type":"hardBreak"},
17361 {"type":"text","text":"after"}
17362 ]}]}"#;
17363 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17364 let md = adf_to_markdown(&doc).unwrap();
17365 let round_tripped = markdown_to_adf(&md).unwrap();
17366 assert_eq!(
17367 round_tripped.content.len(),
17368 1,
17369 "Should remain a single paragraph, got {} blocks",
17370 round_tripped.content.len()
17371 );
17372 let inlines = round_tripped.content[0].content.as_ref().unwrap();
17373 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17374 assert_eq!(
17375 types,
17376 vec!["text", "hardBreak", "hardBreak", "text"],
17377 "Both hardBreaks should be preserved, got: {types:?}"
17378 );
17379 assert_eq!(inlines[0].text.as_deref(), Some("before"));
17380 assert_eq!(inlines[3].text.as_deref(), Some("after"));
17381 }
17382
17383 #[test]
17384 fn hardbreak_only_paragraph_roundtrips() {
17385 let adf_json = r#"{"version":1,"type":"doc","content":[
17387 {"type":"paragraph","content":[{"type":"hardBreak"}]}
17388 ]}"#;
17389 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17390 let md = adf_to_markdown(&doc).unwrap();
17391 let round_tripped = markdown_to_adf(&md).unwrap();
17392 assert_eq!(
17393 round_tripped.content.len(),
17394 1,
17395 "Paragraph should not be dropped, got {} blocks",
17396 round_tripped.content.len()
17397 );
17398 let inlines = round_tripped.content[0].content.as_ref().unwrap();
17399 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17400 assert_eq!(
17401 types,
17402 vec!["hardBreak"],
17403 "hardBreak-only paragraph should preserve its content, got: {types:?}"
17404 );
17405 }
17406
17407 #[test]
17408 fn issue_410_full_reproducer_roundtrips() {
17409 let adf_json = r#"{"version":1,"type":"doc","content":[
17411 {"type":"paragraph","content":[
17412 {"type":"text","text":"before"},
17413 {"type":"hardBreak"},
17414 {"type":"hardBreak"},
17415 {"type":"text","text":"after"}
17416 ]},
17417 {"type":"paragraph","content":[
17418 {"type":"hardBreak"}
17419 ]}
17420 ]}"#;
17421 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17422 let md = adf_to_markdown(&doc).unwrap();
17423 let round_tripped = markdown_to_adf(&md).unwrap();
17424 assert_eq!(
17425 round_tripped.content.len(),
17426 2,
17427 "Should have exactly 2 paragraphs, got {}",
17428 round_tripped.content.len()
17429 );
17430 let p1 = round_tripped.content[0].content.as_ref().unwrap();
17432 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
17433 assert_eq!(types1, vec!["text", "hardBreak", "hardBreak", "text"]);
17434 let p2 = round_tripped.content[1].content.as_ref().unwrap();
17436 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
17437 assert_eq!(types2, vec!["hardBreak"]);
17438 }
17439
17440 #[test]
17441 fn trailing_space_hardbreak_still_parsed() {
17442 let md = "line one \nline two\n";
17444 let doc = markdown_to_adf(md).unwrap();
17445 let inlines = doc.content[0].content.as_ref().unwrap();
17446 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17447 assert_eq!(
17448 types,
17449 vec!["text", "hardBreak", "text"],
17450 "Trailing-space hardBreak should still parse, got: {types:?}"
17451 );
17452 }
17453
17454 #[test]
17455 fn trailing_hardbreak_at_end_of_paragraph_roundtrips() {
17456 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17458 {"type":"text","text":"text"},
17459 {"type":"hardBreak"}
17460 ]}]}"#;
17461 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17462 let md = adf_to_markdown(&doc).unwrap();
17463 let round_tripped = markdown_to_adf(&md).unwrap();
17464 let inlines = round_tripped.content[0].content.as_ref().unwrap();
17465 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17466 assert_eq!(
17467 types,
17468 vec!["text", "hardBreak"],
17469 "Trailing hardBreak should be preserved, got: {types:?}"
17470 );
17471 }
17472
17473 #[test]
17474 #[test]
17475 fn table_with_header_row_uses_pipe_syntax() {
17476 let adf = AdfDocument {
17478 version: 1,
17479 doc_type: "doc".to_string(),
17480 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17481 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("header cell")])]),
17482 ])])],
17483 };
17484 let md = adf_to_markdown(&adf).unwrap();
17485 assert!(
17486 md.contains("| header cell |"),
17487 "Table with header row should use pipe syntax, got:\n{md}"
17488 );
17489 }
17490
17491 #[test]
17492 fn table_without_header_row_uses_directive_syntax() {
17493 let adf = AdfDocument {
17496 version: 1,
17497 doc_type: "doc".to_string(),
17498 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17499 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("simple cell")])]),
17500 ])])],
17501 };
17502 let md = adf_to_markdown(&adf).unwrap();
17503 assert!(
17504 md.contains("::::table"),
17505 "Table without header row should use directive syntax, got:\n{md}"
17506 );
17507 }
17508
17509 #[test]
17510 fn tablecell_first_row_preserved_on_roundtrip() {
17511 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[
17513 {"type":"tableRow","content":[
17514 {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row1 cell"}]}]}
17515 ]},
17516 {"type":"tableRow","content":[
17517 {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row2 cell"}]}]}
17518 ]}
17519 ]}]}"#;
17520 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17521 let md = adf_to_markdown(&doc).unwrap();
17522 let round_tripped = markdown_to_adf(&md).unwrap();
17523 let rows = round_tripped.content[0].content.as_ref().unwrap();
17524 let row0_cell = &rows[0].content.as_ref().unwrap()[0];
17525 assert_eq!(
17526 row0_cell.node_type, "tableCell",
17527 "first row cell should remain tableCell, got: {}",
17528 row0_cell.node_type
17529 );
17530 let row1_cell = &rows[1].content.as_ref().unwrap()[0];
17531 assert_eq!(row1_cell.node_type, "tableCell");
17532 }
17533
17534 #[test]
17535 fn mixed_header_and_cell_first_row_uses_pipe() {
17536 let adf = AdfDocument {
17538 version: 1,
17539 doc_type: "doc".to_string(),
17540 content: vec![AdfNode::table(vec![
17541 AdfNode::table_row(vec![
17542 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17543 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17544 ]),
17545 AdfNode::table_row(vec![
17546 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
17547 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
17548 ]),
17549 ])],
17550 };
17551 let md = adf_to_markdown(&adf).unwrap();
17552 assert!(
17553 md.contains("| H1 |"),
17554 "Table with header first row should use pipe syntax, got:\n{md}"
17555 );
17556 assert!(!md.contains("::::table"), "should not use directive syntax");
17557 }
17558
17559 #[test]
17562 fn render_pipe_table_escapes_pipe_in_code_span_cell() {
17563 let adf = AdfDocument {
17566 version: 1,
17567 doc_type: "doc".to_string(),
17568 content: vec![AdfNode::table(vec![
17569 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17570 AdfNode::text("Header"),
17571 ])])]),
17572 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17573 AdfNode::text_with_marks("a|b", vec![AdfMark::code()]),
17574 ])])]),
17575 ])],
17576 };
17577 let md = adf_to_markdown(&adf).unwrap();
17578 assert!(
17579 md.contains(r"`a\|b`"),
17580 "Pipe inside code span must be escaped, got:\n{md}"
17581 );
17582 }
17583
17584 #[test]
17585 fn render_pipe_table_escapes_pipe_in_plain_text_cell() {
17586 let adf = AdfDocument {
17587 version: 1,
17588 doc_type: "doc".to_string(),
17589 content: vec![AdfNode::table(vec![
17590 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17591 AdfNode::text("Header"),
17592 ])])]),
17593 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17594 AdfNode::text("x|y"),
17595 ])])]),
17596 ])],
17597 };
17598 let md = adf_to_markdown(&adf).unwrap();
17599 assert!(
17600 md.contains(r"x\|y"),
17601 "Pipe inside plain-text cell must be escaped, got:\n{md}"
17602 );
17603 }
17604
17605 #[test]
17606 fn code_span_with_pipe_in_table_cell_roundtrips() {
17607 let adf_json = r#"{
17609 "version": 1,
17610 "type": "doc",
17611 "content": [{
17612 "type": "table",
17613 "attrs": {"isNumberColumnEnabled": false, "layout": "default", "localId": "abc-789"},
17614 "content": [
17615 {"type": "tableRow", "content": [
17616 {"type": "tableHeader", "attrs": {}, "content": [
17617 {"type": "paragraph", "content": [{"type": "text", "text": "Before"}]}
17618 ]},
17619 {"type": "tableHeader", "attrs": {}, "content": [
17620 {"type": "paragraph", "content": [{"type": "text", "text": "After"}]}
17621 ]}
17622 ]},
17623 {"type": "tableRow", "content": [
17624 {"type": "tableCell", "attrs": {}, "content": [
17625 {"type": "paragraph", "content": [
17626 {"type": "text", "text": "parse(json).extract[T]", "marks": [{"type": "code"}]}
17627 ]}
17628 ]},
17629 {"type": "tableCell", "attrs": {}, "content": [
17630 {"type": "paragraph", "content": [
17631 {"type": "text", "text": "parser.decode[T|json]", "marks": [{"type": "code"}]}
17632 ]}
17633 ]}
17634 ]}
17635 ]
17636 }]
17637 }"#;
17638 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17639 let md = adf_to_markdown(&doc).unwrap();
17640 let round_tripped = markdown_to_adf(&md).unwrap();
17641
17642 let rows = round_tripped.content[0].content.as_ref().unwrap();
17643 assert_eq!(
17644 rows.len(),
17645 2,
17646 "Table should have 2 rows, got: {}",
17647 rows.len()
17648 );
17649
17650 let body_row = rows[1].content.as_ref().unwrap();
17651 assert_eq!(
17652 body_row.len(),
17653 2,
17654 "Body row should have 2 cells (not split by the pipe), got: {}",
17655 body_row.len()
17656 );
17657
17658 let second_cell = &body_row[1];
17659 let para = second_cell.content.as_ref().unwrap().first().unwrap();
17660 let inlines = para.content.as_ref().unwrap();
17661 assert_eq!(inlines.len(), 1, "Cell should have a single text node");
17662 assert_eq!(
17663 inlines[0].text.as_deref(),
17664 Some("parser.decode[T|json]"),
17665 "Code-span text must be preserved with literal pipe"
17666 );
17667 let marks = inlines[0]
17668 .marks
17669 .as_ref()
17670 .expect("code mark must be preserved");
17671 assert!(
17672 marks.iter().any(|m| m.mark_type == "code"),
17673 "text node should carry the code mark"
17674 );
17675 }
17676
17677 #[test]
17678 fn plain_text_pipe_in_table_cell_roundtrips() {
17679 let adf = AdfDocument {
17681 version: 1,
17682 doc_type: "doc".to_string(),
17683 content: vec![AdfNode::table(vec![
17684 AdfNode::table_row(vec![
17685 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17686 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17687 ]),
17688 AdfNode::table_row(vec![
17689 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a|b")])]),
17690 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("c")])]),
17691 ]),
17692 ])],
17693 };
17694 let md = adf_to_markdown(&adf).unwrap();
17695 let round_tripped = markdown_to_adf(&md).unwrap();
17696 let rows = round_tripped.content[0].content.as_ref().unwrap();
17697 let body_row = rows[1].content.as_ref().unwrap();
17698 assert_eq!(
17699 body_row.len(),
17700 2,
17701 "Body row should keep 2 cells, got: {}",
17702 body_row.len()
17703 );
17704 let first_cell_text = body_row[0].content.as_ref().unwrap()[0]
17705 .content
17706 .as_ref()
17707 .unwrap()[0]
17708 .text
17709 .as_deref();
17710 assert_eq!(first_cell_text, Some("a|b"));
17711 }
17712
17713 #[test]
17714 fn cell_contains_hard_break_true() {
17715 let para = AdfNode::paragraph(vec![
17716 AdfNode::text("a"),
17717 AdfNode::hard_break(),
17718 AdfNode::text("b"),
17719 ]);
17720 assert!(cell_contains_hard_break(¶));
17721 }
17722
17723 #[test]
17724 fn cell_contains_hard_break_false() {
17725 let para = AdfNode::paragraph(vec![AdfNode::text("no break here")]);
17726 assert!(!cell_contains_hard_break(¶));
17727 }
17728
17729 #[test]
17730 fn cell_contains_hard_break_empty() {
17731 let para = AdfNode::paragraph(vec![]);
17732 assert!(!cell_contains_hard_break(¶));
17733 }
17734
17735 #[test]
17738 fn multi_paragraph_panel_roundtrips() {
17739 let adf = AdfDocument {
17740 version: 1,
17741 doc_type: "doc".to_string(),
17742 content: vec![AdfNode {
17743 node_type: "panel".to_string(),
17744 attrs: Some(serde_json::json!({"panelType": "info"})),
17745 content: Some(vec![
17746 AdfNode::paragraph(vec![AdfNode::text("First paragraph.")]),
17747 AdfNode::paragraph(vec![AdfNode::text("Second paragraph.")]),
17748 ]),
17749 text: None,
17750 marks: None,
17751 local_id: None,
17752 parameters: None,
17753 }],
17754 };
17755
17756 let md = adf_to_markdown(&adf).unwrap();
17757 assert!(
17759 md.contains("First paragraph.\n\nSecond paragraph."),
17760 "Panel should have blank line between paragraphs, got:\n{md}"
17761 );
17762
17763 let roundtripped = markdown_to_adf(&md).unwrap();
17765 assert_eq!(roundtripped.content.len(), 1);
17766 assert_eq!(roundtripped.content[0].node_type, "panel");
17767 let panel_content = roundtripped.content[0].content.as_ref().unwrap();
17768 assert_eq!(
17769 panel_content.len(),
17770 2,
17771 "Panel should have 2 paragraphs after round-trip, got {}",
17772 panel_content.len()
17773 );
17774 }
17775
17776 #[test]
17777 fn multi_paragraph_expand_roundtrips() {
17778 let adf = AdfDocument {
17779 version: 1,
17780 doc_type: "doc".to_string(),
17781 content: vec![AdfNode {
17782 node_type: "expand".to_string(),
17783 attrs: Some(serde_json::json!({"title": "Details"})),
17784 content: Some(vec![
17785 AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17786 AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17787 ]),
17788 text: None,
17789 marks: None,
17790 local_id: None,
17791 parameters: None,
17792 }],
17793 };
17794
17795 let md = adf_to_markdown(&adf).unwrap();
17796 let roundtripped = markdown_to_adf(&md).unwrap();
17797 let expand_content = roundtripped.content[0].content.as_ref().unwrap();
17798 assert_eq!(
17799 expand_content.len(),
17800 2,
17801 "Expand should have 2 paragraphs after round-trip, got {}",
17802 expand_content.len()
17803 );
17804 }
17805
17806 #[test]
17807 fn consecutive_nested_expands_in_table_cell_roundtrip() {
17808 let cell_content = vec![
17809 AdfNode {
17810 node_type: "nestedExpand".to_string(),
17811 attrs: Some(serde_json::json!({"title": "First"})),
17812 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 1")])]),
17813 text: None,
17814 marks: None,
17815 local_id: None,
17816 parameters: None,
17817 },
17818 AdfNode {
17819 node_type: "nestedExpand".to_string(),
17820 attrs: Some(serde_json::json!({"title": "Second"})),
17821 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 2")])]),
17822 text: None,
17823 marks: None,
17824 local_id: None,
17825 parameters: None,
17826 },
17827 ];
17828 let adf = AdfDocument {
17829 version: 1,
17830 doc_type: "doc".to_string(),
17831 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17832 AdfNode::table_cell(cell_content),
17833 ])])],
17834 };
17835
17836 let md = adf_to_markdown(&adf).unwrap();
17837 assert!(
17838 md.contains(":::\n\n:::nested-expand"),
17839 "Should have blank line between consecutive nested-expands in cell, got:\n{md}"
17840 );
17841
17842 let rt = markdown_to_adf(&md).unwrap();
17843 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17844 .content
17845 .as_ref()
17846 .unwrap()[0];
17847 let cell_nodes = cell.content.as_ref().unwrap();
17848 let expand_count = cell_nodes
17849 .iter()
17850 .filter(|n| n.node_type == "nestedExpand")
17851 .count();
17852 assert_eq!(
17853 expand_count, 2,
17854 "Both nested-expands should survive round-trip, got {expand_count}"
17855 );
17856 }
17857
17858 #[test]
17859 fn multi_paragraph_in_table_cell_roundtrip() {
17860 let adf = AdfDocument {
17862 version: 1,
17863 doc_type: "doc".to_string(),
17864 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17865 AdfNode::table_cell(vec![
17866 AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17867 AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17868 ]),
17869 ])])],
17870 };
17871
17872 let md = adf_to_markdown(&adf).unwrap();
17873 assert!(
17874 md.contains("Para one.\n\nPara two."),
17875 "Should have blank line between paragraphs in cell, got:\n{md}"
17876 );
17877
17878 let rt = markdown_to_adf(&md).unwrap();
17879 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17880 .content
17881 .as_ref()
17882 .unwrap()[0];
17883 let para_count = cell
17884 .content
17885 .as_ref()
17886 .unwrap()
17887 .iter()
17888 .filter(|n| n.node_type == "paragraph")
17889 .count();
17890 assert_eq!(para_count, 2, "Both paragraphs should survive round-trip");
17891 }
17892
17893 #[test]
17894 fn panel_inside_table_cell_roundtrip() {
17895 let adf = AdfDocument {
17897 version: 1,
17898 doc_type: "doc".to_string(),
17899 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17900 AdfNode::table_cell(vec![
17901 AdfNode::paragraph(vec![AdfNode::text("Before panel.")]),
17902 AdfNode {
17903 node_type: "panel".to_string(),
17904 attrs: Some(serde_json::json!({"panelType": "info"})),
17905 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text(
17906 "Panel content",
17907 )])]),
17908 text: None,
17909 marks: None,
17910 local_id: None,
17911 parameters: None,
17912 },
17913 ]),
17914 ])])],
17915 };
17916
17917 let md = adf_to_markdown(&adf).unwrap();
17918 assert!(
17919 md.contains(":::panel"),
17920 "Should contain panel directive, got:\n{md}"
17921 );
17922
17923 let rt = markdown_to_adf(&md).unwrap();
17924 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17925 .content
17926 .as_ref()
17927 .unwrap()[0];
17928 let has_panel = cell
17929 .content
17930 .as_ref()
17931 .unwrap()
17932 .iter()
17933 .any(|n| n.node_type == "panel");
17934 assert!(has_panel, "Panel should survive round-trip in table cell");
17935 }
17936
17937 #[test]
17938 fn three_consecutive_expands_in_table_cell() {
17939 let make_expand = |title: &str| AdfNode {
17940 node_type: "nestedExpand".to_string(),
17941 attrs: Some(serde_json::json!({"title": title})),
17942 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("content")])]),
17943 text: None,
17944 marks: None,
17945 local_id: None,
17946 parameters: None,
17947 };
17948 let adf = AdfDocument {
17949 version: 1,
17950 doc_type: "doc".to_string(),
17951 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17952 AdfNode::table_cell(vec![
17953 make_expand("First"),
17954 make_expand("Second"),
17955 make_expand("Third"),
17956 ]),
17957 ])])],
17958 };
17959
17960 let md = adf_to_markdown(&adf).unwrap();
17961 let rt = markdown_to_adf(&md).unwrap();
17962 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17963 .content
17964 .as_ref()
17965 .unwrap()[0];
17966 let expand_count = cell
17967 .content
17968 .as_ref()
17969 .unwrap()
17970 .iter()
17971 .filter(|n| n.node_type == "nestedExpand")
17972 .count();
17973 assert_eq!(expand_count, 3, "All 3 expands should survive round-trip");
17974 }
17975
17976 #[test]
17979 fn nested_expand_inside_panel() {
17980 let md = ":::panel{type=info}\n:::expand{title=\"Details\"}\nHidden content\n:::\nMore panel content\n:::";
17985 let adf = markdown_to_adf(md).unwrap();
17986
17987 let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
17988 assert!(err.violations.iter().any(|v| matches!(
17989 v,
17990 crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
17991 parent_type, child_type, ..
17992 } if parent_type == "panel" && child_type == "expand"
17993 )));
17994 }
17995
17996 #[test]
17997 fn nested_expand_inside_table_cell() {
17998 let md = "::::table\n:::tr\n:::td\n:::expand{title=\"Details\"}\nExpand content\n:::\n:::\n:::\n::::";
18002 let adf = markdown_to_adf(md).unwrap();
18003
18004 let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
18005 assert!(err.violations.iter().any(|v| matches!(
18006 v,
18007 crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
18008 parent_type, child_type, ..
18009 } if parent_type == "tableCell" && child_type == "expand"
18010 )));
18011 }
18012
18013 #[test]
18014 fn nested_expand_inside_layout_column() {
18015 let md = ":::layout\n:::column{width=50}\n:::expand{title=\"Col Expand\"}\nExpanded\n:::\n:::\n:::column{width=50}\nFiller paragraph.\n:::\n:::";
18020 let adf = markdown_to_adf(md).unwrap();
18021
18022 assert_eq!(adf.content.len(), 1);
18023 assert_eq!(adf.content[0].node_type, "layoutSection");
18024
18025 let columns = adf.content[0].content.as_ref().unwrap();
18026 assert_eq!(columns.len(), 2);
18027 let col_content = columns[0].content.as_ref().unwrap();
18028 assert!(
18029 col_content.iter().any(|n| n.node_type == "expand"),
18030 "Column should contain an expand node, got: {:?}",
18031 col_content.iter().map(|n| &n.node_type).collect::<Vec<_>>()
18032 );
18033
18034 crate::atlassian::adf_validated::validate(&adf).unwrap();
18036 }
18037
18038 #[test]
18039 fn expand_localid_in_directive_attrs() {
18040 let adf_json = r#"{"version":1,"type":"doc","content":[
18042 {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
18043 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
18044 ]}
18045 ]}"#;
18046 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18047 let md = adf_to_markdown(&doc).unwrap();
18048 assert!(
18049 md.contains("localId=exp-001"),
18050 "should contain localId: {md}"
18051 );
18052 assert!(
18053 md.contains(":::expand{"),
18054 "should have expand directive with attrs: {md}"
18055 );
18056 assert!(
18057 !md.contains(":::\n{localId="),
18058 "localId should NOT be trailing: {md}"
18059 );
18060 }
18061
18062 #[test]
18063 fn expand_localid_roundtrip() {
18064 let adf_json = r#"{"version":1,"type":"doc","content":[
18065 {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
18066 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
18067 ]}
18068 ]}"#;
18069 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18070 let md = adf_to_markdown(&doc).unwrap();
18071 let rt = markdown_to_adf(&md).unwrap();
18072 let expand = &rt.content[0];
18073 assert_eq!(expand.node_type, "expand");
18074 assert_eq!(
18075 expand.local_id.as_deref(),
18076 Some("exp-001"),
18077 "expand localId should survive round-trip"
18078 );
18079 assert_eq!(
18080 expand.attrs.as_ref().unwrap()["title"],
18081 "Details",
18082 "expand title should survive round-trip"
18083 );
18084 }
18085
18086 #[test]
18087 fn nested_expand_localid_roundtrip() {
18088 let adf_json = r#"{"version":1,"type":"doc","content":[
18089 {"type":"nestedExpand","attrs":{"localId":"ne-001","title":"S"},"content":[
18090 {"type":"paragraph","content":[{"type":"text","text":"content"}]}
18091 ]}
18092 ]}"#;
18093 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18094 let md = adf_to_markdown(&doc).unwrap();
18095 assert!(
18096 md.contains(":::nested-expand{"),
18097 "should have directive: {md}"
18098 );
18099 assert!(md.contains("localId=ne-001"), "should have localId: {md}");
18100 let rt = markdown_to_adf(&md).unwrap();
18101 let ne = &rt.content[0];
18102 assert_eq!(ne.node_type, "nestedExpand");
18103 assert_eq!(ne.local_id.as_deref(), Some("ne-001"));
18104 }
18105
18106 #[test]
18107 fn nested_expand_localid_followed_by_content() {
18108 let adf_json = "{\
18110 \"version\":1,\"type\":\"doc\",\"content\":[\
18111 {\"type\":\"nestedExpand\",\"attrs\":{\"localId\":\"exp-001\",\"title\":\"S\"},\"content\":[\
18112 {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}\
18113 ]},\
18114 {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]}\
18115 ]}";
18116 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18117 let md = adf_to_markdown(&doc).unwrap();
18118 let rt = markdown_to_adf(&md).unwrap();
18119 let ne = &rt.content[0];
18121 assert_eq!(ne.node_type, "nestedExpand");
18122 assert_eq!(
18123 ne.local_id.as_deref(),
18124 Some("exp-001"),
18125 "nestedExpand should preserve localId"
18126 );
18127 let para = &rt.content[1];
18129 assert_eq!(para.node_type, "paragraph");
18130 let text = para.content.as_ref().unwrap()[0]
18131 .text
18132 .as_deref()
18133 .unwrap_or("");
18134 assert!(
18135 !text.contains("localId"),
18136 "following paragraph should not contain localId: {text}"
18137 );
18138 assert!(
18139 text.contains("after"),
18140 "following paragraph should contain 'after': {text}"
18141 );
18142 }
18143
18144 #[test]
18145 fn expand_localid_without_title() {
18146 let adf_json = r#"{"version":1,"type":"doc","content":[
18147 {"type":"expand","attrs":{"localId":"exp-002"},"content":[
18148 {"type":"paragraph","content":[{"type":"text","text":"no title"}]}
18149 ]}
18150 ]}"#;
18151 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18152 let md = adf_to_markdown(&doc).unwrap();
18153 assert!(
18154 md.contains(":::expand{localId=exp-002}"),
18155 "should have localId without title: {md}"
18156 );
18157 let rt = markdown_to_adf(&md).unwrap();
18158 assert_eq!(rt.content[0].local_id.as_deref(), Some("exp-002"));
18159 }
18160
18161 #[test]
18162 fn expand_localid_stripped() {
18163 let adf_json = r#"{"version":1,"type":"doc","content":[
18164 {"type":"expand","attrs":{"localId":"exp-001","title":"X"},"content":[
18165 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
18166 ]}
18167 ]}"#;
18168 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18169 let opts = RenderOptions {
18170 strip_local_ids: true,
18171 };
18172 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18173 assert!(!md.contains("localId"), "localId should be stripped: {md}");
18174 assert!(
18175 md.contains(":::expand{title=\"X\"}"),
18176 "title should remain: {md}"
18177 );
18178 }
18179
18180 #[test]
18183 fn expand_top_level_localid_roundtrip() {
18184 let adf_json = r#"{"version":1,"type":"doc","content":[
18186 {"type":"expand","attrs":{"title":"My Section"},"localId":"abc-123","content":[
18187 {"type":"paragraph","content":[{"type":"text","text":"hello"}]}
18188 ]}
18189 ]}"#;
18190 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18191 assert_eq!(doc.content[0].local_id.as_deref(), Some("abc-123"));
18192 let md = adf_to_markdown(&doc).unwrap();
18193 assert!(
18194 md.contains("localId=abc-123"),
18195 "JFM should contain localId: {md}"
18196 );
18197 let rt = markdown_to_adf(&md).unwrap();
18198 let expand = &rt.content[0];
18199 assert_eq!(expand.node_type, "expand");
18200 assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
18201 assert_eq!(
18202 expand.attrs.as_ref().unwrap()["title"],
18203 "My Section",
18204 "title should survive round-trip"
18205 );
18206 }
18207
18208 #[test]
18209 fn expand_parameters_roundtrip() {
18210 let adf_json = r#"{"version":1,"type":"doc","content":[
18212 {"type":"expand","attrs":{"title":"Props"},"parameters":{"macroMetadata":{"macroId":{"value":"m-001"},"schemaVersion":{"value":"1"}}},"content":[
18213 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
18214 ]}
18215 ]}"#;
18216 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18217 assert!(doc.content[0].parameters.is_some());
18218 let md = adf_to_markdown(&doc).unwrap();
18219 assert!(md.contains("params="), "JFM should contain params: {md}");
18220 let rt = markdown_to_adf(&md).unwrap();
18221 let expand = &rt.content[0];
18222 let params = expand
18223 .parameters
18224 .as_ref()
18225 .expect("parameters should survive round-trip");
18226 assert_eq!(params["macroMetadata"]["macroId"]["value"], "m-001");
18227 assert_eq!(params["macroMetadata"]["schemaVersion"]["value"], "1");
18228 }
18229
18230 #[test]
18231 fn expand_localid_and_parameters_roundtrip() {
18232 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"}]}]}]}"#;
18234 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18235 let md = adf_to_markdown(&doc).unwrap();
18236 let rt = markdown_to_adf(&md).unwrap();
18237 let expand = &rt.content[0];
18238 assert_eq!(expand.node_type, "expand");
18239 assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
18240 assert_eq!(expand.attrs.as_ref().unwrap()["title"], "My Section");
18241 let params = expand
18242 .parameters
18243 .as_ref()
18244 .expect("parameters should survive");
18245 assert_eq!(params["macroMetadata"]["macroId"]["value"], "macro-001");
18246 assert_eq!(params["macroMetadata"]["title"], "Page Properties");
18247 }
18248
18249 #[test]
18250 fn nested_expand_top_level_localid_and_parameters_roundtrip() {
18251 let adf_json = r#"{"version":1,"type":"doc","content":[
18252 {"type":"nestedExpand","attrs":{"title":"Nested"},"localId":"ne-100","parameters":{"macroMetadata":{"macroId":{"value":"nm-001"}}},"content":[
18253 {"type":"paragraph","content":[{"type":"text","text":"inner"}]}
18254 ]}
18255 ]}"#;
18256 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18257 let md = adf_to_markdown(&doc).unwrap();
18258 assert!(
18259 md.contains(":::nested-expand{"),
18260 "should use nested-expand: {md}"
18261 );
18262 assert!(md.contains("localId=ne-100"), "should have localId: {md}");
18263 assert!(md.contains("params="), "should have params: {md}");
18264 let rt = markdown_to_adf(&md).unwrap();
18265 let ne = &rt.content[0];
18266 assert_eq!(ne.node_type, "nestedExpand");
18267 assert_eq!(ne.local_id.as_deref(), Some("ne-100"));
18268 assert_eq!(
18269 ne.parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
18270 "nm-001"
18271 );
18272 }
18273
18274 #[test]
18275 fn expand_top_level_localid_stripped() {
18276 let adf_json = r#"{"version":1,"type":"doc","content":[
18278 {"type":"expand","attrs":{"title":"X"},"localId":"exp-strip","content":[
18279 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
18280 ]}
18281 ]}"#;
18282 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18283 let opts = RenderOptions {
18284 strip_local_ids: true,
18285 };
18286 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18287 assert!(!md.contains("localId"), "localId should be stripped: {md}");
18288 assert!(
18289 md.contains(":::expand{title=\"X\"}"),
18290 "title should remain: {md}"
18291 );
18292 }
18293
18294 #[test]
18295 fn expand_parameters_without_localid() {
18296 let adf_json = r#"{"version":1,"type":"doc","content":[
18298 {"type":"expand","attrs":{"title":"P"},"parameters":{"macroMetadata":{"macroId":{"value":"solo"}}},"content":[
18299 {"type":"paragraph","content":[{"type":"text","text":"data"}]}
18300 ]}
18301 ]}"#;
18302 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18303 let md = adf_to_markdown(&doc).unwrap();
18304 assert!(!md.contains("localId"), "no localId: {md}");
18305 assert!(md.contains("params="), "has params: {md}");
18306 let rt = markdown_to_adf(&md).unwrap();
18307 assert!(rt.content[0].local_id.is_none());
18308 assert_eq!(
18309 rt.content[0].parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
18310 "solo"
18311 );
18312 }
18313
18314 #[test]
18315 fn expand_localid_without_parameters() {
18316 let adf_json = r#"{"version":1,"type":"doc","content":[
18318 {"type":"expand","attrs":{"title":"L"},"localId":"lid-only","content":[
18319 {"type":"paragraph","content":[{"type":"text","text":"txt"}]}
18320 ]}
18321 ]}"#;
18322 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18323 let md = adf_to_markdown(&doc).unwrap();
18324 assert!(md.contains("localId=lid-only"), "has localId: {md}");
18325 assert!(!md.contains("params="), "no params: {md}");
18326 let rt = markdown_to_adf(&md).unwrap();
18327 assert_eq!(rt.content[0].local_id.as_deref(), Some("lid-only"));
18328 assert!(rt.content[0].parameters.is_none());
18329 }
18330
18331 #[test]
18332 fn nested_panel_inside_panel() {
18333 let md = ":::panel{type=info}\n:::panel{type=warning}\nInner warning\n:::\n:::";
18334 let adf = markdown_to_adf(md).unwrap();
18335
18336 assert_eq!(adf.content.len(), 1);
18338 assert_eq!(adf.content[0].node_type, "panel");
18339
18340 let panel_content = adf.content[0].content.as_ref().unwrap();
18342 assert!(
18343 panel_content.iter().any(|n| n.node_type == "panel"),
18344 "Outer panel should contain an inner panel, got: {:?}",
18345 panel_content
18346 .iter()
18347 .map(|n| &n.node_type)
18348 .collect::<Vec<_>>()
18349 );
18350 }
18351
18352 #[test]
18353 fn content_after_directive_table_is_preserved() {
18354 let md = "\
18356## Before table
18357
18358::::table{layout=default}
18359:::tr
18360:::th{}
18361Cell
18362:::
18363:::
18364::::
18365
18366## After table
18367
18368Paragraph after.";
18369 let adf = markdown_to_adf(md).unwrap();
18370 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18371 assert_eq!(
18372 types,
18373 vec!["heading", "table", "heading", "paragraph"],
18374 "Content after table was dropped: got {types:?}"
18375 );
18376 }
18377
18378 #[test]
18379 fn paragraph_after_directive_table_is_preserved() {
18380 let md = "\
18382::::table{layout=default}
18383:::tr
18384:::th{}
18385Header
18386:::
18387:::
18388::::
18389
18390Just a paragraph.";
18391 let adf = markdown_to_adf(md).unwrap();
18392 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18393 assert_eq!(
18394 types,
18395 vec!["table", "paragraph"],
18396 "Paragraph after table was dropped: got {types:?}"
18397 );
18398 }
18399
18400 #[test]
18401 fn extension_after_directive_table_is_preserved() {
18402 let md = "\
18404::::table{layout=default}
18405:::tr
18406:::th{}
18407Header
18408:::
18409:::
18410::::
18411
18412::extension{type=com.atlassian.confluence.macro.core key=toc}";
18413 let adf = markdown_to_adf(md).unwrap();
18414 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18415 assert_eq!(
18416 types,
18417 vec!["table", "extension"],
18418 "Extension after table was dropped: got {types:?}"
18419 );
18420 }
18421
18422 #[test]
18423 fn multiple_blocks_after_directive_table() {
18424 let md = "\
18426## Heading 1
18427
18428::::table{layout=default}
18429:::tr
18430:::td{}
18431A
18432:::
18433:::td{}
18434B
18435:::
18436:::
18437::::
18438
18439## Heading 2
18440
18441Some text.
18442
18443---
18444
18445::::table{layout=default}
18446:::tr
18447:::th{}
18448C
18449:::
18450:::
18451::::
18452
18453## Heading 3";
18454 let adf = markdown_to_adf(md).unwrap();
18455 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18456 assert_eq!(
18457 types,
18458 vec![
18459 "heading",
18460 "table",
18461 "heading",
18462 "paragraph",
18463 "rule",
18464 "table",
18465 "heading"
18466 ],
18467 "Content after tables was dropped: got {types:?}"
18468 );
18469 }
18470
18471 #[test]
18474 fn adf_table_caption_to_markdown() {
18475 let doc = AdfDocument {
18476 version: 1,
18477 doc_type: "doc".to_string(),
18478 content: vec![AdfNode::table(vec![
18479 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18480 AdfNode::text("cell"),
18481 ])])]),
18482 AdfNode::caption(vec![AdfNode::text("Table caption")]),
18483 ])],
18484 };
18485 let md = adf_to_markdown(&doc).unwrap();
18486 assert!(
18487 md.contains("::::table"),
18488 "table with caption must use directive form"
18489 );
18490 assert!(
18491 md.contains(":::caption"),
18492 "caption directive missing, got: {md}"
18493 );
18494 assert!(
18495 md.contains("Table caption"),
18496 "caption text missing, got: {md}"
18497 );
18498 }
18499
18500 #[test]
18501 fn directive_table_caption_parses() {
18502 let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nTable caption\n:::\n::::\n";
18503 let doc = markdown_to_adf(md).unwrap();
18504 let table = &doc.content[0];
18505 assert_eq!(table.node_type, "table");
18506 let children = table.content.as_ref().unwrap();
18507 assert_eq!(children.len(), 2, "expected row + caption");
18508 assert_eq!(children[0].node_type, "tableRow");
18509 assert_eq!(children[1].node_type, "caption");
18510 let caption_content = children[1].content.as_ref().unwrap();
18511 assert_eq!(caption_content[0].text.as_deref(), Some("Table caption"));
18512 }
18513
18514 #[test]
18515 fn table_caption_round_trip_from_adf_json() {
18516 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18517 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18518 {"type":"caption","content":[{"type":"text","text":"Table caption"}]}
18519 ]}]}"#;
18520 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18521 let md = adf_to_markdown(&doc).unwrap();
18522 assert!(md.contains("Table caption"), "caption text lost in ADF→JFM");
18523 let round_tripped = markdown_to_adf(&md).unwrap();
18524 let children = round_tripped.content[0].content.as_ref().unwrap();
18525 let caption = children.iter().find(|n| n.node_type == "caption");
18526 assert!(caption.is_some(), "caption lost on round-trip");
18527 let caption_text = caption.unwrap().content.as_ref().unwrap();
18528 assert_eq!(caption_text[0].text.as_deref(), Some("Table caption"));
18529 }
18530
18531 #[test]
18532 fn table_caption_with_inline_marks_round_trips() {
18533 let doc = AdfDocument {
18534 version: 1,
18535 doc_type: "doc".to_string(),
18536 content: vec![AdfNode::table(vec![
18537 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18538 AdfNode::text("data"),
18539 ])])]),
18540 AdfNode::caption(vec![
18541 AdfNode::text("Caption with "),
18542 AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
18543 ]),
18544 ])],
18545 };
18546 let md = adf_to_markdown(&doc).unwrap();
18547 assert!(md.contains("**bold**"), "bold mark missing in caption");
18548 let round_tripped = markdown_to_adf(&md).unwrap();
18549 let caption = round_tripped.content[0]
18550 .content
18551 .as_ref()
18552 .unwrap()
18553 .iter()
18554 .find(|n| n.node_type == "caption")
18555 .expect("caption node missing after round-trip");
18556 let inlines = caption.content.as_ref().unwrap();
18557 let bold_node = inlines.iter().find(|n| {
18558 n.marks
18559 .as_ref()
18560 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
18561 });
18562 assert!(bold_node.is_some(), "bold mark lost in caption round-trip");
18563 }
18564
18565 #[test]
18568 fn table_caption_localid_roundtrip() {
18569 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18570 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18571 {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Table with localId"}]}
18572 ]}]}"#;
18573 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18574 let md = adf_to_markdown(&doc).unwrap();
18575 assert!(
18576 md.contains("localId=abcdef123456"),
18577 "table caption localId should appear in markdown: {md}"
18578 );
18579 let rt = markdown_to_adf(&md).unwrap();
18580 let caption = rt.content[0]
18581 .content
18582 .as_ref()
18583 .unwrap()
18584 .iter()
18585 .find(|n| n.node_type == "caption")
18586 .expect("caption should survive round-trip");
18587 assert_eq!(
18588 caption.attrs.as_ref().unwrap()["localId"],
18589 "abcdef123456",
18590 "table caption localId should round-trip"
18591 );
18592 }
18593
18594 #[test]
18595 fn table_caption_without_localid_unchanged() {
18596 let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nPlain caption\n:::\n::::\n";
18597 let doc = markdown_to_adf(md).unwrap();
18598 let caption = doc.content[0]
18599 .content
18600 .as_ref()
18601 .unwrap()
18602 .iter()
18603 .find(|n| n.node_type == "caption")
18604 .unwrap();
18605 assert!(
18606 caption.attrs.is_none(),
18607 "table caption without localId should not gain attrs"
18608 );
18609 let md2 = adf_to_markdown(&doc).unwrap();
18610 assert!(!md2.contains("localId"), "no localId should appear: {md2}");
18611 }
18612
18613 #[test]
18614 fn table_caption_localid_stripped_when_option_set() {
18615 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18616 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18617 {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Stripped"}]}
18618 ]}]}"#;
18619 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18620 let opts = RenderOptions {
18621 strip_local_ids: true,
18622 ..Default::default()
18623 };
18624 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18625 assert!(
18626 !md.contains("localId"),
18627 "table caption localId should be stripped: {md}"
18628 );
18629 }
18630
18631 #[test]
18632 #[test]
18633 fn tablecell_empty_attrs_preserved_on_roundtrip() {
18634 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"}]}]}]}]}]}"#;
18636 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18637 let md = adf_to_markdown(&doc).unwrap();
18638 let round_tripped = markdown_to_adf(&md).unwrap();
18639 let rows = round_tripped.content[0].content.as_ref().unwrap();
18640 let cell = &rows[0].content.as_ref().unwrap()[0];
18641 assert!(
18642 cell.attrs.is_some(),
18643 "tableCell attrs should be preserved, got None"
18644 );
18645 assert_eq!(
18646 cell.attrs.as_ref().unwrap(),
18647 &serde_json::json!({}),
18648 "tableCell attrs should be an empty object"
18649 );
18650 }
18651
18652 #[test]
18653 fn tablecell_empty_attrs_serialized_in_json() {
18654 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"}]}]}]}]}]}"#;
18656 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18657 let md = adf_to_markdown(&doc).unwrap();
18658 let round_tripped = markdown_to_adf(&md).unwrap();
18659 let json = serde_json::to_string(&round_tripped).unwrap();
18660 assert!(
18661 json.contains(r#""attrs":{}"#),
18662 "serialized JSON should contain \"attrs\":{{}}, got: {json}"
18663 );
18664 }
18665
18666 #[test]
18667 fn tablecell_empty_attrs_renders_braces_in_markdown() {
18668 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"}]}]}]}]}]}"#;
18670 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18671 let md = adf_to_markdown(&doc).unwrap();
18672 assert!(
18674 md.contains("{} hello"),
18675 "cell with empty attrs should render '{{}} hello', got: {md}"
18676 );
18677 assert!(
18678 !md.contains("{} world"),
18679 "cell without attrs should not render '{{}}', got: {md}"
18680 );
18681 }
18682
18683 #[test]
18684 fn tablecell_no_attrs_unchanged_on_roundtrip() {
18685 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"}]}]}]}]}]}"#;
18687 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18688 let md = adf_to_markdown(&doc).unwrap();
18689 let round_tripped = markdown_to_adf(&md).unwrap();
18690 let rows = round_tripped.content[0].content.as_ref().unwrap();
18691 let cell = &rows[0].content.as_ref().unwrap()[0];
18692 assert!(
18693 cell.attrs.is_none(),
18694 "tableCell without attrs should stay None, got: {:?}",
18695 cell.attrs
18696 );
18697 }
18698
18699 #[test]
18700 fn tablecell_nonempty_attrs_preserved_on_roundtrip() {
18701 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"}]}]}]}]}]}"##;
18703 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18704 let md = adf_to_markdown(&doc).unwrap();
18705 let round_tripped = markdown_to_adf(&md).unwrap();
18706 let rows = round_tripped.content[0].content.as_ref().unwrap();
18707 let cell = &rows[1].content.as_ref().unwrap()[0];
18708 let attrs = cell.attrs.as_ref().unwrap();
18709 assert_eq!(attrs["background"], "#DEEBFF");
18710 assert_eq!(attrs["colspan"], 2);
18711 }
18712
18713 #[test]
18714 fn pipe_table_not_used_when_caption_present() {
18715 let doc = AdfDocument {
18716 version: 1,
18717 doc_type: "doc".to_string(),
18718 content: vec![AdfNode::table(vec![
18719 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
18720 AdfNode::text("H"),
18721 ])])]),
18722 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18723 AdfNode::text("D"),
18724 ])])]),
18725 AdfNode::caption(vec![AdfNode::text("cap")]),
18726 ])],
18727 };
18728 let md = adf_to_markdown(&doc).unwrap();
18729 assert!(
18730 md.contains("::::table"),
18731 "pipe syntax should not be used when caption is present"
18732 );
18733 }
18734
18735 #[test]
18738 fn hardbreak_with_ordered_marker_in_bullet_item_roundtrips() {
18739 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18742 {"type":"listItem","content":[{"type":"paragraph","content":[
18743 {"type":"text","text":"1. First item"},
18744 {"type":"hardBreak"},
18745 {"type":"text","text":"2. Honouring existing commitments"}
18746 ]}]}
18747 ]}]}"#;
18748 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18749 let md = adf_to_markdown(&doc).unwrap();
18750
18751 assert!(
18753 md.contains(" 2. Honouring"),
18754 "Continuation line should be indented, got:\n{md}"
18755 );
18756
18757 let rt = markdown_to_adf(&md).unwrap();
18759 let list = &rt.content[0];
18760 assert_eq!(list.node_type, "bulletList");
18761 let items = list.content.as_ref().unwrap();
18762 assert_eq!(
18763 items.len(),
18764 1,
18765 "Should be one list item, got {}",
18766 items.len()
18767 );
18768
18769 let para = &items[0].content.as_ref().unwrap()[0];
18770 let inlines = para.content.as_ref().unwrap();
18771 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18772 assert_eq!(
18773 types,
18774 vec!["text", "hardBreak", "text"],
18775 "Expected text+hardBreak+text, got {types:?}"
18776 );
18777 assert_eq!(
18778 inlines[2].text.as_deref().unwrap(),
18779 "2. Honouring existing commitments"
18780 );
18781 }
18782
18783 #[test]
18784 fn hardbreak_with_ordered_marker_in_ordered_item_roundtrips() {
18785 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18787 {"type":"listItem","content":[{"type":"paragraph","content":[
18788 {"type":"text","text":"Introduction "},
18789 {"type":"hardBreak"},
18790 {"type":"text","text":"3. Third point"}
18791 ]}]}
18792 ]}]}"#;
18793 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18794 let md = adf_to_markdown(&doc).unwrap();
18795 let rt = markdown_to_adf(&md).unwrap();
18796
18797 let list = &rt.content[0];
18798 assert_eq!(list.node_type, "orderedList");
18799 let items = list.content.as_ref().unwrap();
18800 assert_eq!(items.len(), 1);
18801
18802 let para = &items[0].content.as_ref().unwrap()[0];
18803 let inlines = para.content.as_ref().unwrap();
18804 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18805 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18806 assert_eq!(inlines[2].text.as_deref().unwrap(), "3. Third point");
18807 }
18808
18809 #[test]
18810 fn hardbreak_with_bullet_marker_in_bullet_item_roundtrips() {
18811 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18813 {"type":"listItem","content":[{"type":"paragraph","content":[
18814 {"type":"text","text":"Header "},
18815 {"type":"hardBreak"},
18816 {"type":"text","text":"- not a sub-item"}
18817 ]}]}
18818 ]}]}"#;
18819 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18820 let md = adf_to_markdown(&doc).unwrap();
18821 let rt = markdown_to_adf(&md).unwrap();
18822
18823 let list = &rt.content[0];
18824 assert_eq!(list.node_type, "bulletList");
18825 let items = list.content.as_ref().unwrap();
18826 assert_eq!(
18827 items.len(),
18828 1,
18829 "Should be one list item, not {}",
18830 items.len()
18831 );
18832
18833 let para = &items[0].content.as_ref().unwrap()[0];
18834 let inlines = para.content.as_ref().unwrap();
18835 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18836 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18837 assert_eq!(inlines[2].text.as_deref().unwrap(), "- not a sub-item");
18838 }
18839
18840 #[test]
18841 fn hardbreak_continuation_followed_by_sub_list() {
18842 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18844 {"type":"listItem","content":[
18845 {"type":"paragraph","content":[
18846 {"type":"text","text":"Main item "},
18847 {"type":"hardBreak"},
18848 {"type":"text","text":"continued here"}
18849 ]},
18850 {"type":"bulletList","content":[
18851 {"type":"listItem","content":[{"type":"paragraph","content":[
18852 {"type":"text","text":"sub-item"}
18853 ]}]}
18854 ]}
18855 ]}
18856 ]}]}"#;
18857 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18858 let md = adf_to_markdown(&doc).unwrap();
18859 let rt = markdown_to_adf(&md).unwrap();
18860
18861 let list = &rt.content[0];
18862 let items = list.content.as_ref().unwrap();
18863 assert_eq!(items.len(), 1);
18864
18865 let item_content = items[0].content.as_ref().unwrap();
18866 assert_eq!(item_content.len(), 2, "Expected paragraph + nested list");
18867 assert_eq!(item_content[0].node_type, "paragraph");
18868 assert_eq!(item_content[1].node_type, "bulletList");
18869
18870 let inlines = item_content[0].content.as_ref().unwrap();
18872 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18873 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18874 }
18875
18876 #[test]
18877 fn multiple_hardbreaks_with_numbered_text_roundtrip() {
18878 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18880 {"type":"listItem","content":[{"type":"paragraph","content":[
18881 {"type":"text","text":"Preamble "},
18882 {"type":"hardBreak"},
18883 {"type":"text","text":"1. Alpha "},
18884 {"type":"hardBreak"},
18885 {"type":"text","text":"2. Bravo"}
18886 ]}]}
18887 ]}]}"#;
18888 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18889 let md = adf_to_markdown(&doc).unwrap();
18890 let rt = markdown_to_adf(&md).unwrap();
18891
18892 let items = rt.content[0].content.as_ref().unwrap();
18893 assert_eq!(items.len(), 1);
18894
18895 let inlines = items[0].content.as_ref().unwrap()[0]
18896 .content
18897 .as_ref()
18898 .unwrap();
18899 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18900 assert_eq!(
18901 types,
18902 vec!["text", "hardBreak", "text", "hardBreak", "text"]
18903 );
18904 }
18905
18906 #[test]
18907 fn trailing_hardbreak_in_bullet_item_roundtrips() {
18908 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18912 {"type":"listItem","content":[{"type":"paragraph","content":[
18913 {"type":"text","text":"ends with break"},
18914 {"type":"hardBreak"}
18915 ]}]}
18916 ]}]}"#;
18917 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18918 let md = adf_to_markdown(&doc).unwrap();
18919 let rt = markdown_to_adf(&md).unwrap();
18920
18921 let list = &rt.content[0];
18922 assert_eq!(list.node_type, "bulletList");
18923 let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18924 .content
18925 .as_ref()
18926 .unwrap();
18927 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18928 assert_eq!(types, vec!["text", "hardBreak"]);
18929 }
18930
18931 #[test]
18932 fn trailing_hardbreak_in_ordered_item_roundtrips() {
18933 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18936 {"type":"listItem","content":[{"type":"paragraph","content":[
18937 {"type":"text","text":"ends with break"},
18938 {"type":"hardBreak"}
18939 ]}]}
18940 ]}]}"#;
18941 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18942 let md = adf_to_markdown(&doc).unwrap();
18943 let rt = markdown_to_adf(&md).unwrap();
18944
18945 let list = &rt.content[0];
18946 assert_eq!(list.node_type, "orderedList");
18947 let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18948 .content
18949 .as_ref()
18950 .unwrap();
18951 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18952 assert_eq!(types, vec!["text", "hardBreak"]);
18953 }
18954
18955 #[test]
18956 fn trailing_space_hardbreak_continuation_in_bullet_item() {
18957 let md = "- first line \n 2. continued\n";
18961 let doc = markdown_to_adf(md).unwrap();
18962
18963 let list = &doc.content[0];
18964 assert_eq!(list.node_type, "bulletList");
18965 let items = list.content.as_ref().unwrap();
18966 assert_eq!(
18967 items.len(),
18968 1,
18969 "Should be one list item, got {}",
18970 items.len()
18971 );
18972
18973 let para = &items[0].content.as_ref().unwrap()[0];
18974 let inlines = para.content.as_ref().unwrap();
18975 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18976 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18977 assert_eq!(inlines[2].text.as_deref().unwrap(), "2. continued");
18978 }
18979
18980 #[test]
18981 fn trailing_space_hardbreak_continuation_in_ordered_item() {
18982 let md = "1. first line \n - continued\n";
18985 let doc = markdown_to_adf(md).unwrap();
18986
18987 let list = &doc.content[0];
18988 assert_eq!(list.node_type, "orderedList");
18989 let items = list.content.as_ref().unwrap();
18990 assert_eq!(
18991 items.len(),
18992 1,
18993 "Should be one list item, got {}",
18994 items.len()
18995 );
18996
18997 let para = &items[0].content.as_ref().unwrap()[0];
18998 let inlines = para.content.as_ref().unwrap();
18999 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19000 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19001 assert_eq!(inlines[2].text.as_deref().unwrap(), "- continued");
19002 }
19003
19004 #[test]
19005 fn multi_paragraph_list_item_with_ordered_marker_roundtrips() {
19006 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19009 {"type":"listItem","content":[
19010 {"type":"paragraph","content":[{"type":"text","text":"some preamble"}]},
19011 {"type":"paragraph","content":[{"type":"text","text":"2. Honouring existing commitments"}]}
19012 ]}
19013 ]}]}"#;
19014 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19015 let md = adf_to_markdown(&doc).unwrap();
19016 let rt = markdown_to_adf(&md).unwrap();
19017
19018 assert_eq!(rt.content.len(), 1, "Should be one top-level block");
19019 let list = &rt.content[0];
19020 assert_eq!(list.node_type, "bulletList");
19021 let items = list.content.as_ref().unwrap();
19022 assert_eq!(items.len(), 1);
19023 let item_content = items[0].content.as_ref().unwrap();
19024 assert_eq!(
19025 item_content.len(),
19026 2,
19027 "Expected 2 paragraphs inside the list item, got {}",
19028 item_content.len()
19029 );
19030 assert_eq!(item_content[0].node_type, "paragraph");
19031 assert_eq!(item_content[1].node_type, "paragraph");
19032 let text = item_content[1].content.as_ref().unwrap()[0]
19033 .text
19034 .as_deref()
19035 .unwrap();
19036 assert_eq!(text, "2. Honouring existing commitments");
19037 }
19038
19039 #[test]
19040 fn multi_paragraph_list_item_with_bullet_marker_roundtrips() {
19041 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19043 {"type":"listItem","content":[
19044 {"type":"paragraph","content":[{"type":"text","text":"preamble"}]},
19045 {"type":"paragraph","content":[{"type":"text","text":"- not a sub-item"}]}
19046 ]}
19047 ]}]}"#;
19048 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19049 let md = adf_to_markdown(&doc).unwrap();
19050 let rt = markdown_to_adf(&md).unwrap();
19051
19052 let items = rt.content[0].content.as_ref().unwrap();
19053 assert_eq!(items.len(), 1);
19054 let item_content = items[0].content.as_ref().unwrap();
19055 assert_eq!(item_content.len(), 2);
19056 assert_eq!(item_content[1].node_type, "paragraph");
19057 let text = item_content[1].content.as_ref().unwrap()[0]
19058 .text
19059 .as_deref()
19060 .unwrap();
19061 assert_eq!(text, "- not a sub-item");
19062 }
19063
19064 #[test]
19065 fn backslash_escape_in_inline_text() {
19066 let nodes = parse_inline(r"2\. text");
19068 assert_eq!(nodes.len(), 1, "Should be one text node");
19069 assert_eq!(nodes[0].text.as_deref().unwrap(), "2. text");
19070 }
19071
19072 #[test]
19073 fn escape_list_marker_ordered() {
19074 assert_eq!(escape_list_marker("2. text"), r"2\. text");
19075 assert_eq!(escape_list_marker("10. tenth"), r"10\. tenth");
19076 }
19077
19078 #[test]
19079 fn escape_list_marker_bullet() {
19080 assert_eq!(escape_list_marker("- text"), r"\- text");
19081 assert_eq!(escape_list_marker("* text"), r"\* text");
19082 assert_eq!(escape_list_marker("+ text"), r"\+ text");
19083 }
19084
19085 #[test]
19086 fn escape_list_marker_plain() {
19087 assert_eq!(escape_list_marker("plain text"), "plain text");
19088 assert_eq!(escape_list_marker("no. marker"), "no. marker");
19089 }
19090
19091 #[test]
19092 fn escape_emoji_shortcodes_basic() {
19093 assert_eq!(escape_emoji_shortcodes(":fire:"), r"\:fire:");
19094 assert_eq!(
19095 escape_emoji_shortcodes("hello :wave: world"),
19096 r"hello \:wave: world"
19097 );
19098 }
19099
19100 #[test]
19101 fn escape_emoji_shortcodes_double_colon() {
19102 assert_eq!(
19104 escape_emoji_shortcodes("Status::Active::Running"),
19105 r"Status:\:Active::Running"
19106 );
19107 }
19108
19109 #[test]
19110 fn escape_emoji_shortcodes_no_match() {
19111 assert_eq!(escape_emoji_shortcodes("Time is 10:30"), "Time is 10:30");
19113 assert_eq!(escape_emoji_shortcodes("no colons here"), "no colons here");
19114 assert_eq!(escape_emoji_shortcodes("trailing:"), "trailing:");
19115 assert_eq!(escape_emoji_shortcodes(":"), ":");
19116 }
19117
19118 #[test]
19119 fn escape_emoji_shortcodes_mixed() {
19120 assert_eq!(
19121 escape_emoji_shortcodes("Alert :fire: on pod:pod42"),
19122 r"Alert \:fire: on pod:pod42"
19123 );
19124 }
19125
19126 #[test]
19127 fn escape_emoji_shortcodes_unicode() {
19128 assert_eq!(escape_emoji_shortcodes(":Café:"), r"\:Café:");
19133 assert_eq!(escape_emoji_shortcodes(":über:"), r"\:über:");
19134 assert_eq!(escape_emoji_shortcodes(":配置:"), r"\:配置:");
19135 assert_eq!(
19136 escape_emoji_shortcodes("ZBC::配置::Production"),
19137 r"ZBC:\:配置::Production"
19138 );
19139 }
19140
19141 #[test]
19142 fn escape_emoji_shortcodes_mixed_script_name() {
19143 assert_eq!(escape_emoji_shortcodes(":abc配置:"), r"\:abc配置:");
19146 assert_eq!(escape_emoji_shortcodes(":配置abc:"), r"\:配置abc:");
19147 }
19148
19149 #[test]
19150 fn escape_emoji_shortcodes_unicode_followed_by_non_colon() {
19151 assert_eq!(escape_emoji_shortcodes(":Café world:"), ":Café world:");
19156 }
19157
19158 #[test]
19159 fn escape_emoji_shortcodes_name_runs_to_end() {
19160 assert_eq!(escape_emoji_shortcodes(":abc"), ":abc");
19166 assert_eq!(escape_emoji_shortcodes(":配置"), ":配置");
19167 }
19168
19169 #[test]
19170 fn unicode_shortcode_pattern_text_round_trips_as_text() {
19171 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19174 {"type":"text","text":"Visit :Café: today"}
19175 ]}]}"#;
19176 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19177
19178 let md = adf_to_markdown(&doc).unwrap();
19179 let round_tripped = markdown_to_adf(&md).unwrap();
19180 let content = round_tripped.content[0].content.as_ref().unwrap();
19181
19182 assert_eq!(
19183 content.len(),
19184 1,
19185 "should be a single text node, got: {content:?}"
19186 );
19187 assert_eq!(content[0].node_type, "text");
19188 assert_eq!(content[0].text.as_deref().unwrap(), "Visit :Café: today");
19189 }
19190
19191 #[test]
19192 fn unicode_double_colon_pattern_text_round_trips() {
19193 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19195 {"type":"text","text":"Use ZBC::配置::Production for prod"}
19196 ]}]}"#;
19197 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19198
19199 let md = adf_to_markdown(&doc).unwrap();
19200 let round_tripped = markdown_to_adf(&md).unwrap();
19201 let content = round_tripped.content[0].content.as_ref().unwrap();
19202
19203 assert_eq!(
19204 content.len(),
19205 1,
19206 "should be a single text node, got: {content:?}"
19207 );
19208 assert_eq!(
19209 content[0].text.as_deref().unwrap(),
19210 "Use ZBC::配置::Production for prod"
19211 );
19212 }
19213
19214 #[test]
19215 fn merge_adjacent_text_nodes() {
19216 let mut nodes = vec![AdfNode::text("a"), AdfNode::text("b"), AdfNode::text("c")];
19217 merge_adjacent_text(&mut nodes);
19218 assert_eq!(nodes.len(), 1);
19219 assert_eq!(nodes[0].text.as_deref().unwrap(), "abc");
19220 }
19221
19222 #[test]
19225 fn issue_455_paragraph_hardbreak_ordered_marker_roundtrips() {
19226 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19229 {"type":"text","text":"Introduction: "},
19230 {"type":"hardBreak"},
19231 {"type":"text","text":"1. This text follows a hardBreak"}
19232 ]}]}"#;
19233 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19234 let md = adf_to_markdown(&doc).unwrap();
19235 let rt = markdown_to_adf(&md).unwrap();
19236
19237 assert_eq!(rt.content.len(), 1, "Should remain one block");
19238 assert_eq!(rt.content[0].node_type, "paragraph");
19239 let inlines = rt.content[0].content.as_ref().unwrap();
19240 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19241 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19242 assert_eq!(
19243 inlines[2].text.as_deref(),
19244 Some("1. This text follows a hardBreak")
19245 );
19246 }
19247
19248 #[test]
19249 fn issue_455_paragraph_hardbreak_bullet_marker_roundtrips() {
19250 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19252 {"type":"text","text":"Intro"},
19253 {"type":"hardBreak"},
19254 {"type":"text","text":"- not a list item"}
19255 ]}]}"#;
19256 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19257 let md = adf_to_markdown(&doc).unwrap();
19258 let rt = markdown_to_adf(&md).unwrap();
19259
19260 assert_eq!(rt.content.len(), 1);
19261 assert_eq!(rt.content[0].node_type, "paragraph");
19262 let inlines = rt.content[0].content.as_ref().unwrap();
19263 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19264 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19265 assert_eq!(inlines[2].text.as_deref(), Some("- not a list item"));
19266 }
19267
19268 #[test]
19269 fn issue_455_paragraph_hardbreak_heading_marker_roundtrips() {
19270 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19272 {"type":"text","text":"Intro"},
19273 {"type":"hardBreak"},
19274 {"type":"text","text":"# not a heading"}
19275 ]}]}"##;
19276 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19277 let md = adf_to_markdown(&doc).unwrap();
19278 let rt = markdown_to_adf(&md).unwrap();
19279
19280 assert_eq!(rt.content.len(), 1);
19281 assert_eq!(rt.content[0].node_type, "paragraph");
19282 let inlines = rt.content[0].content.as_ref().unwrap();
19283 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19284 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19285 assert_eq!(inlines[2].text.as_deref(), Some("# not a heading"));
19286 }
19287
19288 #[test]
19289 fn issue_455_paragraph_hardbreak_blockquote_marker_roundtrips() {
19290 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19292 {"type":"text","text":"Intro"},
19293 {"type":"hardBreak"},
19294 {"type":"text","text":"> not a blockquote"}
19295 ]}]}"#;
19296 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19297 let md = adf_to_markdown(&doc).unwrap();
19298 let rt = markdown_to_adf(&md).unwrap();
19299
19300 assert_eq!(rt.content.len(), 1);
19301 assert_eq!(rt.content[0].node_type, "paragraph");
19302 let inlines = rt.content[0].content.as_ref().unwrap();
19303 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19304 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19305 assert_eq!(inlines[2].text.as_deref(), Some("> not a blockquote"));
19306 }
19307
19308 #[test]
19309 fn issue_455_paragraph_multiple_hardbreaks_with_ordered_markers() {
19310 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19312 {"type":"text","text":"Preamble"},
19313 {"type":"hardBreak"},
19314 {"type":"text","text":"1. First"},
19315 {"type":"hardBreak"},
19316 {"type":"text","text":"2. Second"},
19317 {"type":"hardBreak"},
19318 {"type":"text","text":"3. Third"}
19319 ]}]}"#;
19320 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19321 let md = adf_to_markdown(&doc).unwrap();
19322 let rt = markdown_to_adf(&md).unwrap();
19323
19324 assert_eq!(rt.content.len(), 1);
19325 assert_eq!(rt.content[0].node_type, "paragraph");
19326 let inlines = rt.content[0].content.as_ref().unwrap();
19327 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19328 assert_eq!(
19329 types,
19330 vec![
19331 "text",
19332 "hardBreak",
19333 "text",
19334 "hardBreak",
19335 "text",
19336 "hardBreak",
19337 "text"
19338 ]
19339 );
19340 assert_eq!(inlines[2].text.as_deref(), Some("1. First"));
19341 assert_eq!(inlines[4].text.as_deref(), Some("2. Second"));
19342 assert_eq!(inlines[6].text.as_deref(), Some("3. Third"));
19343 }
19344
19345 #[test]
19346 fn issue_455_paragraph_hardbreak_jfm_indentation() {
19347 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19349 {"type":"text","text":"Intro"},
19350 {"type":"hardBreak"},
19351 {"type":"text","text":"1. continued"}
19352 ]}]}"#;
19353 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19354 let md = adf_to_markdown(&doc).unwrap();
19355 assert!(
19356 md.contains("Intro\\\n 1. continued"),
19357 "Continuation should be 2-space-indented, got: {md:?}"
19358 );
19359 }
19360
19361 #[test]
19362 fn issue_455_paragraph_hardbreak_from_jfm() {
19363 let md = "Intro\\\n 1. This is continuation text\n";
19366 let doc = markdown_to_adf(md).unwrap();
19367
19368 assert_eq!(doc.content.len(), 1);
19369 assert_eq!(doc.content[0].node_type, "paragraph");
19370 let inlines = doc.content[0].content.as_ref().unwrap();
19371 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19372 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19373 assert_eq!(
19374 inlines[2].text.as_deref(),
19375 Some("1. This is continuation text")
19376 );
19377 }
19378
19379 #[test]
19380 fn issue_455_paragraph_starts_with_ordered_marker_and_hardbreak() {
19381 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19385 {"type":"text","text":"1. Starting with a number"},
19386 {"type":"hardBreak"},
19387 {"type":"text","text":"continuation after break"}
19388 ]}]}"#;
19389 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19390 let md = adf_to_markdown(&doc).unwrap();
19391 assert!(
19393 md.contains(r"1\. Starting with a number"),
19394 "First line should have escaped list marker, got: {md:?}"
19395 );
19396 let rt = markdown_to_adf(&md).unwrap();
19397
19398 assert_eq!(rt.content.len(), 1);
19399 assert_eq!(rt.content[0].node_type, "paragraph");
19400 let inlines = rt.content[0].content.as_ref().unwrap();
19401 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19402 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19403 assert_eq!(
19404 inlines[0].text.as_deref(),
19405 Some("1. Starting with a number")
19406 );
19407 assert_eq!(inlines[2].text.as_deref(), Some("continuation after break"));
19408 }
19409
19410 #[test]
19411 fn ordered_marker_paragraph_in_table_cell_roundtrips() {
19412 let adf_json = r#"{"version":1,"type":"doc","content":[{
19415 "type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},
19416 "content":[{"type":"tableRow","content":[{
19417 "type":"tableCell","attrs":{"colspan":1,"rowspan":1},
19418 "content":[{"type":"paragraph","content":[
19419 {"type":"text","text":"2. Honouring existing commitments"}
19420 ]}]
19421 }]}]
19422 }]}"#;
19423 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19424 let md = adf_to_markdown(&doc).unwrap();
19425 let rt = markdown_to_adf(&md).unwrap();
19426
19427 let table = &rt.content[0];
19428 let cell = &table.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0];
19429 let para = &cell.content.as_ref().unwrap()[0];
19430 assert_eq!(para.node_type, "paragraph");
19431 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
19432 assert_eq!(text, "2. Honouring existing commitments");
19433 }
19434
19435 #[test]
19436 fn bullet_marker_paragraph_standalone_roundtrips() {
19437 let adf_json = r#"{"version":1,"type":"doc","content":[
19440 {"type":"paragraph","content":[
19441 {"type":"text","text":"- not a list item"}
19442 ]}
19443 ]}"#;
19444 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19445 let md = adf_to_markdown(&doc).unwrap();
19446 assert!(
19447 md.contains(r"\- not a list item"),
19448 "Should escape the leading dash, got:\n{md}"
19449 );
19450 let rt = markdown_to_adf(&md).unwrap();
19451 assert_eq!(rt.content[0].node_type, "paragraph");
19452 let text = rt.content[0].content.as_ref().unwrap()[0]
19453 .text
19454 .as_deref()
19455 .unwrap();
19456 assert_eq!(text, "- not a list item");
19457 }
19458
19459 #[test]
19460 fn merge_adjacent_text_skips_non_text_nodes() {
19461 let mut nodes = vec![
19464 AdfNode::text("a"),
19465 AdfNode::hard_break(),
19466 AdfNode::text("b"),
19467 ];
19468 merge_adjacent_text(&mut nodes);
19469 assert_eq!(nodes.len(), 3);
19470 }
19471
19472 #[test]
19473 fn star_bullet_paragraph_roundtrips() {
19474 let adf_json = r#"{"version":1,"type":"doc","content":[
19477 {"type":"paragraph","content":[
19478 {"type":"text","text":"* starred"}
19479 ]}
19480 ]}"#;
19481 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19482 let md = adf_to_markdown(&doc).unwrap();
19483 let rt = markdown_to_adf(&md).unwrap();
19484 assert_eq!(rt.content[0].node_type, "paragraph");
19485 assert_eq!(
19486 rt.content[0].content.as_ref().unwrap()[0]
19487 .text
19488 .as_deref()
19489 .unwrap(),
19490 "* starred"
19491 );
19492 }
19493
19494 #[test]
19497 fn issue_388_ordered_list_with_strong_hardbreak_roundtrips() {
19498 let adf_json = r#"{"version":1,"type":"doc","content":[
19501 {"type":"orderedList","attrs":{"order":1},"content":[
19502 {"type":"listItem","content":[
19503 {"type":"paragraph","content":[
19504 {"type":"text","text":"Bold heading","marks":[{"type":"strong"}]},
19505 {"type":"hardBreak"},
19506 {"type":"text","text":"Content after break"}
19507 ]}
19508 ]},
19509 {"type":"listItem","content":[
19510 {"type":"paragraph","content":[
19511 {"type":"text","text":"Second item","marks":[{"type":"strong"}]},
19512 {"type":"hardBreak"},
19513 {"type":"text","text":"More content"}
19514 ]}
19515 ]}
19516 ]}
19517 ]}"#;
19518 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19519 let md = adf_to_markdown(&doc).unwrap();
19520 let rt = markdown_to_adf(&md).unwrap();
19521
19522 assert_eq!(
19524 rt.content.len(),
19525 1,
19526 "Should be 1 block (orderedList), got {}",
19527 rt.content.len()
19528 );
19529 assert_eq!(rt.content[0].node_type, "orderedList");
19530 let items = rt.content[0].content.as_ref().unwrap();
19531 assert_eq!(
19532 items.len(),
19533 2,
19534 "Should have 2 listItems, got {}",
19535 items.len()
19536 );
19537
19538 let p1 = items[0].content.as_ref().unwrap()[0]
19540 .content
19541 .as_ref()
19542 .unwrap();
19543 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19544 assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19545 assert_eq!(p1[0].text.as_deref(), Some("Bold heading"));
19546 assert_eq!(p1[2].text.as_deref(), Some("Content after break"));
19547
19548 let p2 = items[1].content.as_ref().unwrap()[0]
19550 .content
19551 .as_ref()
19552 .unwrap();
19553 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19554 assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19555 assert_eq!(p2[0].text.as_deref(), Some("Second item"));
19556 assert_eq!(p2[2].text.as_deref(), Some("More content"));
19557 }
19558
19559 #[test]
19560 fn issue_388_bullet_list_with_strong_hardbreak_roundtrips() {
19561 let adf_json = r#"{"version":1,"type":"doc","content":[
19563 {"type":"bulletList","content":[
19564 {"type":"listItem","content":[
19565 {"type":"paragraph","content":[
19566 {"type":"text","text":"First","marks":[{"type":"strong"}]},
19567 {"type":"hardBreak"},
19568 {"type":"text","text":"details"}
19569 ]}
19570 ]},
19571 {"type":"listItem","content":[
19572 {"type":"paragraph","content":[
19573 {"type":"text","text":"Second","marks":[{"type":"em"}]},
19574 {"type":"hardBreak"},
19575 {"type":"text","text":"more details"}
19576 ]}
19577 ]}
19578 ]}
19579 ]}"#;
19580 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19581 let md = adf_to_markdown(&doc).unwrap();
19582 let rt = markdown_to_adf(&md).unwrap();
19583
19584 assert_eq!(rt.content.len(), 1);
19585 assert_eq!(rt.content[0].node_type, "bulletList");
19586 let items = rt.content[0].content.as_ref().unwrap();
19587 assert_eq!(items.len(), 2);
19588
19589 let p1 = items[0].content.as_ref().unwrap()[0]
19590 .content
19591 .as_ref()
19592 .unwrap();
19593 assert_eq!(p1[0].text.as_deref(), Some("First"));
19594 assert_eq!(p1[2].text.as_deref(), Some("details"));
19595
19596 let p2 = items[1].content.as_ref().unwrap()[0]
19597 .content
19598 .as_ref()
19599 .unwrap();
19600 assert_eq!(p2[0].text.as_deref(), Some("Second"));
19601 assert_eq!(p2[2].text.as_deref(), Some("more details"));
19602 }
19603
19604 #[test]
19605 fn issue_388_ordered_list_hardbreak_jfm_indentation() {
19606 let adf_json = r#"{"version":1,"type":"doc","content":[
19608 {"type":"orderedList","attrs":{"order":1},"content":[
19609 {"type":"listItem","content":[
19610 {"type":"paragraph","content":[
19611 {"type":"text","text":"heading","marks":[{"type":"strong"}]},
19612 {"type":"hardBreak"},
19613 {"type":"text","text":"body"}
19614 ]}
19615 ]}
19616 ]}
19617 ]}"#;
19618 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19619 let md = adf_to_markdown(&doc).unwrap();
19620 assert!(
19621 md.contains("1. **heading**\\\n body"),
19622 "Continuation should be indented, got:\n{md}"
19623 );
19624 }
19625
19626 #[test]
19627 fn issue_388_ordered_list_hardbreak_from_jfm() {
19628 let md = "1. **bold**\\\n continued\n2. **also bold**\\\n also continued\n";
19630 let doc = markdown_to_adf(md).unwrap();
19631
19632 assert_eq!(doc.content.len(), 1);
19633 assert_eq!(doc.content[0].node_type, "orderedList");
19634 let items = doc.content[0].content.as_ref().unwrap();
19635 assert_eq!(items.len(), 2);
19636
19637 let p1 = items[0].content.as_ref().unwrap()[0]
19638 .content
19639 .as_ref()
19640 .unwrap();
19641 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19642 assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19643 assert_eq!(p1[0].text.as_deref(), Some("bold"));
19644 assert_eq!(p1[2].text.as_deref(), Some("continued"));
19645
19646 let p2 = items[1].content.as_ref().unwrap()[0]
19647 .content
19648 .as_ref()
19649 .unwrap();
19650 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19651 assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19652 }
19653
19654 #[test]
19655 fn issue_388_bullet_list_hardbreak_from_jfm() {
19656 let md = "- first\\\n second\n- third\\\n fourth\n";
19658 let doc = markdown_to_adf(md).unwrap();
19659
19660 assert_eq!(doc.content.len(), 1);
19661 assert_eq!(doc.content[0].node_type, "bulletList");
19662 let items = doc.content[0].content.as_ref().unwrap();
19663 assert_eq!(items.len(), 2);
19664
19665 for (i, expected) in [("first", "second"), ("third", "fourth")]
19666 .iter()
19667 .enumerate()
19668 {
19669 let p = items[i].content.as_ref().unwrap()[0]
19670 .content
19671 .as_ref()
19672 .unwrap();
19673 let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19674 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19675 assert_eq!(p[0].text.as_deref(), Some(expected.0));
19676 assert_eq!(p[2].text.as_deref(), Some(expected.1));
19677 }
19678 }
19679
19680 #[test]
19681 fn issue_433_heading_hardbreak_roundtrips() {
19682 let adf_json = r#"{"version":1,"type":"doc","content":[{
19684 "type":"heading",
19685 "attrs":{"level":1},
19686 "content":[
19687 {"type":"text","text":"Line one"},
19688 {"type":"hardBreak"},
19689 {"type":"text","text":"Line two"}
19690 ]
19691 }]}"#;
19692 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19693 let md = adf_to_markdown(&doc).unwrap();
19694 let rt = markdown_to_adf(&md).unwrap();
19695
19696 assert_eq!(
19697 rt.content.len(),
19698 1,
19699 "Should remain a single heading, got {} blocks",
19700 rt.content.len()
19701 );
19702 assert_eq!(rt.content[0].node_type, "heading");
19703 let inlines = rt.content[0].content.as_ref().unwrap();
19704 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19705 assert_eq!(
19706 types,
19707 vec!["text", "hardBreak", "text"],
19708 "hardBreak should be preserved, got: {types:?}"
19709 );
19710 assert_eq!(inlines[0].text.as_deref(), Some("Line one"));
19711 assert_eq!(inlines[2].text.as_deref(), Some("Line two"));
19712 }
19713
19714 #[test]
19715 fn issue_433_heading_hardbreak_jfm_indentation() {
19716 let adf_json = r#"{"version":1,"type":"doc","content":[{
19718 "type":"heading",
19719 "attrs":{"level":2},
19720 "content":[
19721 {"type":"text","text":"Title"},
19722 {"type":"hardBreak"},
19723 {"type":"text","text":"Subtitle"}
19724 ]
19725 }]}"#;
19726 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19727 let md = adf_to_markdown(&doc).unwrap();
19728 assert!(
19729 md.contains("## Title\\\n Subtitle"),
19730 "Continuation should be indented, got:\n{md}"
19731 );
19732 }
19733
19734 #[test]
19735 fn issue_433_heading_hardbreak_from_jfm() {
19736 let md = "# First\\\n Second\n";
19738 let doc = markdown_to_adf(md).unwrap();
19739
19740 assert_eq!(doc.content.len(), 1);
19741 assert_eq!(doc.content[0].node_type, "heading");
19742 let inlines = doc.content[0].content.as_ref().unwrap();
19743 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19744 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19745 assert_eq!(inlines[0].text.as_deref(), Some("First"));
19746 assert_eq!(inlines[2].text.as_deref(), Some("Second"));
19747 }
19748
19749 #[test]
19750 fn issue_433_heading_consecutive_hardbreaks_roundtrip() {
19751 let adf_json = r#"{"version":1,"type":"doc","content":[{
19753 "type":"heading",
19754 "attrs":{"level":3},
19755 "content":[
19756 {"type":"text","text":"A"},
19757 {"type":"hardBreak"},
19758 {"type":"hardBreak"},
19759 {"type":"text","text":"B"}
19760 ]
19761 }]}"#;
19762 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19763 let md = adf_to_markdown(&doc).unwrap();
19764 let rt = markdown_to_adf(&md).unwrap();
19765
19766 assert_eq!(rt.content.len(), 1, "Should remain a single heading");
19767 assert_eq!(rt.content[0].node_type, "heading");
19768 let inlines = rt.content[0].content.as_ref().unwrap();
19769 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19770 assert_eq!(types, vec!["text", "hardBreak", "hardBreak", "text"]);
19771 }
19772
19773 #[test]
19774 fn issue_433_heading_with_strong_and_hardbreak_roundtrips() {
19775 let adf_json = r#"{"version":1,"type":"doc","content":[{
19777 "type":"heading",
19778 "attrs":{"level":1},
19779 "content":[
19780 {"type":"text","text":"Bold title","marks":[{"type":"strong"}]},
19781 {"type":"hardBreak"},
19782 {"type":"text","text":"plain continuation"}
19783 ]
19784 }]}"#;
19785 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19786 let md = adf_to_markdown(&doc).unwrap();
19787 let rt = markdown_to_adf(&md).unwrap();
19788
19789 assert_eq!(rt.content.len(), 1);
19790 assert_eq!(rt.content[0].node_type, "heading");
19791 let inlines = rt.content[0].content.as_ref().unwrap();
19792 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19793 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19794 assert_eq!(inlines[0].text.as_deref(), Some("Bold title"));
19795 assert_eq!(inlines[2].text.as_deref(), Some("plain continuation"));
19796 }
19797
19798 #[test]
19799 fn issue_433_heading_with_link_and_hardbreak_roundtrips() {
19800 let adf_json = r#"{"version":1,"type":"doc","content":[{
19802 "type":"heading",
19803 "attrs":{"level":1},
19804 "content":[
19805 {"type":"text","text":"Click here","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
19806 {"type":"hardBreak"},
19807 {"type":"text","text":"Subtitle text"}
19808 ]
19809 }]}"#;
19810 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19811 let md = adf_to_markdown(&doc).unwrap();
19812 let rt = markdown_to_adf(&md).unwrap();
19813
19814 assert_eq!(rt.content.len(), 1);
19815 assert_eq!(rt.content[0].node_type, "heading");
19816 let inlines = rt.content[0].content.as_ref().unwrap();
19817 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19818 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19819 assert_eq!(inlines[2].text.as_deref(), Some("Subtitle text"));
19820 }
19821
19822 #[test]
19823 fn has_trailing_hard_break_backslash() {
19824 assert!(has_trailing_hard_break("text\\"));
19825 assert!(has_trailing_hard_break("**bold**\\"));
19826 }
19827
19828 #[test]
19829 fn has_trailing_hard_break_trailing_spaces() {
19830 assert!(has_trailing_hard_break("text "));
19831 assert!(has_trailing_hard_break("word "));
19832 }
19833
19834 #[test]
19835 fn has_trailing_hard_break_false() {
19836 assert!(!has_trailing_hard_break("plain text"));
19837 assert!(!has_trailing_hard_break("text "));
19838 assert!(!has_trailing_hard_break(""));
19839 }
19840
19841 #[test]
19842 fn collect_hardbreak_continuations_collects_indented() {
19843 let input = "first\\\n second\n third\n";
19846 let mut parser = MarkdownParser::new(input);
19847 parser.advance(); let mut text = "first\\".to_string();
19849 parser.collect_hardbreak_continuations(&mut text);
19850 assert_eq!(text, "first\\\nsecond");
19851 }
19852
19853 #[test]
19854 fn collect_hardbreak_continuations_stops_at_non_indented() {
19855 let input = "first\\\nnot indented\n";
19856 let mut parser = MarkdownParser::new(input);
19857 parser.advance();
19858 let mut text = "first\\".to_string();
19859 parser.collect_hardbreak_continuations(&mut text);
19860 assert_eq!(text, "first\\");
19862 }
19863
19864 #[test]
19865 fn collect_hardbreak_continuations_no_trailing_break() {
19866 let input = "plain\n indented\n";
19868 let mut parser = MarkdownParser::new(input);
19869 parser.advance();
19870 let mut text = "plain".to_string();
19871 parser.collect_hardbreak_continuations(&mut text);
19872 assert_eq!(text, "plain");
19873 }
19874
19875 #[test]
19876 fn collect_hardbreak_continuations_chained() {
19877 let input = "a\\\n b\\\n c\\\n d\n";
19879 let mut parser = MarkdownParser::new(input);
19880 parser.advance();
19881 let mut text = "a\\".to_string();
19882 parser.collect_hardbreak_continuations(&mut text);
19883 assert_eq!(text, "a\\\nb\\\nc\\\nd");
19884 }
19885
19886 #[test]
19887 fn collect_hardbreak_continuations_stops_before_image_line() {
19888 let input = "text\\\n {type=file id=x}\n";
19891 let mut parser = MarkdownParser::new(input);
19892 parser.advance(); let mut text = "text\\".to_string();
19894 parser.collect_hardbreak_continuations(&mut text);
19895 assert_eq!(text, "text\\");
19897 assert!(!parser.at_end());
19899 assert!(parser.current_line().contains(""));
19900 }
19901
19902 #[test]
19903 fn is_block_level_continuation_marker_positive_cases() {
19904 assert!(is_block_level_continuation_marker(""));
19906 assert!(is_block_level_continuation_marker("```ruby"));
19907 assert!(is_block_level_continuation_marker(":::panel{type=info}"));
19908 }
19909
19910 #[test]
19911 fn is_block_level_continuation_marker_negative_cases() {
19912 assert!(!is_block_level_continuation_marker("plain text"));
19914 assert!(!is_block_level_continuation_marker("- nested item"));
19915 assert!(!is_block_level_continuation_marker("continuation\\"));
19916 assert!(!is_block_level_continuation_marker(""));
19917 assert!(!is_block_level_continuation_marker("::partial"));
19919 assert!(!is_block_level_continuation_marker("`inline`"));
19921 }
19922
19923 #[test]
19924 fn collect_hardbreak_continuations_stops_before_code_fence() {
19925 let input = "text\\\n ```ruby\n Foo::Bar::Baz\n ```\n";
19929 let mut parser = MarkdownParser::new(input);
19930 parser.advance();
19931 let mut text = "text\\".to_string();
19932 parser.collect_hardbreak_continuations(&mut text);
19933 assert_eq!(text, "text\\");
19934 assert!(!parser.at_end());
19935 assert!(parser.current_line().starts_with(" ```"));
19936 }
19937
19938 #[test]
19939 fn collect_hardbreak_continuations_stops_before_container_directive() {
19940 let input = "text\\\n :::panel{type=info}\n body\n :::\n";
19944 let mut parser = MarkdownParser::new(input);
19945 parser.advance();
19946 let mut text = "text\\".to_string();
19947 parser.collect_hardbreak_continuations(&mut text);
19948 assert_eq!(text, "text\\");
19949 assert!(!parser.at_end());
19950 assert!(parser.current_line().contains(":::panel"));
19951 }
19952
19953 #[test]
19954 fn collect_hardbreak_continuations_stops_before_indented_code_fence() {
19955 let input = "text\\\n ```text\n :fire:\n ```\n";
19959 let mut parser = MarkdownParser::new(input);
19960 parser.advance();
19961 let mut text = "text\\".to_string();
19962 parser.collect_hardbreak_continuations(&mut text);
19963 assert_eq!(text, "text\\");
19964 assert!(!parser.at_end());
19965 assert!(parser.current_line().contains("```text"));
19966 }
19967
19968 #[test]
19969 fn ordered_list_with_sub_content_after_hardbreak() {
19970 let adf_json = r#"{"version":1,"type":"doc","content":[
19973 {"type":"orderedList","attrs":{"order":1},"content":[
19974 {"type":"listItem","content":[
19975 {"type":"paragraph","content":[
19976 {"type":"text","text":"parent"},
19977 {"type":"hardBreak"},
19978 {"type":"text","text":"continued"}
19979 ]},
19980 {"type":"bulletList","content":[
19981 {"type":"listItem","content":[
19982 {"type":"paragraph","content":[
19983 {"type":"text","text":"child"}
19984 ]}
19985 ]}
19986 ]}
19987 ]}
19988 ]}
19989 ]}"#;
19990 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19991 let md = adf_to_markdown(&doc).unwrap();
19992 let rt = markdown_to_adf(&md).unwrap();
19993
19994 assert_eq!(rt.content.len(), 1);
19995 assert_eq!(rt.content[0].node_type, "orderedList");
19996 let item_content = rt.content[0].content.as_ref().unwrap()[0]
19997 .content
19998 .as_ref()
19999 .unwrap();
20000 let p = item_content[0].content.as_ref().unwrap();
20002 let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
20003 assert_eq!(types, vec!["text", "hardBreak", "text"]);
20004 assert_eq!(p[0].text.as_deref(), Some("parent"));
20005 assert_eq!(p[2].text.as_deref(), Some("continued"));
20006 assert_eq!(item_content[1].node_type, "bulletList");
20008 }
20009
20010 #[test]
20011 fn render_list_item_content_no_content() {
20012 let item = AdfNode {
20014 node_type: "listItem".to_string(),
20015 attrs: None,
20016 content: None,
20017 text: None,
20018 marks: None,
20019 local_id: None,
20020 parameters: None,
20021 };
20022 let mut output = String::new();
20023 let opts = RenderOptions::default();
20024 render_list_item_content(&item, &mut output, &opts);
20025 assert_eq!(output, "\n");
20026 }
20027
20028 #[test]
20029 fn render_list_item_content_empty_content() {
20030 let item = AdfNode::list_item(vec![]);
20032 let mut output = String::new();
20033 let opts = RenderOptions::default();
20034 render_list_item_content(&item, &mut output, &opts);
20035 assert_eq!(output, "\n");
20036 }
20037
20038 #[test]
20039 fn plus_bullet_paragraph_roundtrips() {
20040 let adf_json = r#"{"version":1,"type":"doc","content":[
20043 {"type":"paragraph","content":[
20044 {"type":"text","text":"+ plus"}
20045 ]}
20046 ]}"#;
20047 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20048 let md = adf_to_markdown(&doc).unwrap();
20049 let rt = markdown_to_adf(&md).unwrap();
20050 assert_eq!(rt.content[0].node_type, "paragraph");
20051 assert_eq!(
20052 rt.content[0].content.as_ref().unwrap()[0]
20053 .text
20054 .as_deref()
20055 .unwrap(),
20056 "+ plus"
20057 );
20058 }
20059
20060 #[test]
20063 fn issue_430_file_media_in_bullet_list_roundtrip() {
20064 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20067 {"type":"listItem","content":[{
20068 "type":"mediaSingle",
20069 "attrs":{"layout":"center","width":1009,"widthType":"pixel"},
20070 "content":[{
20071 "type":"media",
20072 "attrs":{"collection":"contentId-123","height":576,"id":"00066e8e-554e-4d7e-af59-a0ef2888bdb6","type":"file","width":1009}
20073 }]
20074 }]}
20075 ]}]}"#;
20076 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20077 let md = adf_to_markdown(&doc).unwrap();
20078 let rt = markdown_to_adf(&md).unwrap();
20079
20080 let list = &rt.content[0];
20081 assert_eq!(list.node_type, "bulletList");
20082 let item = &list.content.as_ref().unwrap()[0];
20083 assert_eq!(item.node_type, "listItem");
20084 let ms = &item.content.as_ref().unwrap()[0];
20085 assert_eq!(ms.node_type, "mediaSingle");
20086 let ms_attrs = ms.attrs.as_ref().unwrap();
20087 assert_eq!(ms_attrs["layout"], "center");
20088 assert_eq!(ms_attrs["width"], 1009);
20089 assert_eq!(ms_attrs["widthType"], "pixel");
20090 let media = &ms.content.as_ref().unwrap()[0];
20091 assert_eq!(media.node_type, "media");
20092 let m_attrs = media.attrs.as_ref().unwrap();
20093 assert_eq!(m_attrs["type"], "file");
20094 assert_eq!(m_attrs["id"], "00066e8e-554e-4d7e-af59-a0ef2888bdb6");
20095 assert_eq!(m_attrs["collection"], "contentId-123");
20096 assert_eq!(m_attrs["height"], 576);
20097 assert_eq!(m_attrs["width"], 1009);
20098 }
20099
20100 #[test]
20101 fn issue_430_file_media_in_ordered_list_roundtrip() {
20102 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20104 {"type":"listItem","content":[{
20105 "type":"mediaSingle",
20106 "attrs":{"layout":"center"},
20107 "content":[{
20108 "type":"media",
20109 "attrs":{"type":"file","id":"abc-123","collection":"contentId-456","height":100,"width":200}
20110 }]
20111 }]}
20112 ]}]}"#;
20113 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20114 let md = adf_to_markdown(&doc).unwrap();
20115 let rt = markdown_to_adf(&md).unwrap();
20116
20117 let list = &rt.content[0];
20118 assert_eq!(list.node_type, "orderedList");
20119 let item = &list.content.as_ref().unwrap()[0];
20120 assert_eq!(item.node_type, "listItem");
20121 let ms = &item.content.as_ref().unwrap()[0];
20122 assert_eq!(ms.node_type, "mediaSingle");
20123 let media = &ms.content.as_ref().unwrap()[0];
20124 assert_eq!(media.node_type, "media");
20125 let m_attrs = media.attrs.as_ref().unwrap();
20126 assert_eq!(m_attrs["type"], "file");
20127 assert_eq!(m_attrs["id"], "abc-123");
20128 assert_eq!(m_attrs["collection"], "contentId-456");
20129 }
20130
20131 #[test]
20132 fn issue_430_external_media_in_bullet_list_roundtrip() {
20133 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20135 {"type":"listItem","content":[{
20136 "type":"mediaSingle",
20137 "attrs":{"layout":"center"},
20138 "content":[{
20139 "type":"media",
20140 "attrs":{"type":"external","url":"https://example.com/img.png","alt":"Photo"}
20141 }]
20142 }]}
20143 ]}]}"#;
20144 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20145 let md = adf_to_markdown(&doc).unwrap();
20146 let rt = markdown_to_adf(&md).unwrap();
20147
20148 let list = &rt.content[0];
20149 assert_eq!(list.node_type, "bulletList");
20150 let item = &list.content.as_ref().unwrap()[0];
20151 let ms = &item.content.as_ref().unwrap()[0];
20152 assert_eq!(ms.node_type, "mediaSingle");
20153 let media = &ms.content.as_ref().unwrap()[0];
20154 assert_eq!(media.node_type, "media");
20155 let m_attrs = media.attrs.as_ref().unwrap();
20156 assert_eq!(m_attrs["type"], "external");
20157 assert_eq!(m_attrs["url"], "https://example.com/img.png");
20158 }
20159
20160 #[test]
20161 fn issue_430_media_with_paragraph_siblings_in_list_item() {
20162 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20165 {"type":"listItem","content":[
20166 {"type":"paragraph","content":[{"type":"text","text":"Caption:"}]},
20167 {"type":"mediaSingle","attrs":{"layout":"center"},
20168 "content":[{"type":"media","attrs":{"type":"file","id":"img-001","collection":"col-1","height":50,"width":100}}]}
20169 ]}
20170 ]}]}"#;
20171 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20172 let md = adf_to_markdown(&doc).unwrap();
20173 let rt = markdown_to_adf(&md).unwrap();
20174
20175 let item = &rt.content[0].content.as_ref().unwrap()[0];
20176 let children = item.content.as_ref().unwrap();
20177 assert_eq!(children.len(), 2, "expected 2 children in listItem");
20178 assert_eq!(children[0].node_type, "paragraph");
20179 assert_eq!(children[1].node_type, "mediaSingle");
20180 let media = &children[1].content.as_ref().unwrap()[0];
20181 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-001");
20182 }
20183
20184 #[test]
20185 fn issue_430_multiple_media_in_list_items() {
20186 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20188 {"type":"listItem","content":[{
20189 "type":"mediaSingle","attrs":{"layout":"center"},
20190 "content":[{"type":"media","attrs":{"type":"file","id":"img-a","collection":"c1","height":10,"width":20}}]
20191 }]},
20192 {"type":"listItem","content":[{
20193 "type":"mediaSingle","attrs":{"layout":"center"},
20194 "content":[{"type":"media","attrs":{"type":"file","id":"img-b","collection":"c2","height":30,"width":40}}]
20195 }]}
20196 ]}]}"#;
20197 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20198 let md = adf_to_markdown(&doc).unwrap();
20199 let rt = markdown_to_adf(&md).unwrap();
20200
20201 let items = rt.content[0].content.as_ref().unwrap();
20202 assert_eq!(items.len(), 2);
20203 for (i, expected_id) in [("img-a", "c1"), ("img-b", "c2")].iter().enumerate() {
20204 let ms = &items[i].content.as_ref().unwrap()[0];
20205 assert_eq!(ms.node_type, "mediaSingle");
20206 let m_attrs = ms.content.as_ref().unwrap()[0].attrs.as_ref().unwrap();
20207 assert_eq!(m_attrs["id"], expected_id.0);
20208 assert_eq!(m_attrs["collection"], expected_id.1);
20209 }
20210 }
20211
20212 #[test]
20213 fn issue_430_jfm_to_adf_media_in_bullet_item() {
20214 let md = "- ![](){type=file id=test-id collection=col-1 height=100 width=200}\n";
20217 let doc = markdown_to_adf(md).unwrap();
20218
20219 let list = &doc.content[0];
20220 assert_eq!(list.node_type, "bulletList");
20221 let item = &list.content.as_ref().unwrap()[0];
20222 let ms = &item.content.as_ref().unwrap()[0];
20223 assert_eq!(
20224 ms.node_type, "mediaSingle",
20225 "expected mediaSingle, got {}",
20226 ms.node_type
20227 );
20228 let media = &ms.content.as_ref().unwrap()[0];
20229 assert_eq!(media.node_type, "media");
20230 let m_attrs = media.attrs.as_ref().unwrap();
20231 assert_eq!(m_attrs["type"], "file");
20232 assert_eq!(m_attrs["id"], "test-id");
20233 }
20234
20235 #[test]
20236 fn issue_430_jfm_to_adf_media_in_ordered_item() {
20237 let md = "1. \n";
20239 let doc = markdown_to_adf(md).unwrap();
20240
20241 let list = &doc.content[0];
20242 assert_eq!(list.node_type, "orderedList");
20243 let item = &list.content.as_ref().unwrap()[0];
20244 let ms = &item.content.as_ref().unwrap()[0];
20245 assert_eq!(
20246 ms.node_type, "mediaSingle",
20247 "expected mediaSingle, got {}",
20248 ms.node_type
20249 );
20250 }
20251
20252 #[test]
20253 fn issue_430_media_then_paragraph_in_bullet_list_roundtrip() {
20254 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20257 {"type":"listItem","content":[
20258 {"type":"mediaSingle","attrs":{"layout":"center"},
20259 "content":[{"type":"media","attrs":{"type":"file","id":"img-first","collection":"col-1","height":50,"width":100}}]},
20260 {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
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 children = item.content.as_ref().unwrap();
20269 assert_eq!(children.len(), 2, "expected 2 children in listItem");
20270 assert_eq!(children[0].node_type, "mediaSingle");
20271 let media = &children[0].content.as_ref().unwrap()[0];
20272 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-first");
20273 assert_eq!(children[1].node_type, "paragraph");
20274 }
20275
20276 #[test]
20277 fn issue_430_media_then_paragraph_in_ordered_list_roundtrip() {
20278 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20280 {"type":"listItem","content":[
20281 {"type":"mediaSingle","attrs":{"layout":"center"},
20282 "content":[{"type":"media","attrs":{"type":"file","id":"img-ord","collection":"col-2","height":60,"width":120}}]},
20283 {"type":"paragraph","content":[{"type":"text","text":"Description"}]}
20284 ]}
20285 ]}]}"#;
20286 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20287 let md = adf_to_markdown(&doc).unwrap();
20288 let rt = markdown_to_adf(&md).unwrap();
20289
20290 let item = &rt.content[0].content.as_ref().unwrap()[0];
20291 let children = item.content.as_ref().unwrap();
20292 assert_eq!(children.len(), 2, "expected 2 children in listItem");
20293 assert_eq!(children[0].node_type, "mediaSingle");
20294 assert_eq!(children[1].node_type, "paragraph");
20295 }
20296
20297 #[test]
20298 fn issue_430_external_media_with_width_type_roundtrip() {
20299 let adf_json = r#"{"version":1,"type":"doc","content":[{
20301 "type":"mediaSingle",
20302 "attrs":{"layout":"wide","width":800,"widthType":"pixel"},
20303 "content":[{
20304 "type":"media",
20305 "attrs":{"type":"external","url":"https://example.com/photo.png","alt":"wide photo"}
20306 }]
20307 }]}"#;
20308 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20309 let md = adf_to_markdown(&doc).unwrap();
20310 assert!(
20311 md.contains("widthType=pixel"),
20312 "expected widthType=pixel in markdown, got: {md}"
20313 );
20314 let rt = markdown_to_adf(&md).unwrap();
20315 let ms = &rt.content[0];
20316 assert_eq!(ms.node_type, "mediaSingle");
20317 let ms_attrs = ms.attrs.as_ref().unwrap();
20318 assert_eq!(ms_attrs["widthType"], "pixel");
20319 assert_eq!(ms_attrs["width"], 800);
20320 assert_eq!(ms_attrs["layout"], "wide");
20321 }
20322
20323 #[test]
20326 fn issue_490_paragraph_with_hardbreak_then_media_single_roundtrip() {
20327 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20330 {"type":"listItem","content":[
20331 {"type":"paragraph","content":[
20332 {"type":"text","text":"Item with image:"},
20333 {"type":"hardBreak"}
20334 ]},
20335 {"type":"mediaSingle","attrs":{"layout":"center","width":400,"widthType":"pixel"},
20336 "content":[{"type":"media","attrs":{
20337 "id":"aabbccdd-1234-5678-abcd-aabbccdd1234",
20338 "type":"file",
20339 "collection":"contentId-123456",
20340 "width":800,
20341 "height":600
20342 }}]}
20343 ]}
20344 ]}]}"#;
20345 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20346 let md = adf_to_markdown(&doc).unwrap();
20347 let rt = markdown_to_adf(&md).unwrap();
20348
20349 let item = &rt.content[0].content.as_ref().unwrap()[0];
20350 let children = item.content.as_ref().unwrap();
20351 assert_eq!(children.len(), 2, "expected 2 children in listItem");
20352 assert_eq!(children[0].node_type, "paragraph");
20353 assert_eq!(
20354 children[1].node_type, "mediaSingle",
20355 "expected mediaSingle, got {:?}",
20356 children[1].node_type
20357 );
20358 let media = &children[1].content.as_ref().unwrap()[0];
20359 let m_attrs = media.attrs.as_ref().unwrap();
20360 assert_eq!(m_attrs["id"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
20361 assert_eq!(m_attrs["collection"], "contentId-123456");
20362 assert_eq!(m_attrs["height"], 600);
20363 assert_eq!(m_attrs["width"], 800);
20364 }
20365
20366 #[test]
20367 fn issue_490_paragraph_with_hardbreak_then_media_single_ordered_list() {
20368 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20370 {"type":"listItem","content":[
20371 {"type":"paragraph","content":[
20372 {"type":"text","text":"Step with screenshot:"},
20373 {"type":"hardBreak"}
20374 ]},
20375 {"type":"mediaSingle","attrs":{"layout":"center"},
20376 "content":[{"type":"media","attrs":{
20377 "id":"ord-media-id","type":"file","collection":"col-ord","width":640,"height":480
20378 }}]}
20379 ]}
20380 ]}]}"#;
20381 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20382 let md = adf_to_markdown(&doc).unwrap();
20383 let rt = markdown_to_adf(&md).unwrap();
20384
20385 let item = &rt.content[0].content.as_ref().unwrap()[0];
20386 let children = item.content.as_ref().unwrap();
20387 assert_eq!(children.len(), 2, "expected 2 children in listItem");
20388 assert_eq!(children[0].node_type, "paragraph");
20389 assert_eq!(children[1].node_type, "mediaSingle");
20390 let media = &children[1].content.as_ref().unwrap()[0];
20391 assert_eq!(media.attrs.as_ref().unwrap()["id"], "ord-media-id");
20392 }
20393
20394 #[test]
20395 fn issue_490_hardbreak_continuation_does_not_swallow_media_line() {
20396 let md = "- Item with image:\\\n ![](){type=file id=test-490 collection=col height=100 width=200}\n";
20399 let doc = markdown_to_adf(md).unwrap();
20400
20401 let item = &doc.content[0].content.as_ref().unwrap()[0];
20402 let children = item.content.as_ref().unwrap();
20403 assert_eq!(children.len(), 2, "expected 2 children in listItem");
20404 assert_eq!(children[0].node_type, "paragraph");
20405 assert_eq!(
20406 children[1].node_type, "mediaSingle",
20407 "expected mediaSingle as second child, got {:?}",
20408 children[1].node_type
20409 );
20410 let media = &children[1].content.as_ref().unwrap()[0];
20411 assert_eq!(media.attrs.as_ref().unwrap()["id"], "test-490");
20412 }
20413
20414 #[test]
20415 fn issue_490_hardbreak_continuation_still_works_for_text() {
20416 let md = "- first line\\\n second line\n";
20418 let doc = markdown_to_adf(md).unwrap();
20419
20420 let item = &doc.content[0].content.as_ref().unwrap()[0];
20421 let children = item.content.as_ref().unwrap();
20422 assert_eq!(
20423 children.len(),
20424 1,
20425 "expected 1 child (paragraph) in listItem"
20426 );
20427 assert_eq!(children[0].node_type, "paragraph");
20428 let inlines = children[0].content.as_ref().unwrap();
20429 assert_eq!(inlines.len(), 3);
20431 assert_eq!(inlines[0].node_type, "text");
20432 assert_eq!(inlines[1].node_type, "hardBreak");
20433 assert_eq!(inlines[2].node_type, "text");
20434 }
20435
20436 #[test]
20437 fn issue_490_external_media_after_hardbreak_roundtrip() {
20438 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20440 {"type":"listItem","content":[
20441 {"type":"paragraph","content":[
20442 {"type":"text","text":"See image:"},
20443 {"type":"hardBreak"}
20444 ]},
20445 {"type":"mediaSingle","attrs":{"layout":"center"},
20446 "content":[{"type":"media","attrs":{
20447 "type":"external","url":"https://example.com/photo.png","alt":"photo"
20448 }}]}
20449 ]}
20450 ]}]}"#;
20451 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20452 let md = adf_to_markdown(&doc).unwrap();
20453 let rt = markdown_to_adf(&md).unwrap();
20454
20455 let item = &rt.content[0].content.as_ref().unwrap()[0];
20456 let children = item.content.as_ref().unwrap();
20457 assert_eq!(children.len(), 2);
20458 assert_eq!(children[0].node_type, "paragraph");
20459 assert_eq!(children[1].node_type, "mediaSingle");
20460 let media = &children[1].content.as_ref().unwrap()[0];
20461 let m_attrs = media.attrs.as_ref().unwrap();
20462 assert_eq!(m_attrs["url"], "https://example.com/photo.png");
20463 }
20464
20465 #[test]
20466 fn issue_490_multiple_hardbreaks_then_media_single() {
20467 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20469 {"type":"listItem","content":[
20470 {"type":"paragraph","content":[
20471 {"type":"text","text":"line one"},
20472 {"type":"hardBreak"},
20473 {"type":"text","text":"line two"},
20474 {"type":"hardBreak"}
20475 ]},
20476 {"type":"mediaSingle","attrs":{"layout":"center"},
20477 "content":[{"type":"media","attrs":{
20478 "type":"file","id":"multi-hb","collection":"col-m","width":320,"height":240
20479 }}]}
20480 ]}
20481 ]}]}"#;
20482 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20483 let md = adf_to_markdown(&doc).unwrap();
20484 let rt = markdown_to_adf(&md).unwrap();
20485
20486 let item = &rt.content[0].content.as_ref().unwrap()[0];
20487 let children = item.content.as_ref().unwrap();
20488 assert_eq!(children.len(), 2, "expected paragraph + mediaSingle");
20489 assert_eq!(children[0].node_type, "paragraph");
20490 assert_eq!(children[1].node_type, "mediaSingle");
20491 let media = &children[1].content.as_ref().unwrap()[0];
20492 assert_eq!(media.attrs.as_ref().unwrap()["id"], "multi-hb");
20493 }
20494
20495 #[test]
20498 fn issue_525_listitem_localid_with_mediasingle_roundtrip() {
20499 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"}]}]}]}]}]}]}"#;
20502 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20503 let md = adf_to_markdown(&doc).unwrap();
20504 let rt = markdown_to_adf(&md).unwrap();
20505
20506 let list = &rt.content[0];
20507 assert_eq!(list.node_type, "bulletList");
20508 let item = &list.content.as_ref().unwrap()[0];
20509 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20511 assert_eq!(
20512 item_attrs["localId"], "aabbccdd-1234-5678-abcd-000000000001",
20513 "listItem localId must survive round-trip"
20514 );
20515 let children = item.content.as_ref().unwrap();
20516 assert_eq!(
20517 children.len(),
20518 3,
20519 "expected mediaSingle + paragraph + bulletList"
20520 );
20521 assert_eq!(children[0].node_type, "mediaSingle");
20522 assert_eq!(children[1].node_type, "paragraph");
20523 assert_eq!(children[2].node_type, "bulletList");
20524 }
20525
20526 #[test]
20527 fn issue_525_listitem_localid_with_mediasingle_only() {
20528 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20530 {"type":"listItem","attrs":{"localId":"li-media-only"},"content":[
20531 {"type":"mediaSingle","attrs":{"layout":"center"},
20532 "content":[{"type":"media","attrs":{"type":"file","id":"m-001","collection":"c1","height":50,"width":100}}]}
20533 ]}
20534 ]}]}"#;
20535 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20536 let md = adf_to_markdown(&doc).unwrap();
20537 let rt = markdown_to_adf(&md).unwrap();
20538
20539 let item = &rt.content[0].content.as_ref().unwrap()[0];
20540 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20541 assert_eq!(
20542 item_attrs["localId"], "li-media-only",
20543 "listItem localId must survive when sole child is mediaSingle"
20544 );
20545 assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20546 }
20547
20548 #[test]
20549 fn issue_525_listitem_localid_with_external_media() {
20550 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20552 {"type":"listItem","attrs":{"localId":"li-ext-media"},"content":[
20553 {"type":"mediaSingle","attrs":{"layout":"center"},
20554 "content":[{"type":"media","attrs":{"type":"external","url":"https://example.com/img.png","alt":"photo"}}]}
20555 ]}
20556 ]}]}"#;
20557 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20558 let md = adf_to_markdown(&doc).unwrap();
20559 let rt = markdown_to_adf(&md).unwrap();
20560
20561 let item = &rt.content[0].content.as_ref().unwrap()[0];
20562 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20563 assert_eq!(
20564 item_attrs["localId"], "li-ext-media",
20565 "listItem localId must survive with external mediaSingle"
20566 );
20567 }
20568
20569 #[test]
20570 fn issue_525_listitem_localid_with_mediasingle_in_ordered_list() {
20571 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20573 {"type":"listItem","attrs":{"localId":"li-ord-media"},"content":[
20574 {"type":"mediaSingle","attrs":{"layout":"center","width":200,"widthType":"pixel"},
20575 "content":[{"type":"media","attrs":{"type":"file","id":"ord-m-001","collection":"col-ord","height":80,"width":160}}]},
20576 {"type":"paragraph","content":[{"type":"text","text":"ordered item text"}]}
20577 ]}
20578 ]}]}"#;
20579 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20580 let md = adf_to_markdown(&doc).unwrap();
20581 let rt = markdown_to_adf(&md).unwrap();
20582
20583 let item = &rt.content[0].content.as_ref().unwrap()[0];
20584 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20585 assert_eq!(
20586 item_attrs["localId"], "li-ord-media",
20587 "listItem localId must survive in ordered list with mediaSingle"
20588 );
20589 let children = item.content.as_ref().unwrap();
20590 assert_eq!(children[0].node_type, "mediaSingle");
20591 assert_eq!(children[1].node_type, "paragraph");
20592 }
20593
20594 #[test]
20595 fn issue_525_jfm_localid_on_mediasingle_line_parses_correctly() {
20596 let md = "- ![](){type=file id=test-525 collection=col height=100 width=200 mediaWidth=100 widthType=pixel} {localId=li-jfm-525}\n";
20599 let doc = markdown_to_adf(md).unwrap();
20600
20601 let item = &doc.content[0].content.as_ref().unwrap()[0];
20602 let item_attrs = item
20603 .attrs
20604 .as_ref()
20605 .expect("listItem attrs must be present from JFM");
20606 assert_eq!(item_attrs["localId"], "li-jfm-525");
20607 assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20608 }
20609
20610 #[test]
20611 fn issue_525_encoding_emits_localid_on_mediasingle_line() {
20612 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20615 {"type":"listItem","attrs":{"localId":"li-emit-check"},"content":[
20616 {"type":"mediaSingle","attrs":{"layout":"center"},
20617 "content":[{"type":"media","attrs":{"type":"file","id":"m-emit","collection":"c-emit","height":10,"width":20}}]}
20618 ]}
20619 ]}]}"#;
20620 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20621 let md = adf_to_markdown(&doc).unwrap();
20622 assert!(
20623 md.contains("{localId=li-emit-check}"),
20624 "expected localId in JFM output, got: {md}"
20625 );
20626 for line in md.lines() {
20628 if line.contains("![") {
20629 assert!(
20630 line.contains("localId=li-emit-check"),
20631 "localId must be on the same line as the image: {line}"
20632 );
20633 }
20634 }
20635 }
20636
20637 #[test]
20640 fn adf_placeholder_to_markdown() {
20641 let doc = AdfDocument {
20642 version: 1,
20643 doc_type: "doc".to_string(),
20644 content: vec![AdfNode::paragraph(vec![AdfNode::placeholder(
20645 "Type something here",
20646 )])],
20647 };
20648 let md = adf_to_markdown(&doc).unwrap();
20649 assert!(
20650 md.contains(":placeholder[Type something here]"),
20651 "expected :placeholder directive, got: {md}"
20652 );
20653 }
20654
20655 #[test]
20656 fn markdown_placeholder_to_adf() {
20657 let doc = markdown_to_adf("Before :placeholder[Enter name] after").unwrap();
20658 let content = doc.content[0].content.as_ref().unwrap();
20659 assert_eq!(content[1].node_type, "placeholder");
20660 let attrs = content[1].attrs.as_ref().unwrap();
20661 assert_eq!(attrs["text"], "Enter name");
20662 }
20663
20664 #[test]
20665 fn placeholder_round_trip() {
20666 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"placeholder","attrs":{"text":"Type something here"}}]}]}"#;
20667 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20668 let md = adf_to_markdown(&doc).unwrap();
20669 let rt = markdown_to_adf(&md).unwrap();
20670 let content = rt.content[0].content.as_ref().unwrap();
20671 assert_eq!(content.len(), 1);
20672 assert_eq!(content[0].node_type, "placeholder");
20673 let attrs = content[0].attrs.as_ref().unwrap();
20674 assert_eq!(attrs["text"], "Type something here");
20675 }
20676
20677 #[test]
20678 fn placeholder_empty_text() {
20679 let doc = AdfDocument {
20680 version: 1,
20681 doc_type: "doc".to_string(),
20682 content: vec![AdfNode::paragraph(vec![AdfNode::placeholder("")])],
20683 };
20684 let md = adf_to_markdown(&doc).unwrap();
20685 assert!(
20686 md.contains(":placeholder[]"),
20687 "expected empty placeholder directive, got: {md}"
20688 );
20689 let rt = markdown_to_adf(&md).unwrap();
20690 let content = rt.content[0].content.as_ref().unwrap();
20691 assert_eq!(content[0].node_type, "placeholder");
20692 assert_eq!(content[0].attrs.as_ref().unwrap()["text"], "");
20693 }
20694
20695 #[test]
20696 fn placeholder_with_surrounding_text() {
20697 let md = "Click :placeholder[here] to continue\n";
20698 let doc = markdown_to_adf(md).unwrap();
20699 let content = doc.content[0].content.as_ref().unwrap();
20700 assert_eq!(content[0].text.as_deref(), Some("Click "));
20701 assert_eq!(content[1].node_type, "placeholder");
20702 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "here");
20703 assert_eq!(content[2].text.as_deref(), Some(" to continue"));
20704 }
20705
20706 #[test]
20707 fn placeholder_missing_attrs() {
20708 let doc = AdfDocument {
20710 version: 1,
20711 doc_type: "doc".to_string(),
20712 content: vec![AdfNode::paragraph(vec![AdfNode {
20713 node_type: "placeholder".to_string(),
20714 attrs: None,
20715 content: None,
20716 text: None,
20717 marks: None,
20718 local_id: None,
20719 parameters: None,
20720 }])],
20721 };
20722 let md = adf_to_markdown(&doc).unwrap();
20723 assert!(!md.contains("placeholder"));
20725 }
20726
20727 #[test]
20729 fn mention_in_table_bullet_list_preserves_id_and_local_id() {
20730 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":" "}]}]}]}]}]}]}]}"#;
20731 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20732 let md = adf_to_markdown(&doc).unwrap();
20733 let rt = markdown_to_adf(&md).unwrap();
20734
20735 let cell = &rt.content[0].content.as_ref().unwrap()[0]
20737 .content
20738 .as_ref()
20739 .unwrap()[0];
20740 let list = &cell.content.as_ref().unwrap()[0];
20741 let list_item = &list.content.as_ref().unwrap()[0];
20742
20743 assert!(
20745 list_item
20746 .attrs
20747 .as_ref()
20748 .and_then(|a| a.get("localId"))
20749 .is_none(),
20750 "localId should stay on the mention, not the listItem"
20751 );
20752
20753 let para = &list_item.content.as_ref().unwrap()[0];
20754 let inlines = para.content.as_ref().unwrap();
20755
20756 assert_eq!(inlines.len(), 3, "expected 3 inline nodes, got {inlines:?}");
20758
20759 assert_eq!(inlines[0].node_type, "text");
20760 assert_eq!(inlines[0].text.as_deref(), Some("prefix text "));
20761
20762 assert_eq!(inlines[1].node_type, "mention");
20763 let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20764 assert_eq!(
20765 mention_attrs["id"], "aabbccdd11223344aabbccdd",
20766 "mention id must be preserved"
20767 );
20768 assert_eq!(
20769 mention_attrs["localId"], "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
20770 "mention localId must be preserved"
20771 );
20772 assert_eq!(mention_attrs["text"], "@Alice Example");
20773
20774 assert_eq!(inlines[2].node_type, "text");
20775 assert_eq!(inlines[2].text.as_deref(), Some(" "));
20776 }
20777
20778 #[test]
20779 fn mention_in_bullet_list_preserves_id_and_local_id() {
20780 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":" "}]}]}]}]}"#;
20782 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20783 let md = adf_to_markdown(&doc).unwrap();
20784 let rt = markdown_to_adf(&md).unwrap();
20785
20786 let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20787 assert!(
20788 list_item
20789 .attrs
20790 .as_ref()
20791 .and_then(|a| a.get("localId"))
20792 .is_none(),
20793 "localId should stay on the mention, not the listItem"
20794 );
20795
20796 let para = &list_item.content.as_ref().unwrap()[0];
20797 let inlines = para.content.as_ref().unwrap();
20798 assert_eq!(inlines[0].node_type, "mention");
20799 let mention_attrs = inlines[0].attrs.as_ref().unwrap();
20800 assert_eq!(mention_attrs["id"], "user123");
20801 assert_eq!(
20802 mention_attrs["localId"],
20803 "11111111-2222-3333-4444-555555555555"
20804 );
20805 }
20806
20807 #[test]
20808 fn mention_in_ordered_list_preserves_id_and_local_id() {
20809 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"}}]}]}]}]}"#;
20810 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20811 let md = adf_to_markdown(&doc).unwrap();
20812 let rt = markdown_to_adf(&md).unwrap();
20813
20814 let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20815 assert!(
20816 list_item
20817 .attrs
20818 .as_ref()
20819 .and_then(|a| a.get("localId"))
20820 .is_none(),
20821 "localId should stay on the mention, not the listItem"
20822 );
20823
20824 let para = &list_item.content.as_ref().unwrap()[0];
20825 let inlines = para.content.as_ref().unwrap();
20826 assert_eq!(inlines[1].node_type, "mention");
20827 let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20828 assert_eq!(mention_attrs["id"], "xyz");
20829 assert_eq!(mention_attrs["localId"], "aaaa-bbbb");
20830 }
20831
20832 #[test]
20833 fn list_item_own_local_id_with_mention_both_preserved() {
20834 let md = "- hello :mention[@Eve]{id=e1 localId=mention-lid} {localId=item-lid}\n";
20837 let doc = markdown_to_adf(md).unwrap();
20838 let list_item = &doc.content[0].content.as_ref().unwrap()[0];
20839
20840 let item_attrs = list_item.attrs.as_ref().unwrap();
20842 assert_eq!(item_attrs["localId"], "item-lid");
20843
20844 let para = &list_item.content.as_ref().unwrap()[0];
20846 let inlines = para.content.as_ref().unwrap();
20847 let mention = inlines.iter().find(|n| n.node_type == "mention").unwrap();
20848 let mention_attrs = mention.attrs.as_ref().unwrap();
20849 assert_eq!(mention_attrs["id"], "e1");
20850 assert_eq!(mention_attrs["localId"], "mention-lid");
20851 }
20852
20853 #[test]
20854 fn extract_trailing_local_id_ignores_directive_attrs() {
20855 let line = "text :mention[@X]{id=abc localId=uuid}";
20858 let (text, lid, plid) = extract_trailing_local_id(line);
20859 assert_eq!(text, line, "text should be unchanged");
20860 assert!(
20861 lid.is_none(),
20862 "should not extract localId from directive attrs"
20863 );
20864 assert!(plid.is_none());
20865 }
20866
20867 #[test]
20868 fn extract_trailing_local_id_matches_standalone_block() {
20869 let line = "some text {localId=abc-123}";
20871 let (text, lid, plid) = extract_trailing_local_id(line);
20872 assert_eq!(text, "some text");
20873 assert_eq!(lid.as_deref(), Some("abc-123"));
20874 assert!(plid.is_none());
20875 }
20876
20877 #[test]
20880 fn newline_in_text_node_roundtrips_in_bullet_list() {
20881 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"}]}]}]}]}"#;
20885 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20886 let md = adf_to_markdown(&doc).unwrap();
20887 let rt = markdown_to_adf(&md).unwrap();
20888
20889 assert_eq!(rt.content.len(), 1);
20891 let list = &rt.content[0];
20892 assert_eq!(list.node_type, "bulletList");
20893 let items = list.content.as_ref().unwrap();
20894 assert_eq!(items.len(), 1);
20895
20896 let item_content = items[0].content.as_ref().unwrap();
20898 assert_eq!(
20899 item_content.len(),
20900 1,
20901 "listItem should have exactly one paragraph"
20902 );
20903 assert_eq!(item_content[0].node_type, "paragraph");
20904
20905 let inlines = item_content[0].content.as_ref().unwrap();
20907 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20908 assert_eq!(
20909 types,
20910 vec!["text", "hardBreak", "text"],
20911 "embedded newline should stay in a single text node, not produce extra hardBreaks"
20912 );
20913 assert_eq!(
20914 inlines[2].text.as_deref(),
20915 Some("first command\nsecond command")
20916 );
20917 }
20918
20919 #[test]
20920 fn newline_in_text_node_roundtrips_in_ordered_list() {
20921 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"}]}]}]}]}"#;
20923 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20924 let md = adf_to_markdown(&doc).unwrap();
20925 let rt = markdown_to_adf(&md).unwrap();
20926
20927 let list = &rt.content[0];
20928 assert_eq!(list.node_type, "orderedList");
20929 let items = list.content.as_ref().unwrap();
20930 assert_eq!(items.len(), 1);
20931
20932 let item_content = items[0].content.as_ref().unwrap();
20933 assert_eq!(item_content.len(), 1);
20934 assert_eq!(item_content[0].node_type, "paragraph");
20935
20936 let inlines = item_content[0].content.as_ref().unwrap();
20937 assert_eq!(inlines.len(), 1);
20938 assert_eq!(inlines[0].node_type, "text");
20939 assert_eq!(inlines[0].text.as_deref(), Some("first\nsecond"));
20940 }
20941
20942 #[test]
20943 fn newline_in_text_node_roundtrips_in_paragraph() {
20944 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello\nworld"}]}]}"#;
20947 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20948 let md = adf_to_markdown(&doc).unwrap();
20949 assert!(
20950 md.contains("hello\\nworld"),
20951 "newline in text node should render as escaped \\n: {md:?}"
20952 );
20953
20954 let rt = markdown_to_adf(&md).unwrap();
20955 let inlines = rt.content[0].content.as_ref().unwrap();
20956 assert_eq!(inlines.len(), 1);
20957 assert_eq!(inlines[0].text.as_deref(), Some("hello\nworld"));
20958 }
20959
20960 #[test]
20961 fn multiple_newlines_in_text_node_roundtrip() {
20962 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a\nb\nc"}]}]}]}]}"#;
20964 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20965 let md = adf_to_markdown(&doc).unwrap();
20966 let rt = markdown_to_adf(&md).unwrap();
20967
20968 let item_content = rt.content[0].content.as_ref().unwrap()[0]
20969 .content
20970 .as_ref()
20971 .unwrap();
20972 assert_eq!(item_content.len(), 1);
20973
20974 let inlines = item_content[0].content.as_ref().unwrap();
20975 assert_eq!(inlines.len(), 1);
20976 assert_eq!(inlines[0].text.as_deref(), Some("a\nb\nc"));
20977 }
20978
20979 #[test]
20980 fn newline_in_marked_text_node_roundtrips() {
20981 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold\ntext","marks":[{"type":"strong"}]}]}]}"#;
20984 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20985 let md = adf_to_markdown(&doc).unwrap();
20986 assert!(
20987 md.contains("**bold\\ntext**"),
20988 "bold text with embedded newline should stay in one marked run: {md:?}"
20989 );
20990
20991 let rt = markdown_to_adf(&md).unwrap();
20992 let inlines = rt.content[0].content.as_ref().unwrap();
20993 assert_eq!(inlines.len(), 1);
20994 assert_eq!(inlines[0].text.as_deref(), Some("bold\ntext"));
20995 assert!(inlines[0]
20996 .marks
20997 .as_ref()
20998 .unwrap()
20999 .iter()
21000 .any(|m| m.mark_type == "strong"));
21001 }
21002
21003 #[test]
21004 fn trailing_newline_in_text_node_roundtrips() {
21005 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"trailing\n"}]}]}"#;
21008 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21009 let md = adf_to_markdown(&doc).unwrap();
21010 assert!(
21011 md.contains("trailing\\n"),
21012 "trailing newline should be escaped: {md:?}"
21013 );
21014
21015 let rt = markdown_to_adf(&md).unwrap();
21016 let inlines = rt.content[0].content.as_ref().unwrap();
21017 assert_eq!(inlines.len(), 1);
21018 assert_eq!(inlines[0].text.as_deref(), Some("trailing\n"));
21019 }
21020
21021 #[test]
21022 fn hardbreak_and_embedded_newline_are_distinct() {
21023 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"}]}]}"#;
21026 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21027 let md = adf_to_markdown(&doc).unwrap();
21028 let rt = markdown_to_adf(&md).unwrap();
21029
21030 let inlines = rt.content[0].content.as_ref().unwrap();
21031 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21032 assert_eq!(
21033 types,
21034 vec!["text", "hardBreak", "text", "hardBreak", "text"]
21035 );
21036 assert_eq!(inlines[0].text.as_deref(), Some("before"));
21037 assert_eq!(inlines[2].text.as_deref(), Some("mid\ndle"));
21038 assert_eq!(inlines[4].text.as_deref(), Some("after"));
21039 }
21040
21041 #[test]
21044 fn issue_472_bullet_list_trailing_hardbreak_roundtrips() {
21045 let adf_json = r#"{"version":1,"type":"doc","content":[
21048 {"type":"bulletList","content":[
21049 {"type":"listItem","content":[
21050 {"type":"paragraph","content":[
21051 {"type":"text","text":"First item"},
21052 {"type":"hardBreak"}
21053 ]}
21054 ]},
21055 {"type":"listItem","content":[
21056 {"type":"paragraph","content":[
21057 {"type":"text","text":"Second item"}
21058 ]}
21059 ]}
21060 ]}
21061 ]}"#;
21062 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21063 let md = adf_to_markdown(&doc).unwrap();
21064 let rt = markdown_to_adf(&md).unwrap();
21065
21066 assert_eq!(
21068 rt.content.len(),
21069 1,
21070 "Should be 1 block (bulletList), got {}",
21071 rt.content.len()
21072 );
21073 assert_eq!(rt.content[0].node_type, "bulletList");
21074 let items = rt.content[0].content.as_ref().unwrap();
21075 assert_eq!(
21076 items.len(),
21077 2,
21078 "Should have 2 listItems, got {}",
21079 items.len()
21080 );
21081
21082 let p1 = items[0].content.as_ref().unwrap()[0]
21084 .content
21085 .as_ref()
21086 .unwrap();
21087 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21088 assert_eq!(types1, vec!["text", "hardBreak"]);
21089 assert_eq!(p1[0].text.as_deref(), Some("First item"));
21090
21091 let p2 = items[1].content.as_ref().unwrap()[0]
21093 .content
21094 .as_ref()
21095 .unwrap();
21096 assert_eq!(p2[0].text.as_deref(), Some("Second item"));
21097 }
21098
21099 #[test]
21100 fn issue_472_ordered_list_trailing_hardbreak_roundtrips() {
21101 let adf_json = r#"{"version":1,"type":"doc","content":[
21103 {"type":"orderedList","attrs":{"order":1},"content":[
21104 {"type":"listItem","content":[
21105 {"type":"paragraph","content":[
21106 {"type":"text","text":"Alpha"},
21107 {"type":"hardBreak"}
21108 ]}
21109 ]},
21110 {"type":"listItem","content":[
21111 {"type":"paragraph","content":[
21112 {"type":"text","text":"Beta"}
21113 ]}
21114 ]}
21115 ]}
21116 ]}"#;
21117 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21118 let md = adf_to_markdown(&doc).unwrap();
21119 let rt = markdown_to_adf(&md).unwrap();
21120
21121 assert_eq!(rt.content.len(), 1);
21122 assert_eq!(rt.content[0].node_type, "orderedList");
21123 let items = rt.content[0].content.as_ref().unwrap();
21124 assert_eq!(items.len(), 2);
21125
21126 let p1 = items[0].content.as_ref().unwrap()[0]
21127 .content
21128 .as_ref()
21129 .unwrap();
21130 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21131 assert_eq!(types1, vec!["text", "hardBreak"]);
21132 assert_eq!(p1[0].text.as_deref(), Some("Alpha"));
21133 }
21134
21135 #[test]
21136 fn issue_472_trailing_hardbreak_jfm_no_blank_line() {
21137 let adf_json = r#"{"version":1,"type":"doc","content":[
21140 {"type":"bulletList","content":[
21141 {"type":"listItem","content":[
21142 {"type":"paragraph","content":[
21143 {"type":"text","text":"Hello"},
21144 {"type":"hardBreak"}
21145 ]}
21146 ]},
21147 {"type":"listItem","content":[
21148 {"type":"paragraph","content":[
21149 {"type":"text","text":"World"}
21150 ]}
21151 ]}
21152 ]}
21153 ]}"#;
21154 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21155 let md = adf_to_markdown(&doc).unwrap();
21156
21157 assert_eq!(md, "- Hello\\\n- World\n");
21159 }
21160
21161 #[test]
21162 fn issue_472_multiple_trailing_hardbreaks_roundtrip() {
21163 let adf_json = r#"{"version":1,"type":"doc","content":[
21165 {"type":"bulletList","content":[
21166 {"type":"listItem","content":[
21167 {"type":"paragraph","content":[
21168 {"type":"text","text":"Item"},
21169 {"type":"hardBreak"},
21170 {"type":"hardBreak"}
21171 ]}
21172 ]},
21173 {"type":"listItem","content":[
21174 {"type":"paragraph","content":[
21175 {"type":"text","text":"Next"}
21176 ]}
21177 ]}
21178 ]}
21179 ]}"#;
21180 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21181 let md = adf_to_markdown(&doc).unwrap();
21182 let rt = markdown_to_adf(&md).unwrap();
21183
21184 assert_eq!(rt.content.len(), 1);
21186 assert_eq!(rt.content[0].node_type, "bulletList");
21187 let items = rt.content[0].content.as_ref().unwrap();
21188 assert_eq!(items.len(), 2);
21189
21190 let p1 = items[0].content.as_ref().unwrap()[0]
21192 .content
21193 .as_ref()
21194 .unwrap();
21195 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21196 assert_eq!(types1, vec!["text", "hardBreak", "hardBreak"]);
21197 }
21198
21199 #[test]
21200 fn issue_472_hardbreak_mid_and_trailing_roundtrip() {
21201 let adf_json = r#"{"version":1,"type":"doc","content":[
21203 {"type":"bulletList","content":[
21204 {"type":"listItem","content":[
21205 {"type":"paragraph","content":[
21206 {"type":"text","text":"Line one"},
21207 {"type":"hardBreak"},
21208 {"type":"text","text":"Line two"},
21209 {"type":"hardBreak"}
21210 ]}
21211 ]},
21212 {"type":"listItem","content":[
21213 {"type":"paragraph","content":[
21214 {"type":"text","text":"Other item"}
21215 ]}
21216 ]}
21217 ]}
21218 ]}"#;
21219 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21220 let md = adf_to_markdown(&doc).unwrap();
21221 let rt = markdown_to_adf(&md).unwrap();
21222
21223 assert_eq!(rt.content.len(), 1);
21224 assert_eq!(rt.content[0].node_type, "bulletList");
21225 let items = rt.content[0].content.as_ref().unwrap();
21226 assert_eq!(items.len(), 2);
21227
21228 let p1 = items[0].content.as_ref().unwrap()[0]
21229 .content
21230 .as_ref()
21231 .unwrap();
21232 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21233 assert_eq!(types1, vec!["text", "hardBreak", "text", "hardBreak"]);
21234 assert_eq!(p1[0].text.as_deref(), Some("Line one"));
21235 assert_eq!(p1[2].text.as_deref(), Some("Line two"));
21236 }
21237
21238 #[test]
21239 fn issue_472_only_hardbreak_in_listitem_paragraph() {
21240 let adf_json = r#"{"version":1,"type":"doc","content":[
21242 {"type":"bulletList","content":[
21243 {"type":"listItem","content":[
21244 {"type":"paragraph","content":[
21245 {"type":"hardBreak"}
21246 ]}
21247 ]},
21248 {"type":"listItem","content":[
21249 {"type":"paragraph","content":[
21250 {"type":"text","text":"After"}
21251 ]}
21252 ]}
21253 ]}
21254 ]}"#;
21255 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21256 let md = adf_to_markdown(&doc).unwrap();
21257 let rt = markdown_to_adf(&md).unwrap();
21258
21259 assert_eq!(rt.content.len(), 1);
21261 assert_eq!(rt.content[0].node_type, "bulletList");
21262 let items = rt.content[0].content.as_ref().unwrap();
21263 assert_eq!(items.len(), 2);
21264 }
21265
21266 #[test]
21267 fn issue_472_three_items_middle_has_trailing_hardbreak() {
21268 let adf_json = r#"{"version":1,"type":"doc","content":[
21270 {"type":"bulletList","content":[
21271 {"type":"listItem","content":[
21272 {"type":"paragraph","content":[
21273 {"type":"text","text":"First"}
21274 ]}
21275 ]},
21276 {"type":"listItem","content":[
21277 {"type":"paragraph","content":[
21278 {"type":"text","text":"Second"},
21279 {"type":"hardBreak"}
21280 ]}
21281 ]},
21282 {"type":"listItem","content":[
21283 {"type":"paragraph","content":[
21284 {"type":"text","text":"Third"}
21285 ]}
21286 ]}
21287 ]}
21288 ]}"#;
21289 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21290 let md = adf_to_markdown(&doc).unwrap();
21291 let rt = markdown_to_adf(&md).unwrap();
21292
21293 assert_eq!(rt.content.len(), 1);
21294 assert_eq!(rt.content[0].node_type, "bulletList");
21295 let items = rt.content[0].content.as_ref().unwrap();
21296 assert_eq!(items.len(), 3);
21297 assert_eq!(
21298 items[0].content.as_ref().unwrap()[0]
21299 .content
21300 .as_ref()
21301 .unwrap()[0]
21302 .text
21303 .as_deref(),
21304 Some("First")
21305 );
21306 assert_eq!(
21307 items[2].content.as_ref().unwrap()[0]
21308 .content
21309 .as_ref()
21310 .unwrap()[0]
21311 .text
21312 .as_deref(),
21313 Some("Third")
21314 );
21315 }
21316
21317 #[test]
21320 fn issue_494_space_after_hardbreak_roundtrip() {
21321 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21324 {"type":"text","text":"Some text"},
21325 {"type":"hardBreak"},
21326 {"type":"text","text":" "}
21327 ]}]}"#;
21328 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21329 let md = adf_to_markdown(&doc).unwrap();
21330 let rt = markdown_to_adf(&md).unwrap();
21331 let inlines = rt.content[0].content.as_ref().unwrap();
21332 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21333 assert_eq!(
21334 types,
21335 vec!["text", "hardBreak", "text"],
21336 "space-only text node after hardBreak should survive round-trip"
21337 );
21338 assert_eq!(inlines[2].text.as_deref(), Some(" "));
21339 }
21340
21341 #[test]
21342 fn issue_494_multiple_spaces_after_hardbreak_roundtrip() {
21343 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21345 {"type":"text","text":"Hello"},
21346 {"type":"hardBreak"},
21347 {"type":"text","text":" "}
21348 ]}]}"#;
21349 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21350 let md = adf_to_markdown(&doc).unwrap();
21351 let rt = markdown_to_adf(&md).unwrap();
21352 let inlines = rt.content[0].content.as_ref().unwrap();
21353 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21354 assert_eq!(
21355 types,
21356 vec!["text", "hardBreak", "text"],
21357 "multi-space text node after hardBreak should survive round-trip"
21358 );
21359 assert_eq!(inlines[2].text.as_deref(), Some(" "));
21360 }
21361
21362 #[test]
21363 fn issue_494_space_then_text_after_hardbreak_roundtrip() {
21364 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21367 {"type":"text","text":"Before"},
21368 {"type":"hardBreak"},
21369 {"type":"text","text":" After"}
21370 ]}]}"#;
21371 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21372 let md = adf_to_markdown(&doc).unwrap();
21373 let rt = markdown_to_adf(&md).unwrap();
21374 let inlines = rt.content[0].content.as_ref().unwrap();
21375 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21376 assert_eq!(types, vec!["text", "hardBreak", "text"]);
21377 assert_eq!(inlines[2].text.as_deref(), Some(" After"));
21378 }
21379
21380 #[test]
21381 fn issue_494_hardbreak_then_space_then_hardbreak_roundtrip() {
21382 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21384 {"type":"text","text":"A"},
21385 {"type":"hardBreak"},
21386 {"type":"text","text":" "},
21387 {"type":"hardBreak"},
21388 {"type":"text","text":"B"}
21389 ]}]}"#;
21390 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21391 let md = adf_to_markdown(&doc).unwrap();
21392 let rt = markdown_to_adf(&md).unwrap();
21393 let inlines = rt.content[0].content.as_ref().unwrap();
21394 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21395 assert_eq!(
21396 types,
21397 vec!["text", "hardBreak", "text", "hardBreak", "text"],
21398 "space between two hardBreaks should survive round-trip"
21399 );
21400 assert_eq!(inlines[2].text.as_deref(), Some(" "));
21401 assert_eq!(inlines[4].text.as_deref(), Some("B"));
21402 }
21403
21404 #[test]
21405 fn issue_494_trailing_space_hardbreak_style_not_confused() {
21406 let md = "first paragraph\n\nsecond paragraph\n";
21409 let doc = markdown_to_adf(md).unwrap();
21410 assert_eq!(
21411 doc.content.len(),
21412 2,
21413 "blank line should still separate paragraphs"
21414 );
21415 }
21416
21417 #[test]
21418 fn issue_494_space_after_trailing_space_hardbreak_roundtrip() {
21419 let md = "line one \n \n";
21422 let doc = markdown_to_adf(md).unwrap();
21426 let inlines = doc.content[0].content.as_ref().unwrap();
21427 let has_text_after_break = inlines.iter().any(|n| {
21428 n.node_type == "text"
21429 && n.text
21430 .as_deref()
21431 .is_some_and(|t| t.trim().is_empty() && !t.is_empty())
21432 });
21433 assert!(
21434 has_text_after_break || inlines.len() >= 2,
21435 "space-only line after trailing-space hardBreak should be preserved"
21436 );
21437 }
21438
21439 #[test]
21440 fn issue_494_space_after_hardbreak_in_list_item_roundtrip() {
21441 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21443 {"type":"listItem","content":[{"type":"paragraph","content":[
21444 {"type":"text","text":"item"},
21445 {"type":"hardBreak"},
21446 {"type":"text","text":" "}
21447 ]}]}
21448 ]}]}"#;
21449 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21450 let md = adf_to_markdown(&doc).unwrap();
21451 let rt = markdown_to_adf(&md).unwrap();
21452 let list = &rt.content[0];
21453 let item = &list.content.as_ref().unwrap()[0];
21454 let para = &item.content.as_ref().unwrap()[0];
21455 let inlines = para.content.as_ref().unwrap();
21456 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21457 assert_eq!(
21458 types,
21459 vec!["text", "hardBreak", "text"],
21460 "space after hardBreak in list item should survive round-trip"
21461 );
21462 assert_eq!(inlines[2].text.as_deref(), Some(" "));
21463 }
21464
21465 #[test]
21468 fn issue_510_trailing_double_space_paragraph_roundtrip() {
21469 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"}]}]}"#;
21472 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21473 let md = adf_to_markdown(&doc).unwrap();
21474 let rt = markdown_to_adf(&md).unwrap();
21475
21476 assert_eq!(
21478 rt.content.len(),
21479 2,
21480 "should produce two paragraphs, got: {}",
21481 rt.content.len()
21482 );
21483 assert_eq!(rt.content[0].node_type, "paragraph");
21484 assert_eq!(rt.content[1].node_type, "paragraph");
21485
21486 let p1 = rt.content[0].content.as_ref().unwrap();
21488 assert_eq!(
21489 p1[0].text.as_deref(),
21490 Some("first paragraph with trailing spaces "),
21491 "trailing spaces should be preserved in first paragraph"
21492 );
21493
21494 let p2 = rt.content[1].content.as_ref().unwrap();
21496 assert_eq!(p2[0].text.as_deref(), Some("second paragraph"));
21497
21498 let all_types: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21500 assert!(
21501 !all_types.contains(&"hardBreak"),
21502 "trailing spaces should not produce hardBreak, got: {all_types:?}"
21503 );
21504 }
21505
21506 #[test]
21507 fn issue_510_trailing_triple_space_roundtrip() {
21508 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"text "}]},{"type":"paragraph","content":[{"type":"text","text":"next"}]}]}"#;
21510 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21511 let md = adf_to_markdown(&doc).unwrap();
21512 let rt = markdown_to_adf(&md).unwrap();
21513
21514 assert_eq!(rt.content.len(), 2, "should still be two paragraphs");
21515 let p1 = rt.content[0].content.as_ref().unwrap();
21516 assert_eq!(
21517 p1[0].text.as_deref(),
21518 Some("text "),
21519 "three trailing spaces should be preserved"
21520 );
21521 }
21522
21523 #[test]
21524 fn issue_510_trailing_spaces_with_backslash_roundtrip() {
21525 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\ "}]}]}"#;
21527 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21528 let md = adf_to_markdown(&doc).unwrap();
21529 let rt = markdown_to_adf(&md).unwrap();
21530 let p = rt.content[0].content.as_ref().unwrap();
21531 assert_eq!(
21532 p[0].text.as_deref(),
21533 Some("end\\ "),
21534 "backslash + trailing spaces should both survive"
21535 );
21536 }
21537
21538 #[test]
21539 fn issue_510_jfm_contains_escaped_trailing_space() {
21540 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello "}]}]}"#;
21542 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21543 let md = adf_to_markdown(&doc).unwrap();
21544 assert!(
21545 md.contains(r"\ "),
21546 "JFM should contain backslash-space escape for trailing spaces, got: {md:?}"
21547 );
21548 for line in md.lines() {
21550 assert!(
21551 !line.ends_with(" "),
21552 "no JFM line should end with two plain spaces, got: {line:?}"
21553 );
21554 }
21555 }
21556
21557 #[test]
21558 fn issue_510_single_trailing_space_not_escaped() {
21559 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"word "}]}]}"#;
21561 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21562 let md = adf_to_markdown(&doc).unwrap();
21563 assert!(
21564 !md.contains('\\'),
21565 "single trailing space should not be escaped, got: {md:?}"
21566 );
21567 let rt = markdown_to_adf(&md).unwrap();
21568 let p = rt.content[0].content.as_ref().unwrap();
21569 assert_eq!(p[0].text.as_deref(), Some("word "));
21570 }
21571
21572 #[test]
21573 fn issue_510_trailing_spaces_in_heading_roundtrip() {
21574 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"heading "}]}]}"#;
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 let h = rt.content[0].content.as_ref().unwrap();
21580 assert_eq!(
21581 h[0].text.as_deref(),
21582 Some("heading "),
21583 "trailing spaces in heading should be preserved"
21584 );
21585 }
21586
21587 #[test]
21588 fn issue_510_trailing_spaces_in_list_item_roundtrip() {
21589 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item "}]}]}]}]}"#;
21591 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21592 let md = adf_to_markdown(&doc).unwrap();
21593 let rt = markdown_to_adf(&md).unwrap();
21594 let list = &rt.content[0];
21595 let item = &list.content.as_ref().unwrap()[0];
21596 let para = &item.content.as_ref().unwrap()[0];
21597 let inlines = para.content.as_ref().unwrap();
21598 assert_eq!(
21599 inlines[0].text.as_deref(),
21600 Some("item "),
21601 "trailing spaces in list item should be preserved"
21602 );
21603 }
21604
21605 #[test]
21606 fn issue_510_trailing_spaces_with_bold_mark_roundtrip() {
21607 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold ","marks":[{"type":"strong"}]}]}]}"#;
21611 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21612 let md = adf_to_markdown(&doc).unwrap();
21613 let rt = markdown_to_adf(&md).unwrap();
21614 let p = rt.content[0].content.as_ref().unwrap();
21615 assert_eq!(
21616 p[0].text.as_deref(),
21617 Some("bold "),
21618 "trailing spaces in bold text should be preserved"
21619 );
21620 }
21621
21622 #[test]
21623 fn issue_510_hardbreak_between_paragraphs_still_works() {
21624 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"line one"},{"type":"hardBreak"},{"type":"text","text":"line two"}]}]}"#;
21626 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21627 let md = adf_to_markdown(&doc).unwrap();
21628 let rt = markdown_to_adf(&md).unwrap();
21629 let inlines = rt.content[0].content.as_ref().unwrap();
21630 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21631 assert_eq!(
21632 types,
21633 vec!["text", "hardBreak", "text"],
21634 "explicit hardBreak should still round-trip"
21635 );
21636 }
21637
21638 #[test]
21639 fn issue_510_all_spaces_text_node_roundtrip() {
21640 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":" "}]}]}"#;
21642 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21643 let md = adf_to_markdown(&doc).unwrap();
21644 let rt = markdown_to_adf(&md).unwrap();
21645 let p = rt.content[0].content.as_ref().unwrap();
21646 assert_eq!(
21647 p[0].text.as_deref(),
21648 Some(" "),
21649 "space-only text node should survive round-trip"
21650 );
21651 }
21652
21653 #[test]
21656 fn issue_522_listitem_hardbreak_then_two_paragraphs_roundtrips() {
21657 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"}]}]}]}]}"#;
21660 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21661 let md = adf_to_markdown(&doc).unwrap();
21662 let rt = markdown_to_adf(&md).unwrap();
21663
21664 let items = rt.content[0].content.as_ref().unwrap();
21665 assert_eq!(items.len(), 1);
21666 let children = items[0].content.as_ref().unwrap();
21667 assert_eq!(
21668 children.len(),
21669 3,
21670 "Expected 3 paragraphs in listItem, got {}",
21671 children.len()
21672 );
21673 assert_eq!(children[0].node_type, "paragraph");
21674 assert_eq!(children[1].node_type, "paragraph");
21675 assert_eq!(children[2].node_type, "paragraph");
21676
21677 let text1 = children[1].content.as_ref().unwrap()[0]
21679 .text
21680 .as_deref()
21681 .unwrap();
21682 assert_eq!(text1, "second paragraph");
21683 let text2 = children[2].content.as_ref().unwrap()[0]
21684 .text
21685 .as_deref()
21686 .unwrap();
21687 assert_eq!(text2, "third paragraph");
21688 }
21689
21690 #[test]
21691 fn issue_522_ordered_list_hardbreak_then_paragraphs_roundtrips() {
21692 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"}]}]}]}]}"#;
21694 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21695 let md = adf_to_markdown(&doc).unwrap();
21696 let rt = markdown_to_adf(&md).unwrap();
21697
21698 let items = rt.content[0].content.as_ref().unwrap();
21699 let children = items[0].content.as_ref().unwrap();
21700 assert_eq!(
21701 children.len(),
21702 3,
21703 "Expected 3 paragraphs in ordered listItem, got {}",
21704 children.len()
21705 );
21706 assert_eq!(children[1].node_type, "paragraph");
21707 assert_eq!(children[2].node_type, "paragraph");
21708 assert_eq!(
21709 children[1].content.as_ref().unwrap()[0]
21710 .text
21711 .as_deref()
21712 .unwrap(),
21713 "second para"
21714 );
21715 assert_eq!(
21716 children[2].content.as_ref().unwrap()[0]
21717 .text
21718 .as_deref()
21719 .unwrap(),
21720 "third para"
21721 );
21722 }
21723
21724 #[test]
21725 fn issue_522_two_paragraphs_without_hardbreak_roundtrips() {
21726 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"}]}]}]}]}"#;
21728 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21729 let md = adf_to_markdown(&doc).unwrap();
21730 let rt = markdown_to_adf(&md).unwrap();
21731
21732 let items = rt.content[0].content.as_ref().unwrap();
21733 let children = items[0].content.as_ref().unwrap();
21734 assert_eq!(
21735 children.len(),
21736 2,
21737 "Expected 2 paragraphs in listItem, got {}",
21738 children.len()
21739 );
21740 assert_eq!(children[0].node_type, "paragraph");
21741 assert_eq!(children[1].node_type, "paragraph");
21742 }
21743
21744 #[test]
21745 fn issue_522_paragraph_then_nested_list_no_spurious_blank() {
21746 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"}]}]}]}]}]}]}"#;
21749 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21750 let md = adf_to_markdown(&doc).unwrap();
21751 assert!(
21753 !md.contains(" \n -"),
21754 "No blank separator between paragraph and nested list"
21755 );
21756 let rt = markdown_to_adf(&md).unwrap();
21757
21758 let items = rt.content[0].content.as_ref().unwrap();
21759 let children = items[0].content.as_ref().unwrap();
21760 assert_eq!(children.len(), 2);
21761 assert_eq!(children[0].node_type, "paragraph");
21762 assert_eq!(children[1].node_type, "bulletList");
21763 }
21764
21765 #[test]
21766 fn issue_522_three_paragraphs_no_hardbreak_roundtrips() {
21767 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"}]}]}]}]}"#;
21769 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21770 let md = adf_to_markdown(&doc).unwrap();
21771 let rt = markdown_to_adf(&md).unwrap();
21772
21773 let items = rt.content[0].content.as_ref().unwrap();
21774 let children = items[0].content.as_ref().unwrap();
21775 assert_eq!(
21776 children.len(),
21777 3,
21778 "Expected 3 paragraphs, got {}",
21779 children.len()
21780 );
21781 for (i, child) in children.iter().enumerate() {
21782 assert_eq!(
21783 child.node_type, "paragraph",
21784 "Child {i} should be a paragraph"
21785 );
21786 }
21787 }
21788
21789 #[test]
21790 fn issue_522_multiple_list_items_each_with_paragraphs() {
21791 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"}]}]}]}]}"#;
21793 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21794 let md = adf_to_markdown(&doc).unwrap();
21795 let rt = markdown_to_adf(&md).unwrap();
21796
21797 let items = rt.content[0].content.as_ref().unwrap();
21798 assert_eq!(items.len(), 2, "Expected 2 list items");
21799
21800 let item1 = items[0].content.as_ref().unwrap();
21801 assert_eq!(item1.len(), 2, "Item 1 should have 2 paragraphs");
21802
21803 let item2 = items[1].content.as_ref().unwrap();
21804 assert_eq!(item2.len(), 2, "Item 2 should have 2 paragraphs");
21805 let item2_p1_inlines = item2[0].content.as_ref().unwrap();
21807 let types: Vec<&str> = item2_p1_inlines
21808 .iter()
21809 .map(|n| n.node_type.as_str())
21810 .collect();
21811 assert_eq!(types, vec!["text", "hardBreak", "text"]);
21812 }
21813
21814 #[test]
21815 fn issue_531_blockquote_hardbreak_then_two_paragraphs_roundtrips() {
21816 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"}]}]}]}"#;
21820 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21821 let md = adf_to_markdown(&doc).unwrap();
21822 let rt = markdown_to_adf(&md).unwrap();
21823
21824 let children = rt.content[0].content.as_ref().unwrap();
21825 assert_eq!(
21826 children.len(),
21827 3,
21828 "Expected 3 paragraphs in blockquote, got {}",
21829 children.len()
21830 );
21831 assert_eq!(children[0].node_type, "paragraph");
21832 assert_eq!(children[1].node_type, "paragraph");
21833 assert_eq!(children[2].node_type, "paragraph");
21834
21835 let text1 = children[1].content.as_ref().unwrap()[0]
21836 .text
21837 .as_deref()
21838 .unwrap();
21839 assert_eq!(text1, "second paragraph");
21840 let text2 = children[2].content.as_ref().unwrap()[0]
21841 .text
21842 .as_deref()
21843 .unwrap();
21844 assert_eq!(text2, "third paragraph");
21845 }
21846
21847 #[test]
21848 fn issue_531_blockquote_two_paragraphs_without_hardbreak_roundtrips() {
21849 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"}]}]}]}"#;
21851 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21852 let md = adf_to_markdown(&doc).unwrap();
21853 let rt = markdown_to_adf(&md).unwrap();
21854
21855 let children = rt.content[0].content.as_ref().unwrap();
21856 assert_eq!(
21857 children.len(),
21858 2,
21859 "Expected 2 paragraphs in blockquote, got {}",
21860 children.len()
21861 );
21862 assert_eq!(children[0].node_type, "paragraph");
21863 assert_eq!(children[1].node_type, "paragraph");
21864 }
21865
21866 #[test]
21867 fn issue_531_blockquote_three_paragraphs_no_hardbreak_roundtrips() {
21868 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"}]}]}]}"#;
21870 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21871 let md = adf_to_markdown(&doc).unwrap();
21872 let rt = markdown_to_adf(&md).unwrap();
21873
21874 let children = rt.content[0].content.as_ref().unwrap();
21875 assert_eq!(
21876 children.len(),
21877 3,
21878 "Expected 3 paragraphs in blockquote, got {}",
21879 children.len()
21880 );
21881 for child in children {
21882 assert_eq!(child.node_type, "paragraph");
21883 }
21884 }
21885
21886 #[test]
21887 fn issue_531_blockquote_paragraph_then_list_no_spurious_blank() {
21888 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"}]}]}]}]}]}"#;
21891 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21892 let md = adf_to_markdown(&doc).unwrap();
21893 let rt = markdown_to_adf(&md).unwrap();
21894
21895 let children = rt.content[0].content.as_ref().unwrap();
21896 assert_eq!(children[0].node_type, "paragraph");
21897 assert_eq!(children[1].node_type, "bulletList");
21898 }
21899
21900 #[test]
21901 fn issue_531_blockquote_single_paragraph_unchanged() {
21902 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"solo"}]}]}]}"#;
21904 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21905 let md = adf_to_markdown(&doc).unwrap();
21906 let rt = markdown_to_adf(&md).unwrap();
21907
21908 let children = rt.content[0].content.as_ref().unwrap();
21909 assert_eq!(children.len(), 1);
21910 assert_eq!(children[0].node_type, "paragraph");
21911 let text = children[0].content.as_ref().unwrap()[0]
21912 .text
21913 .as_deref()
21914 .unwrap();
21915 assert_eq!(text, "solo");
21916 }
21917
21918 fn assert_roundtrip_marks(adf_json: &str, expected_marks: &[&str]) {
21923 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21924 let md = adf_to_markdown(&doc).unwrap();
21925 let rt = markdown_to_adf(&md).unwrap();
21926 let node = &rt.content[0].content.as_ref().unwrap()[0];
21927 let mark_types: Vec<&str> = node
21928 .marks
21929 .as_ref()
21930 .expect("should have marks")
21931 .iter()
21932 .map(|m| m.mark_type.as_str())
21933 .collect();
21934 assert_eq!(
21935 mark_types, expected_marks,
21936 "mark order mismatch for md={md}"
21937 );
21938 }
21939
21940 #[test]
21941 fn issue_554_code_and_text_color_preserved() {
21942 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21943 {"type":"text","text":"x","marks":[
21944 {"type":"textColor","attrs":{"color":"#008000"}},
21945 {"type":"code"}
21946 ]}
21947 ]}]}"##;
21948 assert_roundtrip_marks(adf_json, &["textColor", "code"]);
21949 }
21950
21951 #[test]
21952 fn issue_554_code_and_bg_color_preserved() {
21953 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21954 {"type":"text","text":"x","marks":[
21955 {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21956 {"type":"code"}
21957 ]}
21958 ]}]}"##;
21959 assert_roundtrip_marks(adf_json, &["backgroundColor", "code"]);
21960 }
21961
21962 #[test]
21963 fn issue_554_code_and_subsup_preserved() {
21964 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21965 {"type":"text","text":"x","marks":[
21966 {"type":"subsup","attrs":{"type":"sub"}},
21967 {"type":"code"}
21968 ]}
21969 ]}]}"#;
21970 assert_roundtrip_marks(adf_json, &["subsup", "code"]);
21971 }
21972
21973 #[test]
21974 fn issue_554_code_and_underline_preserved() {
21975 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21976 {"type":"text","text":"x","marks":[
21977 {"type":"underline"},
21978 {"type":"code"}
21979 ]}
21980 ]}]}"#;
21981 assert_roundtrip_marks(adf_json, &["underline", "code"]);
21982 }
21983
21984 #[test]
21985 fn issue_554_code_textcolor_and_underline_preserved() {
21986 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21987 {"type":"text","text":"x","marks":[
21988 {"type":"textColor","attrs":{"color":"#008000"}},
21989 {"type":"underline"},
21990 {"type":"code"}
21991 ]}
21992 ]}]}"##;
21993 assert_roundtrip_marks(adf_json, &["textColor", "underline", "code"]);
21994 }
21995
21996 #[test]
21997 fn issue_554_textcolor_and_underline_preserved() {
21998 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21999 {"type":"text","text":"x","marks":[
22000 {"type":"textColor","attrs":{"color":"#008000"}},
22001 {"type":"underline"}
22002 ]}
22003 ]}]}"##;
22004 assert_roundtrip_marks(adf_json, &["textColor", "underline"]);
22005 }
22006
22007 #[test]
22008 fn issue_554_underline_and_textcolor_preserved_order_swapped() {
22009 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22010 {"type":"text","text":"x","marks":[
22011 {"type":"underline"},
22012 {"type":"textColor","attrs":{"color":"#008000"}}
22013 ]}
22014 ]}]}"##;
22015 assert_roundtrip_marks(adf_json, &["underline", "textColor"]);
22017 }
22018
22019 #[test]
22020 fn issue_554_textcolor_and_annotation_preserved() {
22021 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22022 {"type":"text","text":"x","marks":[
22023 {"type":"textColor","attrs":{"color":"#008000"}},
22024 {"type":"annotation","attrs":{"id":"abc-123","annotationType":"inlineComment"}}
22025 ]}
22026 ]}]}"##;
22027 assert_roundtrip_marks(adf_json, &["textColor", "annotation"]);
22028 }
22029
22030 #[test]
22031 fn issue_554_bgcolor_and_underline_preserved() {
22032 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22033 {"type":"text","text":"x","marks":[
22034 {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
22035 {"type":"underline"}
22036 ]}
22037 ]}]}"##;
22038 assert_roundtrip_marks(adf_json, &["backgroundColor", "underline"]);
22039 }
22040
22041 #[test]
22042 fn issue_554_subsup_and_underline_preserved() {
22043 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22044 {"type":"text","text":"x","marks":[
22045 {"type":"subsup","attrs":{"type":"sub"}},
22046 {"type":"underline"}
22047 ]}
22048 ]}]}"#;
22049 assert_roundtrip_marks(adf_json, &["subsup", "underline"]);
22050 }
22051
22052 #[test]
22053 fn issue_554_exact_reproducer_full_match() {
22054 let adf_json = r##"{
22057 "version": 1,
22058 "type": "doc",
22059 "content": [
22060 {
22061 "type": "paragraph",
22062 "content": [
22063 {"type":"text","text":"Status: ","marks":[{"type":"strong"}]},
22064 {"type":"text","text":"Approved","marks":[
22065 {"type":"textColor","attrs":{"color":"#008000"}}
22066 ]},
22067 {"type":"text","text":" — ready to proceed"}
22068 ]
22069 }
22070 ]
22071 }"##;
22072 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22073 let md = adf_to_markdown(&doc).unwrap();
22074 assert!(
22075 md.contains(":span[Approved]{color=#008000}"),
22076 "JFM should contain green span: {md}"
22077 );
22078 let rt = markdown_to_adf(&md).unwrap();
22079 let approved = rt.content[0]
22081 .content
22082 .as_ref()
22083 .unwrap()
22084 .iter()
22085 .find(|n| n.text.as_deref() == Some("Approved"))
22086 .expect("Approved text node");
22087 let marks = approved.marks.as_ref().expect("should have marks");
22088 let color_mark = marks
22089 .iter()
22090 .find(|m| m.mark_type == "textColor")
22091 .expect("textColor mark must be preserved");
22092 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
22093 }
22094
22095 #[test]
22096 fn issue_554_textcolor_with_code_renders_span_around_code() {
22097 let doc = AdfDocument {
22100 version: 1,
22101 doc_type: "doc".to_string(),
22102 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
22103 "fn main",
22104 vec![
22105 AdfMark::text_color("#008000"),
22106 AdfMark {
22107 mark_type: "code".to_string(),
22108 attrs: None,
22109 },
22110 ],
22111 )])],
22112 };
22113 let md = adf_to_markdown(&doc).unwrap();
22114 assert!(
22115 md.contains(":span[`fn main`]{color=#008000}"),
22116 "expected span-wrapped code, got: {md}"
22117 );
22118 }
22119
22120 #[test]
22121 fn issue_554_underline_with_code_renders_bracketed_around_code() {
22122 let doc = AdfDocument {
22123 version: 1,
22124 doc_type: "doc".to_string(),
22125 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
22126 "fn main",
22127 vec![
22128 AdfMark::underline(),
22129 AdfMark {
22130 mark_type: "code".to_string(),
22131 attrs: None,
22132 },
22133 ],
22134 )])],
22135 };
22136 let md = adf_to_markdown(&doc).unwrap();
22137 assert!(
22138 md.contains("[`fn main`]{underline}"),
22139 "expected bracketed-span around code, got: {md}"
22140 );
22141 }
22142
22143 #[test]
22146 fn issue_554_underscore_adjacent_to_textcolor_span_roundtrip() {
22147 let adf_json = r##"{
22152 "version": 1,
22153 "type": "doc",
22154 "content": [
22155 {
22156 "type": "paragraph",
22157 "content": [
22158 {"type":"text","text":"_ "},
22159 {"type":"text","text":"_Action:*","marks":[
22160 {"type":"textColor","attrs":{"color":"#008000"}}
22161 ]},
22162 {"type":"text","text":" Complete the setup process."}
22163 ]
22164 }
22165 ]
22166 }"##;
22167 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22168 let md = adf_to_markdown(&doc).unwrap();
22169 assert!(
22172 md.contains(r"\_ ") && md.contains(r":span[\_Action"),
22173 "underscores at node boundaries should be escaped: {md}"
22174 );
22175 let rt = markdown_to_adf(&md).unwrap();
22176 let para_content = rt.content[0].content.as_ref().unwrap();
22177 let colored = para_content
22179 .iter()
22180 .find(|n| {
22181 n.marks
22182 .as_deref()
22183 .is_some_and(|ms| ms.iter().any(|m| m.mark_type == "textColor"))
22184 })
22185 .expect("textColor node must be preserved");
22186 assert_eq!(colored.text.as_deref(), Some("_Action:*"));
22187 let color_mark = colored
22188 .marks
22189 .as_ref()
22190 .unwrap()
22191 .iter()
22192 .find(|m| m.mark_type == "textColor")
22193 .unwrap();
22194 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
22195 for n in para_content {
22197 if let Some(ms) = n.marks.as_deref() {
22198 assert!(
22199 !ms.iter().any(|m| m.mark_type == "em"),
22200 "no em mark should appear, got marks {:?}",
22201 ms.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
22202 );
22203 }
22204 }
22205 }
22206
22207 #[test]
22208 fn issue_554_underscore_intraword_left_unescaped() {
22209 let doc = AdfDocument {
22213 version: 1,
22214 doc_type: "doc".to_string(),
22215 content: vec![AdfNode::paragraph(vec![AdfNode::text(
22216 "call do_something_useful now",
22217 )])],
22218 };
22219 let md = adf_to_markdown(&doc).unwrap();
22220 assert!(
22221 md.contains("do_something_useful") && !md.contains(r"do\_something\_useful"),
22222 "intraword underscores should not be escaped: {md}"
22223 );
22224 }
22225
22226 #[test]
22227 fn issue_554_code_underline_then_textcolor_bracketed_outer() {
22228 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22231 {"type":"text","text":"x","marks":[
22232 {"type":"underline"},
22233 {"type":"textColor","attrs":{"color":"#008000"}},
22234 {"type":"code"}
22235 ]}
22236 ]}]}"##;
22237 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22238 let md = adf_to_markdown(&doc).unwrap();
22239 assert!(
22241 md.starts_with('[') && md.contains("underline}"),
22242 "bracketed-span should wrap the span, got: {md}"
22243 );
22244 let rt = markdown_to_adf(&md).unwrap();
22245 let node = &rt.content[0].content.as_ref().unwrap()[0];
22246 let mark_types: Vec<&str> = node
22247 .marks
22248 .as_ref()
22249 .unwrap()
22250 .iter()
22251 .map(|m| m.mark_type.as_str())
22252 .collect();
22253 assert_eq!(mark_types, vec!["underline", "textColor", "code"]);
22254 }
22255
22256 #[test]
22257 fn issue_554_textcolor_underline_link_all_preserved() {
22258 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22262 {"type":"text","text":"linked","marks":[
22263 {"type":"textColor","attrs":{"color":"#008000"}},
22264 {"type":"underline"},
22265 {"type":"link","attrs":{"href":"https://example.com"}}
22266 ]}
22267 ]}]}"##;
22268 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22269 let md = adf_to_markdown(&doc).unwrap();
22270 let rt = markdown_to_adf(&md).unwrap();
22271 let node = &rt.content[0].content.as_ref().unwrap()[0];
22272 let mark_types: Vec<&str> = node
22273 .marks
22274 .as_ref()
22275 .unwrap()
22276 .iter()
22277 .map(|m| m.mark_type.as_str())
22278 .collect();
22279 assert_eq!(mark_types, vec!["textColor", "underline", "link"]);
22280 }
22281
22282 #[test]
22283 fn issue_554_underline_textcolor_link_bracketed_outer_link_last() {
22284 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22287 {"type":"text","text":"linked","marks":[
22288 {"type":"underline"},
22289 {"type":"textColor","attrs":{"color":"#008000"}},
22290 {"type":"link","attrs":{"href":"https://example.com"}}
22291 ]}
22292 ]}]}"##;
22293 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22294 let md = adf_to_markdown(&doc).unwrap();
22295 let rt = markdown_to_adf(&md).unwrap();
22296 let node = &rt.content[0].content.as_ref().unwrap()[0];
22297 let mark_types: Vec<&str> = node
22298 .marks
22299 .as_ref()
22300 .unwrap()
22301 .iter()
22302 .map(|m| m.mark_type.as_str())
22303 .collect();
22304 assert_eq!(mark_types, vec!["underline", "textColor", "link"]);
22305 }
22306
22307 #[test]
22308 fn issue_554_link_underline_textcolor_link_outer() {
22309 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22313 {"type":"text","text":"linked","marks":[
22314 {"type":"link","attrs":{"href":"https://example.com"}},
22315 {"type":"underline"},
22316 {"type":"textColor","attrs":{"color":"#008000"}}
22317 ]}
22318 ]}]}"##;
22319 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22320 let md = adf_to_markdown(&doc).unwrap();
22321 assert!(
22322 md.starts_with('[') && md.contains("](https://example.com)"),
22323 "link should be outermost, got: {md}"
22324 );
22325 let rt = markdown_to_adf(&md).unwrap();
22326 let node = &rt.content[0].content.as_ref().unwrap()[0];
22327 let mark_types: Vec<&str> = node
22328 .marks
22329 .as_ref()
22330 .unwrap()
22331 .iter()
22332 .map(|m| m.mark_type.as_str())
22333 .collect();
22334 assert_eq!(mark_types, vec!["link", "underline", "textColor"]);
22335 }
22336
22337 #[test]
22338 fn issue_554_trailing_underscore_then_leading_underscore_round_trip() {
22339 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22343 {"type":"text","text":"end_"},
22344 {"type":"text","text":"_start"}
22345 ]}]}"#;
22346 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22347 let md = adf_to_markdown(&doc).unwrap();
22348 let rt = markdown_to_adf(&md).unwrap();
22349 let combined: String = rt.content[0]
22351 .content
22352 .as_ref()
22353 .unwrap()
22354 .iter()
22355 .filter_map(|n| n.text.as_deref())
22356 .collect();
22357 assert_eq!(combined, "end__start");
22358 for n in rt.content[0].content.as_ref().unwrap() {
22360 if let Some(ms) = n.marks.as_deref() {
22361 assert!(!ms.iter().any(|m| m.mark_type == "em"));
22362 }
22363 }
22364 }
22365
22366 #[test]
22374 fn blockquote_nesting_beyond_cap_errors() {
22375 let md = format!("{}x", "> ".repeat(200));
22376 let err = markdown_to_adf(&md).unwrap_err();
22377 assert!(err.to_string().contains("maximum depth"));
22378 }
22379
22380 #[test]
22381 fn list_nesting_beyond_cap_errors() {
22382 let mut md = String::new();
22383 for i in 0..200 {
22384 md.push_str(&" ".repeat(i));
22385 md.push_str("- x\n");
22386 }
22387 let err = markdown_to_adf(&md).unwrap_err();
22388 assert!(err.to_string().contains("maximum depth"));
22389 }
22390
22391 #[test]
22392 fn container_directive_nesting_beyond_cap_errors() {
22393 let mut md = String::new();
22394 for _ in 0..200 {
22395 md.push_str(":::panel{type=info}\n");
22396 }
22397 md.push_str("body\n");
22398 for _ in 0..200 {
22399 md.push_str(":::\n");
22400 }
22401 let err = markdown_to_adf(&md).unwrap_err();
22402 assert!(err.to_string().contains("maximum depth"));
22403 }
22404
22405 #[test]
22406 fn directive_table_cell_nesting_beyond_cap_errors() {
22407 let mut md = String::new();
22408 for _ in 0..200 {
22409 md.push_str("::::table\n:::tr\n:::td\n");
22410 }
22411 md.push_str("cell\n");
22412 for _ in 0..200 {
22413 md.push_str(":::\n:::\n::::\n");
22414 }
22415 let err = markdown_to_adf(&md).unwrap_err();
22416 assert!(err.to_string().contains("maximum depth"));
22417 }
22418
22419 #[test]
22420 fn layout_column_nesting_beyond_cap_errors() {
22421 let mut md = String::new();
22422 for _ in 0..200 {
22423 md.push_str("::::layout\n:::column{width=100}\n");
22424 }
22425 md.push_str("x\n");
22426 for _ in 0..200 {
22427 md.push_str(":::\n::::\n");
22428 }
22429 let err = markdown_to_adf(&md).unwrap_err();
22430 assert!(err.to_string().contains("maximum depth"));
22431 }
22432
22433 #[test]
22434 fn blockquote_nesting_at_cap_boundary() {
22435 let at_cap = format!("{}x", "> ".repeat(MAX_NESTING_DEPTH));
22438 assert!(markdown_to_adf(&at_cap).is_ok());
22439 let past_cap = format!("{}x", "> ".repeat(MAX_NESTING_DEPTH + 1));
22440 let err = markdown_to_adf(&past_cap).unwrap_err();
22441 assert!(err.to_string().contains("maximum depth"));
22442 }
22443
22444 #[test]
22445 fn list_nesting_at_cap_succeeds() {
22446 let mut md = String::new();
22450 for i in 0..MAX_NESTING_DEPTH {
22451 md.push_str(&" ".repeat(i));
22452 md.push_str("- x\n");
22453 }
22454 assert!(markdown_to_adf(&md).is_ok());
22455 }
22456
22457 #[test]
22458 fn deep_but_reasonable_nesting_succeeds() {
22459 let md = format!("{}deep", "> ".repeat(20));
22460 let doc = markdown_to_adf(&md).unwrap();
22461 let mut node = &doc.content[0];
22463 for _ in 0..20 {
22464 assert_eq!(node.node_type, "blockquote");
22465 node = &node.content.as_ref().unwrap()[0];
22466 }
22467 assert_eq!(node.node_type, "paragraph");
22468 }
22469
22470 #[test]
22471 fn deeply_nested_emphasis_degrades_without_overflow() {
22472 let md = format!("{}x{}", "*_".repeat(400), "_*".repeat(400));
22473 let doc = markdown_to_adf(&md).unwrap();
22474 assert_eq!(doc.content[0].node_type, "paragraph");
22475 }
22476
22477 #[test]
22478 fn deeply_nested_links_degrade_without_overflow() {
22479 let mut md = String::from("x");
22480 for _ in 0..400 {
22481 md = format!("[{md}](https://example.com)");
22482 }
22483 let doc = markdown_to_adf(&md).unwrap();
22484 assert_eq!(doc.content[0].node_type, "paragraph");
22485 }
22486
22487 #[test]
22488 fn deeply_nested_bracketed_spans_degrade_without_overflow() {
22489 let mut md = String::from("x");
22490 for _ in 0..400 {
22491 md = format!("[{md}]{{underline}}");
22492 }
22493 let doc = markdown_to_adf(&md).unwrap();
22494 assert_eq!(doc.content[0].node_type, "paragraph");
22495 }
22496}