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 debug!(
43 "markdown_to_adf: produced {} top-level ADF nodes",
44 doc.content.len()
45 );
46 Ok(doc)
47}
48
49struct MarkdownParser<'a> {
51 lines: Vec<&'a str>,
52 pos: usize,
53 depth: usize,
58}
59
60impl<'a> MarkdownParser<'a> {
61 fn new(input: &'a str) -> Self {
62 Self::with_depth(input, 0)
63 }
64
65 fn with_depth(input: &'a str, depth: usize) -> Self {
67 Self {
68 lines: input.lines().collect(),
69 pos: 0,
70 depth,
71 }
72 }
73
74 fn at_end(&self) -> bool {
75 self.pos >= self.lines.len()
76 }
77
78 fn current_line(&self) -> &'a str {
79 self.lines[self.pos]
80 }
81
82 fn advance(&mut self) {
83 self.pos += 1;
84 }
85
86 fn collect_hardbreak_continuations(&mut self, full_text: &mut String) {
94 while has_trailing_hard_break(full_text) && !self.at_end() {
95 if !self.try_append_hardbreak_continuation(full_text) {
96 break;
97 }
98 }
99 }
100
101 fn try_append_hardbreak_continuation(&mut self, full_text: &mut String) -> bool {
108 match self
114 .current_line()
115 .strip_prefix(" ")
116 .filter(|s| !is_block_level_continuation_marker(s.trim_start()))
117 {
118 Some(stripped) => {
119 full_text.push('\n');
120 full_text.push_str(stripped);
121 self.advance();
122 true
123 }
124 None => false,
125 }
126 }
127
128 fn parse_blocks(&mut self) -> Result<Vec<AdfNode>> {
129 if self.depth > MAX_NESTING_DEPTH {
130 bail!(
131 "Markdown nesting exceeds the maximum depth of {MAX_NESTING_DEPTH} \
132 (nested blockquotes, lists, panels, expands, layouts, or table \
133 cells); flatten the document structure"
134 );
135 }
136
137 let mut blocks = Vec::new();
138
139 while !self.at_end() {
140 let line = self.current_line();
141
142 if line.trim().is_empty() {
143 self.advance();
144 continue;
145 }
146
147 let mut node = if let Some(node) = self.try_heading() {
148 node
149 } else if let Some(node) = self.try_horizontal_rule() {
150 node
151 } else if let Some(node) = self.try_container_directive()? {
152 node
153 } else if let Some(node) = self.try_code_block()? {
154 node
155 } else if let Some(node) = self.try_table()? {
156 node
157 } else if let Some(node) = self.try_blockquote()? {
158 node
159 } else if let Some(node) = self.try_list()? {
160 node
161 } else if let Some(node) = self.try_leaf_directive() {
162 node
163 } else if let Some(node) = self.try_image() {
164 node
165 } else {
166 self.parse_paragraph()?
167 };
168
169 self.try_apply_block_attrs(&mut node);
171 blocks.push(node);
172 }
173
174 Ok(blocks)
175 }
176
177 fn try_heading(&mut self) -> Option<AdfNode> {
178 let line = self.current_line();
179 let trimmed = line.trim_start();
180
181 if !trimmed.starts_with('#') {
182 return None;
183 }
184
185 let level = trimmed.chars().take_while(|&c| c == '#').count();
186 if !(1..=6).contains(&level) || !trimmed[level..].starts_with(' ') {
187 return None;
188 }
189
190 let mut full_text = trimmed[level + 1..].to_string();
191 self.advance();
192 self.collect_hardbreak_continuations(&mut full_text);
194 let mut inline_nodes = parse_inline(&full_text);
195 if strip_heading_code_marks(&mut inline_nodes) > 0 {
200 warn!(
201 "Stripped inline `code` from heading (ADF forbids code marks on \
202 headings; kept the text as plain): {}",
203 full_text.trim()
204 );
205 }
206
207 #[allow(clippy::cast_possible_truncation)]
208 Some(AdfNode::heading(level as u8, inline_nodes))
209 }
210
211 fn try_horizontal_rule(&mut self) -> Option<AdfNode> {
212 let line = self.current_line().trim();
213 let is_rule = (line.starts_with("---") && line.chars().all(|c| c == '-'))
214 || (line.starts_with("***") && line.chars().all(|c| c == '*'))
215 || (line.starts_with("___") && line.chars().all(|c| c == '_'));
216
217 if is_rule && line.len() >= 3 {
218 self.advance();
219 Some(AdfNode::rule())
220 } else {
221 None
222 }
223 }
224
225 fn try_code_block(&mut self) -> Result<Option<AdfNode>> {
226 let line = self.current_line();
227 if !is_code_fence_opener(line) {
228 return Ok(None);
229 }
230
231 let language = line[3..].trim();
232 let language = if language == "\"\"" {
233 Some(String::new())
235 } else if language.is_empty() {
236 None
237 } else {
238 Some(language.to_string())
239 };
240
241 self.advance();
242 let mut code_lines = Vec::new();
243
244 while !self.at_end() {
245 let line = self.current_line();
246 if line.starts_with("```") {
247 self.advance();
248 break;
249 }
250 code_lines.push(line);
251 self.advance();
252 }
253
254 let code_text = code_lines.join("\n");
255
256 if language.as_deref() == Some("adf-unsupported") {
258 if let Ok(node) = serde_json::from_str::<AdfNode>(&code_text) {
259 return Ok(Some(node));
260 }
261 }
262
263 Ok(Some(AdfNode::code_block(language.as_deref(), &code_text)))
264 }
265
266 fn try_blockquote(&mut self) -> Result<Option<AdfNode>> {
267 let line = self.current_line();
268 if !line.starts_with('>') {
269 return Ok(None);
270 }
271
272 let mut quote_lines = Vec::new();
273 while !self.at_end() {
274 let line = self.current_line();
275 if let Some(rest) = line.strip_prefix("> ") {
276 quote_lines.push(rest);
277 self.advance();
278 } else if let Some(rest) = line.strip_prefix('>') {
279 quote_lines.push(rest);
280 self.advance();
281 } else {
282 break;
283 }
284 }
285
286 let quote_text = quote_lines.join("\n");
287 let mut inner_parser = MarkdownParser::with_depth("e_text, self.depth + 1);
288 let inner_blocks = inner_parser.parse_blocks()?;
289
290 Ok(Some(AdfNode::blockquote(inner_blocks)))
291 }
292
293 fn try_list(&mut self) -> Result<Option<AdfNode>> {
294 let line = self.current_line();
295 let trimmed = line.trim_start();
296
297 let is_bullet =
298 trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ");
299 let ordered_match = parse_ordered_list_marker(trimmed);
300
301 if !is_bullet && ordered_match.is_none() {
302 return Ok(None);
303 }
304
305 if is_bullet {
306 self.parse_bullet_list()
307 } else {
308 let start = ordered_match.map_or(1, |(n, _)| n);
309 self.parse_ordered_list(start)
310 }
311 }
312
313 fn parse_bullet_list(&mut self) -> Result<Option<AdfNode>> {
314 let mut items = Vec::new();
315 let mut is_task_list = false;
316
317 while !self.at_end() {
318 let line = self.current_line();
319 let trimmed = line.trim_start();
320
321 if !(trimmed.starts_with("- ")
322 || trimmed.starts_with("* ")
323 || trimmed.starts_with("+ "))
324 {
325 break;
326 }
327
328 let after_marker = trimmed[2..].trim_start();
329
330 if let Some((state, text)) = try_parse_task_marker(after_marker) {
332 is_task_list = true;
333 self.advance();
334 let mut full_text = text.to_string();
338 self.collect_hardbreak_continuations(&mut full_text);
339 let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
340 let inline_nodes = parse_inline(item_text);
341 let content = if let Some(ref plid) = para_local_id {
345 let mut para = AdfNode::paragraph(inline_nodes);
346 if plid != "_" {
347 para.attrs = Some(serde_json::json!({"localId": plid}));
348 }
349 vec![para]
350 } else {
351 inline_nodes
352 };
353 let mut task = AdfNode::task_item(state, content);
354 if let Some(id) = local_id {
356 if let Some(ref mut attrs) = task.attrs {
357 attrs["localId"] = serde_json::Value::String(id);
358 }
359 }
360 let mut sub_lines: Vec<String> = Vec::new();
364 while !self.at_end() && self.current_line().starts_with(" ") {
365 let stripped = &self.current_line()[2..];
366 sub_lines.push(stripped.to_string());
367 self.advance();
368 }
369 if !sub_lines.is_empty() {
370 let sub_text = sub_lines.join("\n");
371 let mut nested =
372 MarkdownParser::with_depth(&sub_text, self.depth + 1).parse_blocks()?;
373 let is_empty = task.content.as_ref().map_or(true, Vec::is_empty);
381 if is_empty && nested.len() == 1 && nested[0].node_type == "taskList" {
382 if let Some(task_items) = nested.remove(0).content {
383 task.content = Some(task_items);
384 }
385 if let Some(ref mut attrs) = task.attrs {
386 if let Some(obj) = attrs.as_object_mut() {
387 obj.remove("state");
388 }
389 }
390 items.push(task);
391 } else {
392 let mut sibling_task_lists = Vec::new();
398 let mut child_nodes = Vec::new();
399 for n in nested {
400 if n.node_type == "taskList" {
401 sibling_task_lists.push(n);
402 } else {
403 child_nodes.push(n);
404 }
405 }
406 if !child_nodes.is_empty() {
407 match task.content {
408 Some(ref mut content) => content.append(&mut child_nodes),
409 None => task.content = Some(child_nodes),
410 }
411 }
412 items.push(task);
413 items.append(&mut sibling_task_lists);
414 }
415 } else {
416 items.push(task);
417 }
418 } else {
419 let first_line = &trimmed[2..];
420 self.advance();
421 let mut full_text = first_line.to_string();
422 self.collect_hardbreak_continuations(&mut full_text);
423 let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
424 let mut sub_lines: Vec<String> = Vec::new();
428 while !self.at_end() {
429 let next = self.current_line();
430 if let Some(stripped) = next.strip_prefix(" ") {
431 sub_lines.push(stripped.to_string());
432 self.advance();
433 continue;
434 }
435 break;
436 }
437 let item_content = parse_list_item_first_line(
438 item_text,
439 sub_lines,
440 local_id,
441 para_local_id,
442 self.depth + 1,
443 )?;
444 items.push(item_content);
445 }
446 }
447
448 if items.is_empty() {
449 Ok(None)
450 } else if is_task_list {
451 Ok(Some(AdfNode::task_list(items)))
452 } else {
453 Ok(Some(AdfNode::bullet_list(items)))
454 }
455 }
456
457 fn parse_ordered_list(&mut self, start: u32) -> Result<Option<AdfNode>> {
458 let mut items = Vec::new();
459
460 while !self.at_end() {
461 let line = self.current_line();
462 let trimmed = line.trim_start();
463
464 if let Some((_, rest)) = parse_ordered_list_marker(trimmed) {
465 let first_line = rest.trim_start_matches(|c: char| c.is_ascii_whitespace());
466 self.advance();
467 let mut full_text = first_line.to_string();
468 self.collect_hardbreak_continuations(&mut full_text);
469 let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
470 let mut sub_lines: Vec<String> = Vec::new();
472 while !self.at_end() {
473 let next = self.current_line();
474 if let Some(stripped) = next.strip_prefix(" ") {
475 sub_lines.push(stripped.to_string());
476 self.advance();
477 continue;
478 }
479 break;
480 }
481 let item_content = parse_list_item_first_line(
482 item_text,
483 sub_lines,
484 local_id,
485 para_local_id,
486 self.depth + 1,
487 )?;
488 items.push(item_content);
489 } else {
490 break;
491 }
492 }
493
494 if items.is_empty() {
495 Ok(None)
496 } else {
497 let order = if start == 1 { None } else { Some(start) };
498 Ok(Some(AdfNode::ordered_list(items, order)))
499 }
500 }
501
502 fn try_apply_block_attrs(&mut self, node: &mut AdfNode) {
503 if self.at_end() {
504 return;
505 }
506 let line = self.current_line().trim();
507 if !line.starts_with('{') {
508 return;
509 }
510 let Some((_, attrs)) = parse_attrs(line, 0) else {
511 return;
512 };
513
514 let mut marks = Vec::new();
515 if let Some(align) = attrs.get("align") {
516 marks.push(AdfMark::alignment(align));
517 }
518 if let Some(indent) = attrs.get("indent") {
519 if let Ok(level) = indent.parse::<u32>() {
520 marks.push(AdfMark::indentation(level));
521 }
522 }
523 if let Some(mode) = attrs.get("breakout") {
524 let width = attrs
525 .get("breakoutWidth")
526 .and_then(|w| w.parse::<u32>().ok());
527 marks.push(AdfMark::breakout(mode, width));
528 }
529
530 let local_id = attrs.get("localId").map(str::to_string);
532
533 let order = if node.node_type == "orderedList" {
535 attrs.get("order").and_then(|v| v.parse::<u32>().ok())
536 } else {
537 None
538 };
539
540 let has_attrs = !marks.is_empty() || local_id.is_some() || order.is_some();
541 if has_attrs {
542 if !marks.is_empty() {
543 let existing = node.marks.get_or_insert_with(Vec::new);
544 existing.extend(marks);
545 }
546 if let Some(id) = local_id {
547 let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
548 node_attrs["localId"] = serde_json::Value::String(id);
549 }
550 if let Some(n) = order {
551 let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
552 node_attrs["order"] = serde_json::json!(n);
553 }
554 self.advance(); }
556 }
557
558 fn try_container_directive(&mut self) -> Result<Option<AdfNode>> {
559 let line = self.current_line();
560 let Some((d, colon_count)) = try_parse_container_open(line) else {
561 return Ok(None);
562 };
563 self.advance(); let mut inner_lines = Vec::new();
567 let mut depth: usize = 0;
568 while !self.at_end() {
569 let current = self.current_line();
570 if try_parse_container_open(current).is_some() {
571 depth += 1;
572 } else if depth == 0 && is_container_close(current, colon_count) {
573 self.advance(); break;
575 } else if depth > 0 && is_container_close(current, 3) {
576 depth -= 1;
577 }
578 inner_lines.push(current.to_string());
579 self.advance();
580 }
581
582 let inner_text = inner_lines.join("\n");
583
584 let node = match d.name.as_str() {
585 "panel" => {
586 let panel_type = d
587 .attrs
588 .as_ref()
589 .and_then(|a| a.get("type"))
590 .unwrap_or("info");
591 let inner_blocks =
592 MarkdownParser::with_depth(&inner_text, self.depth + 1).parse_blocks()?;
593 let mut node = AdfNode::panel(panel_type, inner_blocks);
594 if let Some(ref attrs) = d.attrs {
596 if let Some(ref mut node_attrs) = node.attrs {
597 if let Some(icon) = attrs.get("icon") {
598 node_attrs["panelIcon"] = serde_json::Value::String(icon.to_string());
599 }
600 if let Some(color) = attrs.get("color") {
601 node_attrs["panelColor"] = serde_json::Value::String(color.to_string());
602 }
603 }
604 }
605 node
606 }
607 "expand" => {
608 let title = d.attrs.as_ref().and_then(|a| a.get("title"));
609 let inner_blocks =
610 MarkdownParser::with_depth(&inner_text, self.depth + 1).parse_blocks()?;
611 let mut node = AdfNode::expand(title, inner_blocks);
612 pass_through_expand_params(&d.attrs, &mut node);
613 node
614 }
615 "nested-expand" => {
616 let title = d.attrs.as_ref().and_then(|a| a.get("title"));
617 let inner_blocks =
618 MarkdownParser::with_depth(&inner_text, self.depth + 1).parse_blocks()?;
619 let mut node = AdfNode::nested_expand(title, inner_blocks);
620 pass_through_expand_params(&d.attrs, &mut node);
621 node
622 }
623 "layout" => {
624 let columns = self.parse_layout_columns(&inner_text)?;
626 AdfNode::layout_section(columns)
627 }
628 "decisions" => {
629 let items = parse_decision_items(&inner_text);
630 AdfNode::decision_list(items)
631 }
632 "table" => {
633 let rows = self.parse_directive_table_rows(&inner_text)?;
634 let mut table_attrs = serde_json::json!({});
635 if let Some(ref attrs) = d.attrs {
636 if let Some(layout) = attrs.get("layout") {
637 table_attrs["layout"] = serde_json::Value::String(layout.to_string());
638 }
639 if attrs.has_flag("numbered") {
640 table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
641 } else if attrs.get("numbered") == Some("false") {
642 table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
643 }
644 if let Some(tw) = attrs.get("width") {
645 if let Some(w) = parse_numeric_attr(tw) {
646 table_attrs["width"] = w;
647 }
648 }
649 if let Some(local_id) = attrs.get("localId") {
650 table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
651 }
652 }
653 if table_attrs == serde_json::json!({}) {
654 AdfNode::table(rows)
655 } else {
656 AdfNode::table_with_attrs(rows, table_attrs)
657 }
658 }
659 "extension" => {
660 let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
661 let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
662 let inner_blocks =
663 MarkdownParser::with_depth(&inner_text, self.depth + 1).parse_blocks()?;
664 let mut node = AdfNode::bodied_extension(ext_type, ext_key, inner_blocks);
665 if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
666 {
667 if let Some(layout) = dir_attrs.get("layout") {
668 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
669 }
670 if let Some(local_id) = dir_attrs.get("localId") {
671 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
672 }
673 if let Some(params_str) = dir_attrs.get("params") {
674 if let Ok(params_val) =
675 serde_json::from_str::<serde_json::Value>(params_str)
676 {
677 node_attrs["parameters"] = params_val;
678 }
679 }
680 }
681 node
682 }
683 _ => return Ok(None),
684 };
685
686 Ok(Some(node))
687 }
688
689 fn parse_layout_columns(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
690 let mut columns = Vec::new();
691 let mut current_column_lines: Vec<String> = Vec::new();
692 let mut current_width: serde_json::Value = serde_json::json!(50);
693 let mut current_dir_attrs: Option<crate::atlassian::attrs::Attrs> = None;
694 let mut in_column = false;
695 let mut depth: usize = 0;
696
697 let lines: Vec<&str> = inner_text.lines().collect();
698 let mut i = 0;
699
700 while i < lines.len() {
701 let line = lines[i];
702 if let Some((col_d, _)) = try_parse_container_open(line) {
703 if col_d.name == "column" && depth == 0 {
704 if in_column && !current_column_lines.is_empty() {
706 let col_text = current_column_lines.join("\n");
707 let blocks =
708 MarkdownParser::with_depth(&col_text, self.depth + 1).parse_blocks()?;
709 let mut col = AdfNode::layout_column(current_width.clone(), blocks);
710 pass_through_local_id(¤t_dir_attrs, &mut col);
711 columns.push(col);
712 current_column_lines.clear();
713 }
714 current_width = col_d
715 .attrs
716 .as_ref()
717 .and_then(|a| a.get("width"))
718 .and_then(parse_numeric_attr)
719 .unwrap_or_else(|| serde_json::json!(50));
720 current_dir_attrs = col_d.attrs;
721 in_column = true;
722 i += 1;
723 continue;
724 }
725 if in_column {
726 depth += 1;
727 }
728 }
729 if in_column && is_container_close(line, 3) {
730 if depth > 0 {
731 depth -= 1;
732 current_column_lines.push(line.to_string());
733 i += 1;
734 continue;
735 }
736 let col_text = current_column_lines.join("\n");
738 let blocks =
739 MarkdownParser::with_depth(&col_text, self.depth + 1).parse_blocks()?;
740 let mut col = AdfNode::layout_column(current_width.clone(), blocks);
741 pass_through_local_id(¤t_dir_attrs, &mut col);
742 columns.push(col);
743 current_column_lines.clear();
744 current_dir_attrs = None;
745 in_column = false;
746 i += 1;
747 continue;
748 }
749 if in_column {
750 current_column_lines.push(line.to_string());
751 }
752 i += 1;
753 }
754
755 if in_column && !current_column_lines.is_empty() {
757 let col_text = current_column_lines.join("\n");
758 let blocks = MarkdownParser::with_depth(&col_text, self.depth + 1).parse_blocks()?;
759 let mut col = AdfNode::layout_column(current_width, blocks);
760 pass_through_local_id(¤t_dir_attrs, &mut col);
761 columns.push(col);
762 }
763
764 Ok(columns)
765 }
766
767 fn parse_directive_table_rows(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
769 debug!(
770 "parse_directive_table_rows: {} lines of inner text",
771 inner_text.lines().count()
772 );
773 let mut rows = Vec::new();
774 let lines: Vec<&str> = inner_text.lines().collect();
775 let mut i = 0;
776
777 while i < lines.len() {
778 let line = lines[i];
779 if let Some((d, _)) = try_parse_container_open(line) {
780 if d.name == "tr" {
781 let tr_attrs = d.attrs.clone();
782 i += 1;
783 let (mut row, next_i) = self.parse_directive_table_row(&lines, i)?;
784 if let Some(ref attrs) = tr_attrs {
786 if let Some(local_id) = attrs.get("localId") {
787 let row_attrs = row.attrs.get_or_insert_with(|| serde_json::json!({}));
788 row_attrs["localId"] = serde_json::Value::String(local_id.to_string());
789 }
790 }
791 rows.push(row);
792 i = next_i;
793 continue;
794 }
795 if d.name == "caption" {
796 let dir_attrs = d.attrs.clone();
797 i += 1;
798 let mut caption_lines = Vec::new();
799 while i < lines.len() {
800 if is_container_close(lines[i], 3) {
801 i += 1;
802 break;
803 }
804 caption_lines.push(lines[i]);
805 i += 1;
806 }
807 let caption_text = caption_lines.join("\n");
808 let inline_nodes = parse_inline(&caption_text);
809 let mut caption = AdfNode::caption(inline_nodes);
810 pass_through_local_id(&dir_attrs, &mut caption);
811 rows.push(caption);
812 continue;
813 }
814 }
815 i += 1;
816 }
817
818 Ok(rows)
819 }
820
821 fn parse_directive_table_row(&self, lines: &[&str], start: usize) -> Result<(AdfNode, usize)> {
823 let mut cells = Vec::new();
824 let mut i = start;
825 let mut depth: usize = 0;
826
827 while i < lines.len() {
828 let line = lines[i];
829 if is_container_close(line, 3) {
830 if depth == 0 {
831 i += 1;
833 break;
834 }
835 depth -= 1;
836 i += 1;
837 continue;
838 }
839 if let Some((d, _)) = try_parse_container_open(line) {
840 if depth == 0 && (d.name == "th" || d.name == "td") {
841 let is_header = d.name == "th";
842 let cell_attrs = d.attrs.clone();
843 i += 1;
844 let (cell, next_i) =
845 self.parse_directive_table_cell(lines, i, is_header, cell_attrs)?;
846 cells.push(cell);
847 i = next_i;
848 continue;
849 }
850 depth += 1;
851 }
852 i += 1;
853 }
854
855 if cells.is_empty() {
856 let context = lines[start.saturating_sub(1)..lines.len().min(start + 3)].to_vec();
857 warn!(
858 "Directive table row at line {start} has no cells — \
859 Confluence requires at least one. Nearby lines: {context:?}"
860 );
861 }
862 debug!("Parsed directive table row: {} cells", cells.len());
863
864 Ok((AdfNode::table_row(cells), i))
865 }
866
867 fn parse_directive_table_cell(
869 &self,
870 lines: &[&str],
871 start: usize,
872 is_header: bool,
873 cell_attrs: Option<crate::atlassian::attrs::Attrs>,
874 ) -> Result<(AdfNode, usize)> {
875 let mut cell_lines = Vec::new();
876 let mut i = start;
877 let mut depth: usize = 0;
878
879 while i < lines.len() {
880 let line = lines[i];
881 if try_parse_container_open(line).is_some() {
882 depth += 1;
883 } else if is_container_close(line, 3) {
884 if depth == 0 {
885 i += 1;
886 break;
887 }
888 depth -= 1;
889 }
890 cell_lines.push(line.to_string());
891 i += 1;
892 }
893
894 let cell_text = cell_lines.join("\n");
895 let blocks = MarkdownParser::with_depth(&cell_text, self.depth + 1).parse_blocks()?;
896
897 let adf_attrs = cell_attrs.as_ref().map(build_cell_attrs);
898 let cell_marks = cell_attrs
899 .as_ref()
900 .map(build_border_marks)
901 .unwrap_or_default();
902
903 let cell = if cell_marks.is_empty() {
904 if is_header {
905 if let Some(attrs) = adf_attrs {
906 AdfNode::table_header_with_attrs(blocks, attrs)
907 } else {
908 AdfNode::table_header(blocks)
909 }
910 } else if let Some(attrs) = adf_attrs {
911 AdfNode::table_cell_with_attrs(blocks, attrs)
912 } else {
913 AdfNode::table_cell(blocks)
914 }
915 } else if is_header {
916 AdfNode::table_header_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
917 } else {
918 AdfNode::table_cell_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
919 };
920
921 Ok((cell, i))
922 }
923
924 fn try_leaf_directive(&mut self) -> Option<AdfNode> {
925 let line = self.current_line();
926 let d = try_parse_leaf_directive(line)?;
927
928 let node = match d.name.as_str() {
929 "card" => {
930 let content = d.content.as_deref().unwrap_or("");
931 let url = match d.attrs.as_ref().and_then(|a| a.get("url")) {
936 Some(u) => u,
937 None => content,
938 };
939 let mut node = AdfNode::block_card(url);
940 if let Some(ref attrs) = d.attrs {
942 if let Some(ref mut node_attrs) = node.attrs {
943 if let Some(layout) = attrs.get("layout") {
944 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
945 }
946 if let Some(width) = attrs.get("width") {
947 if let Ok(w) = width.parse::<u64>() {
948 node_attrs["width"] = serde_json::json!(w);
949 }
950 }
951 }
952 }
953 node
954 }
955 "embed" => {
956 let url = d.content.as_deref().unwrap_or("");
957 let layout = d.attrs.as_ref().and_then(|a| a.get("layout"));
958 let original_height = d
959 .attrs
960 .as_ref()
961 .and_then(|a| a.get("originalHeight"))
962 .and_then(|v| v.parse::<f64>().ok());
963 let width = d
964 .attrs
965 .as_ref()
966 .and_then(|a| a.get("width"))
967 .and_then(|w| w.parse::<f64>().ok());
968 AdfNode::embed_card(url, layout, original_height, width)
969 }
970 "extension" => {
971 let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
972 let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
973 let params = d
974 .attrs
975 .as_ref()
976 .and_then(|a| a.get("params"))
977 .and_then(|p| serde_json::from_str(p).ok());
978 let mut node = AdfNode::extension(ext_type, ext_key, params);
979 if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
980 {
981 if let Some(layout) = dir_attrs.get("layout") {
982 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
983 }
984 if let Some(local_id) = dir_attrs.get("localId") {
985 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
986 }
987 }
988 node
989 }
990 "paragraph" => {
991 let mut node = if let Some(ref text) = d.content {
992 AdfNode::paragraph(parse_inline(text))
993 } else {
994 AdfNode::paragraph(vec![])
995 };
996 pass_through_local_id(&d.attrs, &mut node);
997 node
998 }
999 _ => return None,
1000 };
1001
1002 self.advance();
1003 Some(node)
1004 }
1005
1006 fn try_image(&mut self) -> Option<AdfNode> {
1007 let line = self.current_line().trim();
1008 let mut node = try_parse_media_single_from_line(line)?;
1009 self.advance();
1010
1011 if !self.at_end() {
1013 if let Some((d, _)) = try_parse_container_open(self.current_line()) {
1014 if d.name == "caption" {
1015 let dir_attrs = d.attrs;
1016 self.advance(); let mut caption_lines = Vec::new();
1018 while !self.at_end() {
1019 if is_container_close(self.current_line(), 3) {
1020 self.advance(); break;
1022 }
1023 caption_lines.push(self.current_line());
1024 self.advance();
1025 }
1026 let caption_text = caption_lines.join("\n");
1027 let inline_nodes = parse_inline(&caption_text);
1028 let mut caption = AdfNode::caption(inline_nodes);
1029 pass_through_local_id(&dir_attrs, &mut caption);
1030 if let Some(ref mut content) = node.content {
1031 content.push(caption);
1032 }
1033 }
1034 }
1035 }
1036
1037 Some(node)
1038 }
1039
1040 fn try_table(&mut self) -> Result<Option<AdfNode>> {
1041 let line = self.current_line();
1042 if !line.contains('|') || !line.trim_start().starts_with('|') {
1043 return Ok(None);
1044 }
1045
1046 if self.pos + 1 >= self.lines.len() {
1048 return Ok(None);
1049 }
1050 let next_line = self.lines[self.pos + 1];
1051 if !is_table_separator(next_line) {
1052 return Ok(None);
1053 }
1054
1055 let header_cells = parse_table_row(line);
1057 self.advance(); let sep_line = self.current_line();
1061 let alignments = parse_table_alignments(sep_line);
1062 self.advance(); let mut rows = Vec::new();
1065
1066 let header_adf_cells: Vec<AdfNode> = header_cells
1068 .iter()
1069 .enumerate()
1070 .map(|(col_idx, cell)| {
1071 let (cell_text, cell_attrs) = extract_cell_attrs(cell);
1072 let mut para = AdfNode::paragraph(parse_inline(&cell_text));
1073 apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
1074 if let Some(attrs) = cell_attrs {
1075 AdfNode::table_header_with_attrs(vec![para], attrs)
1076 } else {
1077 AdfNode::table_header(vec![para])
1078 }
1079 })
1080 .collect();
1081 if header_adf_cells.is_empty() {
1082 warn!(
1083 "Pipe table header row at line {} has no cells",
1084 self.pos - 1
1085 );
1086 }
1087 rows.push(AdfNode::table_row(header_adf_cells));
1088
1089 while !self.at_end() {
1091 let line = self.current_line();
1092 if !line.contains('|') || line.trim().is_empty() {
1093 break;
1094 }
1095
1096 let cells = parse_table_row(line);
1097 let adf_cells: Vec<AdfNode> = cells
1098 .iter()
1099 .enumerate()
1100 .map(|(col_idx, cell)| {
1101 let (cell_text, cell_attrs) = extract_cell_attrs(cell);
1102 let mut para = AdfNode::paragraph(parse_inline(&cell_text));
1103 apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
1104 if let Some(attrs) = cell_attrs {
1105 AdfNode::table_cell_with_attrs(vec![para], attrs)
1106 } else {
1107 AdfNode::table_cell(vec![para])
1108 }
1109 })
1110 .collect();
1111 if adf_cells.is_empty() {
1112 warn!("Pipe table body row at line {} has no cells", self.pos);
1113 }
1114 rows.push(AdfNode::table_row(adf_cells));
1115 self.advance();
1116 }
1117
1118 debug!("Parsed pipe table with {} rows", rows.len());
1119 let mut table = AdfNode::table(rows);
1120
1121 if !self.at_end() {
1123 let next = self.current_line().trim();
1124 if next.starts_with('{') {
1125 if let Some((_, attrs)) = parse_attrs(next, 0) {
1126 let mut table_attrs = serde_json::json!({});
1127 if let Some(layout) = attrs.get("layout") {
1128 table_attrs["layout"] = serde_json::Value::String(layout.to_string());
1129 }
1130 if attrs.has_flag("numbered") {
1131 table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
1132 } else if attrs.get("numbered") == Some("false") {
1133 table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
1134 }
1135 if let Some(tw) = attrs.get("width") {
1136 if let Some(w) = parse_numeric_attr(tw) {
1137 table_attrs["width"] = w;
1138 }
1139 }
1140 if let Some(local_id) = attrs.get("localId") {
1141 table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1142 }
1143 if table_attrs != serde_json::json!({}) {
1144 table.attrs = Some(table_attrs);
1145 self.advance(); }
1147 }
1148 }
1149 }
1150
1151 Ok(Some(table))
1152 }
1153
1154 fn parse_paragraph(&mut self) -> Result<AdfNode> {
1155 let mut lines: Vec<&str> = Vec::new();
1156
1157 while !self.at_end() {
1158 let line = self.current_line();
1159 if (line.trim().is_empty()
1168 && !lines
1169 .last()
1170 .is_some_and(|prev| has_trailing_hard_break(prev)))
1171 || is_code_fence_opener(line)
1172 || (is_horizontal_rule(line) && !lines.is_empty())
1173 {
1174 break;
1175 }
1176 let is_hardbreak_cont = !lines.is_empty()
1179 && line.starts_with(" ")
1180 && lines
1181 .last()
1182 .is_some_and(|prev| has_trailing_hard_break(prev));
1183 if is_hardbreak_cont {
1184 lines.push(&line[2..]);
1185 self.advance();
1186 continue;
1187 }
1188 if !lines.is_empty()
1189 && (line.starts_with('#') || line.starts_with('>') || is_list_start(line))
1190 {
1191 break;
1192 }
1193 if !lines.is_empty() && is_block_attrs_line(line) {
1195 break;
1196 }
1197 lines.push(line);
1198 self.advance();
1199 }
1200
1201 let text = lines.join("\n");
1202 let inline_nodes = parse_inline(&text);
1203 Ok(AdfNode::paragraph(inline_nodes))
1204 }
1205}
1206
1207fn build_cell_attrs(attrs: &crate::atlassian::attrs::Attrs) -> serde_json::Value {
1210 let mut adf = serde_json::json!({});
1211 if let Some(bg) = attrs.get("bg") {
1212 adf["background"] = serde_json::Value::String(bg.to_string());
1213 }
1214 if let Some(colspan) = attrs.get("colspan") {
1215 if let Ok(n) = colspan.parse::<u32>() {
1216 adf["colspan"] = serde_json::json!(n);
1217 }
1218 }
1219 if let Some(rowspan) = attrs.get("rowspan") {
1220 if let Ok(n) = rowspan.parse::<u32>() {
1221 adf["rowspan"] = serde_json::json!(n);
1222 }
1223 }
1224 if let Some(colwidth) = attrs.get("colwidth") {
1225 let widths: Vec<serde_json::Value> = colwidth
1226 .split(',')
1227 .filter_map(|s| parse_numeric_attr(s.trim()))
1228 .collect();
1229 if !widths.is_empty() {
1230 adf["colwidth"] = serde_json::Value::Array(widths);
1231 }
1232 }
1233 if let Some(local_id) = attrs.get("localId") {
1234 adf["localId"] = serde_json::Value::String(local_id.to_string());
1235 }
1236 adf
1237}
1238
1239fn build_border_marks(attrs: &crate::atlassian::attrs::Attrs) -> Vec<AdfMark> {
1241 let mut marks = Vec::new();
1242 let border_color = attrs.get("border-color");
1243 let border_size = attrs.get("border-size");
1244 if border_color.is_some() || border_size.is_some() {
1245 let color = border_color.unwrap_or("#000000");
1246 let size = border_size.and_then(|s| s.parse::<u32>().ok()).unwrap_or(1);
1247 marks.push(AdfMark::border(color, size));
1248 }
1249 marks
1250}
1251
1252fn iso_date_to_epoch_ms(date_str: &str) -> String {
1255 if date_str.chars().all(|c| c.is_ascii_digit()) {
1257 return date_str.to_string();
1258 }
1259 if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
1260 let epoch_ms = date
1261 .and_hms_opt(0, 0, 0)
1262 .map_or(0, |dt| dt.and_utc().timestamp_millis());
1263 epoch_ms.to_string()
1264 } else {
1265 date_str.to_string()
1267 }
1268}
1269
1270fn epoch_ms_to_iso_date(timestamp: &str) -> String {
1273 if timestamp.contains('-') {
1275 return timestamp.to_string();
1276 }
1277 if let Ok(ms) = timestamp.parse::<i64>() {
1278 let secs = ms / 1000;
1279 if let Some(dt) = chrono::DateTime::from_timestamp(secs, 0) {
1280 return dt.format("%Y-%m-%d").to_string();
1281 }
1282 }
1283 timestamp.to_string()
1285}
1286
1287fn is_block_attrs_line(line: &str) -> bool {
1289 let trimmed = line.trim();
1290 if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
1291 return false;
1292 }
1293 if let Some((_, attrs)) = parse_attrs(trimmed, 0) {
1294 attrs.get("align").is_some()
1296 || attrs.get("indent").is_some()
1297 || attrs.get("breakout").is_some()
1298 || attrs.get("breakoutWidth").is_some()
1299 || attrs.get("localId").is_some()
1300 } else {
1301 false
1302 }
1303}
1304
1305fn parse_decision_items(text: &str) -> Vec<AdfNode> {
1308 let mut items = Vec::new();
1309 for line in text.lines() {
1310 let trimmed = line.trim();
1311 if let Some(rest) = trimmed.strip_prefix("- <> ") {
1312 let inline_nodes = parse_inline(rest);
1313 items.push(AdfNode::decision_item("DECIDED", inline_nodes));
1314 }
1315 }
1316 items
1317}
1318
1319fn try_parse_task_marker(text: &str) -> Option<(&str, &str)> {
1327 if let Some(rest) = strip_task_checkbox(text, "[ ]") {
1328 Some(("TODO", rest))
1329 } else if let Some(rest) =
1330 strip_task_checkbox(text, "[x]").or_else(|| strip_task_checkbox(text, "[X]"))
1331 {
1332 Some(("DONE", rest))
1333 } else {
1334 None
1335 }
1336}
1337
1338fn strip_task_checkbox<'a>(text: &'a str, checkbox: &str) -> Option<&'a str> {
1342 let rest = text.strip_prefix(checkbox)?;
1343 if rest.is_empty() {
1344 Some(rest)
1345 } else {
1346 rest.strip_prefix(' ')
1347 }
1348}
1349
1350fn starts_with_task_marker(s: &str) -> bool {
1358 let after = if let Some(rest) = s.strip_prefix("[ ]") {
1359 rest
1360 } else if let Some(rest) = s.strip_prefix("[x]").or_else(|| s.strip_prefix("[X]")) {
1361 rest
1362 } else {
1363 return false;
1364 };
1365 after.is_empty() || after.starts_with(' ') || after.starts_with('\n')
1366}
1367
1368fn parse_ordered_list_marker(line: &str) -> Option<(u32, &str)> {
1370 let digit_end = line.find(|c: char| !c.is_ascii_digit())?;
1371 if digit_end == 0 {
1372 return None;
1373 }
1374 let rest = &line[digit_end..];
1375 let after_marker = rest.strip_prefix(". ")?;
1376 let num: u32 = line[..digit_end].parse().ok()?;
1377 Some((num, after_marker))
1378}
1379
1380fn has_trailing_hard_break(line: &str) -> bool {
1383 line.ends_with('\\') || line.ends_with(" ")
1384}
1385
1386fn is_block_level_continuation_marker(trimmed: &str) -> bool {
1394 trimmed.starts_with("![") || trimmed.starts_with("```") || trimmed.starts_with(":::")
1395}
1396
1397fn is_list_start(line: &str) -> bool {
1399 let trimmed = line.trim_start();
1400 trimmed.starts_with("- ")
1401 || trimmed.starts_with("* ")
1402 || trimmed.starts_with("+ ")
1403 || parse_ordered_list_marker(trimmed).is_some()
1404}
1405
1406fn escape_emphasis_markers(text: &str) -> String {
1419 escape_emphasis_with(text, false)
1420}
1421
1422fn escape_emphasis_markers_with_underscore(text: &str) -> String {
1430 escape_emphasis_with(text, true)
1431}
1432
1433fn escape_emphasis_with(text: &str, escape_underscore_always: bool) -> String {
1439 let chars: Vec<char> = text.chars().collect();
1440 let mut out = String::with_capacity(text.len());
1441 let mut idx = 0;
1442 while idx < chars.len() {
1443 let ch = chars[idx];
1444 if ch == '*' {
1445 out.push('\\');
1446 out.push(ch);
1447 idx += 1;
1448 } else if ch == '_' {
1449 let run_start = idx;
1453 let mut run_end = idx;
1454 while run_end < chars.len() && chars[run_end] == '_' {
1455 run_end += 1;
1456 }
1457 let escape_run = if escape_underscore_always {
1458 true
1459 } else {
1460 let before_alnum = run_start > 0 && chars[run_start - 1].is_alphanumeric();
1461 let after_alnum = chars.get(run_end).is_some_and(|c| c.is_alphanumeric());
1462 !(before_alnum && after_alnum)
1463 };
1464 for _ in run_start..run_end {
1465 if escape_run {
1466 out.push('\\');
1467 }
1468 out.push('_');
1469 }
1470 idx = run_end;
1471 } else {
1472 out.push(ch);
1473 idx += 1;
1474 }
1475 }
1476 out
1477}
1478
1479fn escape_backticks(text: &str) -> String {
1485 let mut out = String::with_capacity(text.len());
1486 for ch in text.chars() {
1487 if ch == '`' {
1488 out.push('\\');
1489 }
1490 out.push(ch);
1491 }
1492 out
1493}
1494
1495fn inline_code_delimiter(text: &str) -> (usize, bool) {
1503 let mut max_run = 0usize;
1504 let mut current = 0usize;
1505 for ch in text.chars() {
1506 if ch == '`' {
1507 current += 1;
1508 if current > max_run {
1509 max_run = current;
1510 }
1511 } else {
1512 current = 0;
1513 }
1514 }
1515 let n = max_run + 1;
1516 let starts_bt = text.starts_with('`');
1517 let ends_bt = text.ends_with('`');
1518 let starts_sp = text.starts_with(' ');
1519 let ends_sp = text.ends_with(' ');
1520 let all_sp = !text.is_empty() && text.chars().all(|c| c == ' ');
1521 let needs_pad = starts_bt || ends_bt || (starts_sp && ends_sp && !all_sp);
1522 (n, needs_pad)
1523}
1524
1525fn render_inline_code(text: &str, output: &mut String) {
1528 let (n, pad) = inline_code_delimiter(text);
1529 for _ in 0..n {
1530 output.push('`');
1531 }
1532 if pad {
1533 output.push(' ');
1534 }
1535 output.push_str(text);
1536 if pad {
1537 output.push(' ');
1538 }
1539 for _ in 0..n {
1540 output.push('`');
1541 }
1542}
1543
1544fn escape_pipes_in_cell(text: &str) -> String {
1551 let mut out = String::with_capacity(text.len());
1552 for ch in text.chars() {
1553 if ch == '|' {
1554 out.push('\\');
1555 }
1556 out.push(ch);
1557 }
1558 out
1559}
1560
1561fn escape_link_brackets(text: &str) -> String {
1566 let mut out = String::with_capacity(text.len());
1567 for ch in text.chars() {
1568 if ch == '[' || ch == ']' {
1569 out.push('\\');
1570 }
1571 out.push(ch);
1572 }
1573 out
1574}
1575
1576fn escape_bare_urls(text: &str) -> String {
1582 let mut result = String::with_capacity(text.len());
1583 for (i, ch) in text.char_indices() {
1584 if ch == 'h' {
1585 let rest = &text[i..];
1586 if rest.starts_with("http://") || rest.starts_with("https://") {
1587 result.push('\\');
1588 }
1589 }
1590 result.push(ch);
1591 }
1592 result
1593}
1594
1595fn url_safe_in_bracket_content(s: &str) -> bool {
1604 if s.contains('\n') {
1605 return false;
1606 }
1607 let mut depth: i32 = 1;
1608 for ch in s.chars() {
1609 match ch {
1610 '[' => depth += 1,
1611 ']' => {
1612 depth -= 1;
1613 if depth == 0 {
1614 return false;
1615 }
1616 }
1617 _ => {}
1618 }
1619 }
1620 true
1621}
1622
1623fn escape_emoji_shortcodes(text: &str) -> String {
1634 let mut result = String::with_capacity(text.len());
1635
1636 for (i, ch) in text.char_indices() {
1637 if ch == ':' {
1638 let after = i + 1;
1641 if after < text.len() {
1642 let name_end = text[after..]
1643 .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')
1644 .map_or(text[after..].len(), |pos| pos);
1645 if name_end > 0
1646 && after + name_end < text.len()
1647 && text.as_bytes()[after + name_end] == b':'
1648 {
1649 result.push('\\');
1651 }
1652 }
1653 }
1654 result.push(ch);
1655 }
1656
1657 result
1658}
1659
1660fn escape_list_marker(line: &str) -> String {
1664 if let Some(dot_pos) = line.find(". ") {
1665 if parse_ordered_list_marker(line).is_some() {
1666 let mut s = String::with_capacity(line.len() + 1);
1667 s.push_str(&line[..dot_pos]);
1668 s.push('\\');
1669 s.push_str(&line[dot_pos..]);
1670 return s;
1671 }
1672 }
1673 for prefix in &["- ", "* ", "+ "] {
1674 if line.starts_with(prefix) {
1675 let mut s = String::with_capacity(line.len() + 1);
1676 s.push('\\');
1677 s.push_str(line);
1678 return s;
1679 }
1680 }
1681 line.to_string()
1682}
1683
1684fn is_code_fence_opener(line: &str) -> bool {
1691 if !line.starts_with("```") {
1692 return false;
1693 }
1694 !line[3..].contains('`')
1695}
1696
1697fn is_horizontal_rule(line: &str) -> bool {
1699 let trimmed = line.trim();
1700 trimmed.len() >= 3
1701 && ((trimmed.starts_with("---") && trimmed.chars().all(|c| c == '-'))
1702 || (trimmed.starts_with("***") && trimmed.chars().all(|c| c == '*'))
1703 || (trimmed.starts_with("___") && trimmed.chars().all(|c| c == '_')))
1704}
1705
1706fn is_table_separator(line: &str) -> bool {
1708 let trimmed = line.trim();
1709 trimmed.contains('|')
1710 && trimmed
1711 .chars()
1712 .all(|c| c == '|' || c == '-' || c == ':' || c == ' ')
1713}
1714
1715fn parse_table_row(line: &str) -> Vec<String> {
1722 let trimmed = line.trim();
1723 let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1724 let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1725
1726 let mut cells: Vec<String> = Vec::new();
1727 let mut current = String::new();
1728 let mut chars = trimmed.chars().peekable();
1729 while let Some(ch) = chars.next() {
1730 if ch == '\\' && chars.peek() == Some(&'|') {
1731 current.push('|');
1732 chars.next();
1733 } else if ch == '|' {
1734 cells.push(std::mem::take(&mut current));
1735 } else {
1736 current.push(ch);
1737 }
1738 }
1739 cells.push(current);
1740
1741 cells
1742 .iter()
1743 .map(|s| {
1744 let stripped = s.strip_prefix(' ').unwrap_or(s.as_str());
1747 let stripped = stripped.strip_suffix(' ').unwrap_or(stripped);
1748 stripped.to_string()
1749 })
1750 .collect()
1751}
1752
1753fn parse_table_alignments(separator_line: &str) -> Vec<Option<&'static str>> {
1756 let trimmed = separator_line.trim();
1757 let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1758 let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1759
1760 trimmed
1761 .split('|')
1762 .map(|cell| {
1763 let cell = cell.trim();
1764 let starts_colon = cell.starts_with(':');
1765 let ends_colon = cell.ends_with(':');
1766 match (starts_colon, ends_colon) {
1767 (true, true) => Some("center"),
1768 (false, true) => Some("end"),
1769 _ => None, }
1771 })
1772 .collect()
1773}
1774
1775fn apply_column_alignment(para: &mut AdfNode, alignment: Option<&str>) {
1777 if let Some(align) = alignment {
1778 para.marks = Some(vec![AdfMark::alignment(align)]);
1779 }
1780}
1781
1782fn extract_cell_attrs(cell_text: &str) -> (String, Option<serde_json::Value>) {
1785 let trimmed = cell_text.trim_start();
1786 if !trimmed.starts_with('{') {
1787 return (cell_text.to_string(), None);
1788 }
1789 if let Some((end_pos, attrs)) = parse_attrs(trimmed, 0) {
1790 let remaining = trimmed[end_pos..].trim_start().to_string();
1791 let adf_attrs = build_cell_attrs(&attrs);
1792 (remaining, Some(adf_attrs))
1793 } else {
1794 (cell_text.to_string(), None)
1795 }
1796}
1797
1798fn try_parse_media_single_from_line(line: &str) -> Option<AdfNode> {
1801 let line = line.trim();
1802 if !line.starts_with("? + 1; let img_end = find_closing_paren(line, paren_open)? + 1;
1811 let after_img = line[img_end..].trim_start();
1812
1813 if after_img.starts_with('{') {
1814 if let Some((_, attrs)) = parse_attrs(after_img, 0) {
1815 if attrs.get("type") == Some("file") || attrs.get("id").is_some() {
1817 let mut media_attrs = serde_json::json!({"type": "file"});
1818 if let Some(id) = attrs.get("id") {
1819 media_attrs["id"] = serde_json::Value::String(id.to_string());
1820 }
1821 if let Some(collection) = attrs.get("collection") {
1822 media_attrs["collection"] = serde_json::Value::String(collection.to_string());
1823 }
1824 if let Some(occurrence_key) = attrs.get("occurrenceKey") {
1825 media_attrs["occurrenceKey"] =
1826 serde_json::Value::String(occurrence_key.to_string());
1827 }
1828 if let Some(height) = attrs.get("height") {
1829 if let Some(h) = parse_numeric_attr(height) {
1830 media_attrs["height"] = h;
1831 }
1832 }
1833 if let Some(width) = attrs.get("width") {
1834 if let Some(w) = parse_numeric_attr(width) {
1835 media_attrs["width"] = w;
1836 }
1837 }
1838 if let Some(alt_text) = alt_opt {
1839 media_attrs["alt"] = serde_json::Value::String(alt_text.to_string());
1840 }
1841 if let Some(local_id) = attrs.get("localId") {
1842 media_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1843 }
1844 let mut ms_attrs = serde_json::json!({"layout": "center"});
1845 if let Some(layout) = attrs.get("layout") {
1846 ms_attrs["layout"] = serde_json::Value::String(layout.to_string());
1847 }
1848 if let Some(ms_width) = attrs.get("mediaWidth") {
1849 if let Some(w) = parse_numeric_attr(ms_width) {
1850 ms_attrs["width"] = w;
1851 }
1852 }
1853 if let Some(wt) = attrs.get("widthType") {
1854 ms_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1855 }
1856 if let Some(mode) = attrs.get("mode") {
1857 ms_attrs["mode"] = serde_json::Value::String(mode.to_string());
1858 }
1859 let border_marks = build_border_marks(&attrs);
1860 let media_marks = if border_marks.is_empty() {
1861 None
1862 } else {
1863 Some(border_marks)
1864 };
1865 return Some(AdfNode {
1866 node_type: "mediaSingle".to_string(),
1867 attrs: Some(ms_attrs),
1868 content: Some(vec![AdfNode {
1869 node_type: "media".to_string(),
1870 attrs: Some(media_attrs),
1871 content: None,
1872 text: None,
1873 marks: media_marks,
1874 local_id: None,
1875 parameters: None,
1876 }]),
1877 text: None,
1878 marks: None,
1879 local_id: None,
1880 parameters: None,
1881 });
1882 }
1883
1884 let mut node = AdfNode::media_single(url, alt_opt);
1886 if let Some(ref mut node_attrs) = node.attrs {
1887 if let Some(layout) = attrs.get("layout") {
1888 node_attrs["layout"] = serde_json::Value::String(layout.to_string());
1889 }
1890 if let Some(width) = attrs.get("width") {
1891 if let Some(w) = parse_numeric_attr(width) {
1892 node_attrs["width"] = w;
1893 }
1894 }
1895 if let Some(wt) = attrs.get("widthType") {
1896 node_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1897 }
1898 if let Some(mode) = attrs.get("mode") {
1899 node_attrs["mode"] = serde_json::Value::String(mode.to_string());
1900 }
1901 }
1902 if let Some(ref mut content) = node.content {
1903 if let Some(media) = content.first_mut() {
1904 if let Some(local_id) = attrs.get("localId") {
1905 if let Some(ref mut media_attrs) = media.attrs {
1906 media_attrs["localId"] =
1907 serde_json::Value::String(local_id.to_string());
1908 }
1909 }
1910 let border_marks = build_border_marks(&attrs);
1911 if !border_marks.is_empty() {
1912 media.marks = Some(border_marks);
1913 }
1914 }
1915 }
1916 return Some(node);
1917 }
1918 }
1919
1920 Some(AdfNode::media_single(url, alt_opt))
1921}
1922
1923fn parse_image_syntax(line: &str) -> Option<(&str, &str)> {
1925 let line = line.trim();
1926 if !line.starts_with("?;
1931 let alt = &line[2..alt_end];
1932 let paren_start = alt_end + 1; let url_end = find_closing_paren(line, paren_start)?;
1934 let url = &line[paren_start + 1..url_end];
1935
1936 Some((alt, url))
1937}
1938
1939fn parse_inline(text: &str) -> Vec<AdfNode> {
1947 parse_inline_impl(text, true, 0)
1948}
1949
1950fn parse_inline_impl(text: &str, auto_inline_card: bool, depth: usize) -> Vec<AdfNode> {
1965 if depth > MAX_NESTING_DEPTH {
1966 return if text.is_empty() {
1967 Vec::new()
1968 } else {
1969 vec![AdfNode::text(text)]
1970 };
1971 }
1972
1973 let mut nodes = Vec::new();
1974 let mut chars = text.char_indices().peekable();
1975 let mut plain_start = 0;
1976
1977 while let Some(&(i, ch)) = chars.peek() {
1978 match ch {
1979 '*' | '_' => {
1980 if let Some((end, content, is_bold)) = try_parse_emphasis(text, i) {
1981 flush_plain(text, plain_start, i, &mut nodes);
1982 let mark = if is_bold {
1983 AdfMark::strong()
1984 } else {
1985 AdfMark::em()
1986 };
1987 let inner = parse_inline_impl(content, false, depth + 1);
1988 for mut node in inner {
1989 prepend_mark(&mut node, mark.clone());
1990 nodes.push(node);
1991 }
1992 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1994 chars.next();
1995 }
1996 plain_start = end;
1997 continue;
1998 }
1999 if ch == '_' {
2004 while chars.peek().is_some_and(|&(_, c)| c == '_') {
2005 chars.next();
2006 }
2007 } else {
2008 chars.next();
2009 }
2010 }
2011 '~' => {
2012 if let Some((end, content)) = try_parse_strikethrough(text, i) {
2013 flush_plain(text, plain_start, i, &mut nodes);
2014 let inner = parse_inline_impl(content, false, depth + 1);
2015 for mut node in inner {
2016 prepend_mark(&mut node, AdfMark::strike());
2017 nodes.push(node);
2018 }
2019 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2020 chars.next();
2021 }
2022 plain_start = end;
2023 continue;
2024 }
2025 chars.next();
2026 }
2027 '`' => {
2028 if let Some((end, content)) = try_parse_inline_code(text, i) {
2029 flush_plain(text, plain_start, i, &mut nodes);
2030 nodes.push(AdfNode::text_with_marks(content, vec![AdfMark::code()]));
2031 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2032 chars.next();
2033 }
2034 plain_start = end;
2035 continue;
2036 }
2037 while chars.peek().is_some_and(|&(_, c)| c == '`') {
2040 chars.next();
2041 }
2042 }
2043 '[' => {
2044 if let Some((end, link_text, href)) = try_parse_link(text, i) {
2045 flush_plain(text, plain_start, i, &mut nodes);
2046 if link_text.starts_with("http://") || link_text.starts_with("https://") {
2047 nodes.push(AdfNode::text_with_marks(
2052 link_text,
2053 vec![AdfMark::link(href)],
2054 ));
2055 } else {
2056 let inner = parse_inline_impl(link_text, false, depth + 1);
2057 for mut node in inner {
2058 prepend_mark(&mut node, AdfMark::link(href));
2059 nodes.push(node);
2060 }
2061 }
2062 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2063 chars.next();
2064 }
2065 plain_start = end;
2066 continue;
2067 }
2068 if let Some((end, span_nodes)) = try_parse_bracketed_span(text, i, depth) {
2070 flush_plain(text, plain_start, i, &mut nodes);
2071 nodes.extend(span_nodes);
2072 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2073 chars.next();
2074 }
2075 plain_start = end;
2076 continue;
2077 }
2078 chars.next();
2079 }
2080 ':' => {
2081 if let Some(node) = try_dispatch_inline_directive(text, i, depth) {
2083 flush_plain(text, plain_start, i, &mut nodes);
2084 let end = node.1;
2085 nodes.push(node.0);
2086 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2087 chars.next();
2088 }
2089 plain_start = end;
2090 continue;
2091 }
2092 if let Some((end, short_name)) = try_parse_emoji_shortcode(text, i) {
2094 flush_plain(text, plain_start, i, &mut nodes);
2095 let (final_end, emoji_node) = parse_emoji_with_attrs(text, end, short_name);
2096 nodes.push(emoji_node);
2097 while chars.peek().is_some_and(|&(idx, _)| idx < final_end) {
2098 chars.next();
2099 }
2100 plain_start = final_end;
2101 continue;
2102 }
2103 chars.next();
2104 }
2105 ' ' if text[i..].starts_with(" \n") => {
2106 flush_plain(text, plain_start, i, &mut nodes);
2109 nodes.push(AdfNode::hard_break());
2110 while chars.peek().is_some_and(|&(_, c)| c == ' ') {
2112 chars.next();
2113 }
2114 if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2116 chars.next();
2117 }
2118 plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2119 }
2120 '!' if text[i..].starts_with("![") => {
2121 chars.next();
2125 }
2126 'h' if auto_inline_card
2127 && (text[i..].starts_with("http://") || text[i..].starts_with("https://")) =>
2128 {
2129 if let Some((end, url)) = try_parse_bare_url(text, i) {
2130 flush_plain(text, plain_start, i, &mut nodes);
2131 nodes.push(AdfNode::inline_card(url));
2132 while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2133 chars.next();
2134 }
2135 plain_start = end;
2136 continue;
2137 }
2138 chars.next();
2139 }
2140 '\\' if text.as_bytes().get(i + 1) == Some(&b'n')
2141 && text.as_bytes().get(i + 2) != Some(&b'\n') =>
2142 {
2143 flush_plain(text, plain_start, i, &mut nodes);
2147 nodes.push(AdfNode::text("\n"));
2148 chars.next(); chars.next(); plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2151 }
2152 '\\' if i + 1 < text.len() && !text[i..].starts_with("\\\n") => {
2153 flush_plain(text, plain_start, i, &mut nodes);
2158 chars.next(); plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2163 chars.next(); }
2165 '\\' if text[i..].starts_with("\\\n") => {
2166 flush_plain(text, plain_start, i, &mut nodes);
2168 nodes.push(AdfNode::hard_break());
2169 chars.next(); if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2172 chars.next();
2173 }
2174 plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2175 }
2176 '\\' if i + 1 == text.len() => {
2177 flush_plain(text, plain_start, i, &mut nodes);
2179 nodes.push(AdfNode::hard_break());
2180 chars.next(); plain_start = text.len();
2182 }
2183 _ => {
2184 chars.next();
2185 }
2186 }
2187 }
2188
2189 if plain_start < text.len() {
2191 let remaining = &text[plain_start..];
2192 if !remaining.is_empty() {
2193 nodes.push(AdfNode::text(remaining));
2194 }
2195 }
2196
2197 merge_adjacent_text(&mut nodes);
2200
2201 nodes
2202}
2203
2204fn merge_adjacent_text(nodes: &mut Vec<AdfNode>) {
2206 let mut i = 0;
2207 while i + 1 < nodes.len() {
2208 if nodes[i].node_type == "text"
2209 && nodes[i + 1].node_type == "text"
2210 && nodes[i].marks.is_none()
2211 && nodes[i + 1].marks.is_none()
2212 {
2213 let next_text = nodes[i + 1].text.clone().unwrap_or_default();
2214 if let Some(ref mut t) = nodes[i].text {
2215 t.push_str(&next_text);
2216 }
2217 nodes.remove(i + 1);
2218 } else {
2219 i += 1;
2220 }
2221 }
2222}
2223
2224fn flush_plain(text: &str, start: usize, end: usize, nodes: &mut Vec<AdfNode>) {
2226 if start < end {
2227 let plain = &text[start..end];
2228 if !plain.is_empty() {
2229 nodes.push(AdfNode::text(plain));
2230 }
2231 }
2232}
2233
2234#[cfg(test)]
2236fn add_mark(node: &mut AdfNode, mark: AdfMark) {
2237 if let Some(ref mut marks) = node.marks {
2238 marks.push(mark);
2239 } else {
2240 node.marks = Some(vec![mark]);
2241 }
2242}
2243
2244fn strip_heading_code_marks(nodes: &mut [AdfNode]) -> usize {
2255 let mut removed = 0;
2256 for node in nodes.iter_mut() {
2257 if let Some(marks) = node.marks.as_mut() {
2258 let before = marks.len();
2259 marks.retain(|m| m.mark_type != "code");
2260 removed += before - marks.len();
2261 if marks.is_empty() {
2262 node.marks = None;
2263 }
2264 }
2265 if let Some(children) = node.content.as_mut() {
2266 removed += strip_heading_code_marks(children);
2267 }
2268 }
2269 removed
2270}
2271
2272fn prepend_mark(node: &mut AdfNode, mark: AdfMark) {
2274 if let Some(ref mut marks) = node.marks {
2275 marks.insert(0, mark);
2276 } else {
2277 node.marks = Some(vec![mark]);
2278 }
2279}
2280
2281fn is_intraword_underscore(text: &str, delim_pos: usize, len: usize) -> bool {
2286 let before = text[..delim_pos]
2287 .chars()
2288 .next_back()
2289 .is_some_and(char::is_alphanumeric);
2290 let after = text[delim_pos + len..]
2291 .chars()
2292 .next()
2293 .is_some_and(char::is_alphanumeric);
2294 before && after
2295}
2296
2297fn find_unescaped(haystack: &str, needle: &str) -> Option<usize> {
2301 let needle_bytes = needle.as_bytes();
2302 let hay_bytes = haystack.as_bytes();
2303 let mut i = 0;
2304 while i < hay_bytes.len() {
2305 if hay_bytes[i] == b'\\' {
2306 i += 2; continue;
2308 }
2309 if hay_bytes[i..].starts_with(needle_bytes) {
2310 return Some(i);
2311 }
2312 i += 1;
2313 }
2314 None
2315}
2316
2317fn find_unescaped_char(haystack: &str, ch: u8) -> Option<usize> {
2320 let hay_bytes = haystack.as_bytes();
2321 let mut i = 0;
2322 while i < hay_bytes.len() {
2323 if hay_bytes[i] == b'\\' {
2324 i += 2;
2325 continue;
2326 }
2327 if hay_bytes[i] == ch {
2328 return Some(i);
2329 }
2330 i += 1;
2331 }
2332 None
2333}
2334
2335fn try_parse_emphasis(text: &str, i: usize) -> Option<(usize, &str, bool)> {
2345 let rest = &text[i..];
2346
2347 if rest.starts_with("***") || rest.starts_with("___") {
2351 let is_underscore = rest.starts_with("___");
2352 if is_underscore && is_intraword_underscore(text, i, 3) {
2353 return None;
2354 }
2355 let triple = &rest[..3];
2356 let after = &rest[3..];
2357 if let Some(close) = find_unescaped(after, triple) {
2358 if close > 0 {
2359 let close_pos = i + 3 + close;
2360 if is_underscore && is_intraword_underscore(text, close_pos, 3) {
2361 return None;
2362 }
2363 let content = &rest[2..=3 + close];
2367 let end = i + 3 + close + 3;
2368 return Some((end, content, true));
2369 }
2370 }
2371 }
2372
2373 if rest.starts_with("**") || rest.starts_with("__") {
2375 let is_underscore = rest.starts_with("__");
2376 if is_underscore && is_intraword_underscore(text, i, 2) {
2377 return None;
2378 }
2379 let delimiter = &rest[..2];
2380 let after = &rest[2..];
2381 let close = find_unescaped(after, delimiter)?;
2382 if close == 0 {
2383 return None;
2384 }
2385 let close_pos = i + 2 + close;
2386 if is_underscore && is_intraword_underscore(text, close_pos, 2) {
2387 return None;
2388 }
2389 let content = &after[..close];
2390 let end = i + 2 + close + 2;
2391 return Some((end, content, true));
2392 }
2393
2394 if rest.starts_with('*') || rest.starts_with('_') {
2396 let delim_char = rest.as_bytes()[0];
2397 let is_underscore = delim_char == b'_';
2398 if is_underscore && is_intraword_underscore(text, i, 1) {
2399 return None;
2400 }
2401 let after = &rest[1..];
2402 let close = find_unescaped_char(after, delim_char)?;
2403 if close == 0 {
2404 return None;
2405 }
2406 let close_pos = i + 1 + close;
2407 if is_underscore && is_intraword_underscore(text, close_pos, 1) {
2408 return None;
2409 }
2410 let content = &after[..close];
2411 let end = i + 1 + close + 1;
2412 return Some((end, content, false));
2413 }
2414
2415 None
2416}
2417
2418fn try_parse_strikethrough(text: &str, i: usize) -> Option<(usize, &str)> {
2420 let rest = &text[i..];
2421 if !rest.starts_with("~~") {
2422 return None;
2423 }
2424 let after = &rest[2..];
2425 let close = after.find("~~")?;
2426 if close == 0 {
2427 return None;
2428 }
2429 let content = &after[..close];
2430 Some((i + 2 + close + 2, content))
2431}
2432
2433fn try_parse_inline_code(text: &str, i: usize) -> Option<(usize, &str)> {
2440 let rest = &text[i..];
2441 let bytes = rest.as_bytes();
2442 if bytes.is_empty() || bytes[0] != b'`' {
2443 return None;
2444 }
2445 let mut opening = 0usize;
2446 while opening < bytes.len() && bytes[opening] == b'`' {
2447 opening += 1;
2448 }
2449
2450 let mut j = opening;
2451 while j < bytes.len() {
2452 if bytes[j] == b'`' {
2453 let run_start = j;
2454 while j < bytes.len() && bytes[j] == b'`' {
2455 j += 1;
2456 }
2457 if j - run_start == opening {
2458 let content = &rest[opening..run_start];
2459 let content = strip_code_span_padding(content);
2460 return Some((i + j, content));
2461 }
2462 } else {
2463 j += 1;
2464 }
2465 }
2466 None
2467}
2468
2469fn strip_code_span_padding(content: &str) -> &str {
2473 let bytes = content.as_bytes();
2474 if bytes.len() >= 2
2475 && bytes[0] == b' '
2476 && bytes[bytes.len() - 1] == b' '
2477 && content.bytes().any(|b| b != b' ')
2478 {
2479 &content[1..content.len() - 1]
2480 } else {
2481 content
2482 }
2483}
2484
2485fn try_parse_bracketed_span(text: &str, i: usize, depth: usize) -> Option<(usize, Vec<AdfNode>)> {
2489 let rest = &text[i..];
2490 if !rest.starts_with('[') {
2491 return None;
2492 }
2493
2494 let mut bracket_depth: usize = 0;
2498 let mut bracket_close = None;
2499 let bs_bytes = rest.as_bytes();
2500 for (j, ch) in rest.char_indices() {
2501 match ch {
2502 '\\' if j + 1 < bs_bytes.len()
2503 && (bs_bytes[j + 1] == b'[' || bs_bytes[j + 1] == b']') => {}
2504 '[' if j == 0 || bs_bytes[j - 1] != b'\\' => bracket_depth += 1,
2505 ']' if j == 0 || bs_bytes[j - 1] != b'\\' => {
2506 bracket_depth -= 1;
2507 if bracket_depth == 0 {
2508 bracket_close = Some(j);
2509 break;
2510 }
2511 }
2512 _ => {}
2513 }
2514 }
2515 let bracket_close = bracket_close?;
2516 let after_bracket = &rest[bracket_close + 1..];
2518 if !after_bracket.starts_with('{') {
2519 return None;
2520 }
2521
2522 let span_text = &rest[1..bracket_close];
2523 let attrs_start = i + bracket_close + 1;
2524 let (attrs_end, attrs) = parse_attrs(text, attrs_start)?;
2525
2526 let mut marks = Vec::new();
2527 if attrs.has_flag("underline") {
2528 marks.push(AdfMark::underline());
2529 }
2530 let ann_ids = attrs.get_all("annotation-id");
2531 let ann_types = attrs.get_all("annotation-type");
2532 for (idx, ann_id) in ann_ids.iter().enumerate() {
2533 let ann_type = ann_types.get(idx).copied().unwrap_or("inlineComment");
2534 marks.push(AdfMark::annotation(ann_id, ann_type));
2535 }
2536
2537 if marks.is_empty() {
2538 return None; }
2540
2541 let inner = parse_inline_impl(span_text, false, depth + 1);
2542 let result: Vec<AdfNode> = inner
2543 .into_iter()
2544 .map(|mut node| {
2545 let mut combined = marks.clone();
2548 if let Some(ref existing) = node.marks {
2549 combined.extend(existing.iter().cloned());
2550 }
2551 node.marks = Some(combined);
2552 node
2553 })
2554 .collect();
2555
2556 Some((attrs_end, result))
2557}
2558
2559fn try_dispatch_inline_directive(text: &str, pos: usize, depth: usize) -> Option<(AdfNode, usize)> {
2563 let d = try_parse_inline_directive(text, pos)?;
2564 let content = d.content.as_deref().unwrap_or("");
2565
2566 let node = match d.name.as_str() {
2567 "card" => {
2568 let url = d
2573 .attrs
2574 .as_ref()
2575 .and_then(|a| a.get("url"))
2576 .unwrap_or(content);
2577 let mut node = AdfNode::inline_card(url);
2578 pass_through_local_id(&d.attrs, &mut node);
2579 node
2580 }
2581 "status" => {
2582 let color = d
2583 .attrs
2584 .as_ref()
2585 .and_then(|a| a.get("color"))
2586 .unwrap_or("neutral");
2587 let mut node = AdfNode::status(content, color);
2588 if let Some(ref attrs) = d.attrs {
2590 if let Some(ref mut node_attrs) = node.attrs {
2591 if let Some(style) = attrs.get("style") {
2592 node_attrs["style"] = serde_json::Value::String(style.to_string());
2593 }
2594 if let Some(local_id) = attrs.get("localId") {
2595 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2596 }
2597 }
2598 }
2599 node
2600 }
2601 "date" => {
2602 let timestamp = d
2603 .attrs
2604 .as_ref()
2605 .and_then(|a| a.get("timestamp"))
2606 .map_or_else(|| iso_date_to_epoch_ms(content), ToString::to_string);
2607 let mut node = AdfNode::date(×tamp);
2608 pass_through_local_id(&d.attrs, &mut node);
2609 node
2610 }
2611 "mention" => {
2612 let id = d.attrs.as_ref().and_then(|a| a.get("id")).unwrap_or("");
2613 let mut node = AdfNode::mention(id, content);
2614 if let Some(ref attrs) = d.attrs {
2616 if let (Some(ref mut node_attrs), true) = (
2617 &mut node.attrs,
2618 attrs.get("userType").is_some() || attrs.get("accessLevel").is_some(),
2619 ) {
2620 if let Some(ut) = attrs.get("userType") {
2621 node_attrs["userType"] = serde_json::Value::String(ut.to_string());
2622 }
2623 if let Some(al) = attrs.get("accessLevel") {
2624 node_attrs["accessLevel"] = serde_json::Value::String(al.to_string());
2625 }
2626 }
2627 }
2628 pass_through_local_id(&d.attrs, &mut node);
2629 node
2630 }
2631 "span" => {
2632 let mut marks = Vec::new();
2633 if let Some(ref attrs) = d.attrs {
2634 if let Some(color) = attrs.get("color") {
2635 marks.push(AdfMark::text_color(color));
2636 }
2637 if let Some(bg) = attrs.get("bg") {
2638 marks.push(AdfMark::background_color(bg));
2639 }
2640 if attrs.has_flag("sub") {
2641 marks.push(AdfMark::subsup("sub"));
2642 }
2643 if attrs.has_flag("sup") {
2644 marks.push(AdfMark::subsup("sup"));
2645 }
2646 }
2647 if marks.is_empty() {
2648 AdfNode::text(content)
2649 } else {
2650 let inner = parse_inline_impl(content, false, depth + 1);
2653 let mut nodes: Vec<AdfNode> = inner
2654 .into_iter()
2655 .map(|mut node| {
2656 let mut combined = marks.clone();
2657 if let Some(ref existing) = node.marks {
2658 combined.extend(existing.iter().cloned());
2659 }
2660 node.marks = Some(combined);
2661 node
2662 })
2663 .collect();
2664 nodes.remove(0)
2666 }
2667 }
2668 "placeholder" => AdfNode::placeholder(content),
2669 "media-inline" => {
2670 let mut json_attrs = serde_json::Map::new();
2671 if let Some(ref attrs) = d.attrs {
2672 for key in &["type", "id", "collection", "url", "alt", "width", "height"] {
2673 if let Some(val) = attrs.get(key) {
2674 if *key == "width" || *key == "height" {
2675 if let Ok(n) = val.parse::<u64>() {
2676 json_attrs.insert(
2677 (*key).to_string(),
2678 serde_json::Value::Number(n.into()),
2679 );
2680 continue;
2681 }
2682 }
2683 json_attrs.insert(
2684 (*key).to_string(),
2685 serde_json::Value::String(val.to_string()),
2686 );
2687 }
2688 }
2689 if let Some(local_id) = attrs.get("localId") {
2690 json_attrs.insert(
2691 "localId".to_string(),
2692 serde_json::Value::String(local_id.to_string()),
2693 );
2694 }
2695 }
2696 AdfNode::media_inline(serde_json::Value::Object(json_attrs))
2697 }
2698 "extension" => {
2699 let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
2700 let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
2701 AdfNode::inline_extension(ext_type, ext_key, Some(content))
2702 }
2703 "adf-unsupported" => {
2704 let json = d.attrs.as_ref().and_then(|a| a.get("json"))?;
2712 serde_json::from_str::<AdfNode>(json).ok()?
2713 }
2714 _ => return None, };
2716
2717 Some((node, d.end_pos))
2718}
2719
2720fn try_parse_bare_url(text: &str, i: usize) -> Option<(usize, &str)> {
2723 let rest = &text[i..];
2724 if !rest.starts_with("http://") && !rest.starts_with("https://") {
2725 return None;
2726 }
2727 let end = rest
2729 .find(|c: char| c.is_whitespace() || c == ')' || c == ']' || c == '>')
2730 .unwrap_or(rest.len());
2731 let url = rest[..end].trim_end_matches(['.', ',', ';', '!', '?']);
2733 if url.len() <= "https://".len() {
2734 return None; }
2736 Some((i + url.len(), url))
2737}
2738
2739fn try_parse_emoji_shortcode(text: &str, i: usize) -> Option<(usize, &str)> {
2742 let rest = &text[i..];
2743 if !rest.starts_with(':') {
2744 return None;
2745 }
2746 let after = &rest[1..];
2747 let name_end =
2748 after.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')?;
2749 if name_end == 0 {
2750 return None;
2751 }
2752 if after.as_bytes().get(name_end) != Some(&b':') {
2753 return None;
2754 }
2755 let name = &after[..name_end];
2756 Some((i + 1 + name_end + 1, name))
2757}
2758
2759fn parse_emoji_with_attrs(text: &str, shortcode_end: usize, short_name: &str) -> (usize, AdfNode) {
2762 let mut chain_end = shortcode_end;
2767 while let Some((next_end, _)) = try_parse_emoji_shortcode(text, chain_end) {
2768 chain_end = next_end;
2769 }
2770 if chain_end > shortcode_end {
2771 if let Some((attr_end, attrs)) = parse_attrs(text, chain_end) {
2772 return (attr_end, build_emoji_node(&attrs, short_name));
2773 }
2774 }
2775
2776 if let Some((attr_end, attrs)) = parse_attrs(text, shortcode_end) {
2777 (attr_end, build_emoji_node(&attrs, short_name))
2778 } else {
2779 (shortcode_end, AdfNode::emoji(&format!(":{short_name}:")))
2780 }
2781}
2782
2783fn build_emoji_node(attrs: &Attrs, short_name: &str) -> AdfNode {
2786 let resolved_name = attrs
2787 .get("shortName")
2788 .map_or_else(|| format!(":{short_name}:"), str::to_string);
2789 let mut emoji_attrs = serde_json::json!({ "shortName": resolved_name });
2790 if let Some(id) = attrs.get("id") {
2791 emoji_attrs["id"] = serde_json::Value::String(id.to_string());
2792 }
2793 if let Some(t) = attrs.get("text") {
2794 emoji_attrs["text"] = serde_json::Value::String(t.to_string());
2795 }
2796 if let Some(lid) = attrs.get("localId") {
2797 emoji_attrs["localId"] = serde_json::Value::String(lid.to_string());
2798 }
2799 AdfNode {
2800 node_type: "emoji".to_string(),
2801 attrs: Some(emoji_attrs),
2802 content: None,
2803 text: None,
2804 marks: None,
2805 local_id: None,
2806 parameters: None,
2807 }
2808}
2809
2810fn find_closing_paren(s: &str, open: usize) -> Option<usize> {
2815 let mut depth: usize = 0;
2816 for (j, ch) in s[open..].char_indices() {
2817 match ch {
2818 '(' => depth += 1,
2819 ')' => {
2820 depth -= 1;
2821 if depth == 0 {
2822 return Some(open + j);
2823 }
2824 }
2825 _ => {}
2826 }
2827 }
2828 None
2829}
2830
2831fn try_parse_link(text: &str, i: usize) -> Option<(usize, &str, &str)> {
2837 let rest = &text[i..];
2838 if !rest.starts_with('[') {
2839 return None;
2840 }
2841
2842 let mut depth: usize = 0;
2844 let mut text_end = None;
2845 let bytes = rest.as_bytes();
2846 for (j, ch) in rest.char_indices() {
2847 match ch {
2848 '\\' if j + 1 < bytes.len() && (bytes[j + 1] == b'[' || bytes[j + 1] == b']') => {
2849 }
2851 '[' if j == 0 || bytes[j - 1] != b'\\' => depth += 1,
2852 ']' if j == 0 || bytes[j - 1] != b'\\' => {
2853 depth -= 1;
2854 if depth == 0 {
2855 text_end = Some(j);
2856 break;
2857 }
2858 }
2859 _ => {}
2860 }
2861 }
2862
2863 let text_end = text_end?;
2864 let link_text = &rest[1..text_end];
2865 let after_bracket = &rest[text_end + 1..];
2867 if !after_bracket.starts_with('(') {
2868 return None;
2869 }
2870 let url_start = text_end + 1; let url_end = find_closing_paren(rest, url_start)?;
2872 let href = &rest[url_start + 1..url_end];
2873
2874 Some((i + url_end + 1, link_text, href))
2875}
2876
2877#[derive(Debug, Clone, Default)]
2881pub struct RenderOptions {
2882 pub strip_local_ids: bool,
2884}
2885
2886pub fn adf_to_markdown(doc: &AdfDocument) -> Result<String> {
2888 adf_to_markdown_with_options(doc, &RenderOptions::default())
2889}
2890
2891pub fn adf_to_markdown_with_options(doc: &AdfDocument, opts: &RenderOptions) -> Result<String> {
2893 let mut output = String::new();
2894
2895 for (i, node) in doc.content.iter().enumerate() {
2896 if i > 0 {
2897 output.push('\n');
2898 }
2899 render_block_node(node, &mut output, opts);
2900 }
2901
2902 Ok(output)
2903}
2904
2905#[must_use]
2914pub fn adf_to_plain_text(doc: &AdfDocument) -> String {
2915 let mut out = String::new();
2916 for node in &doc.content {
2917 collect_plain_text(node, &mut out);
2918 if !out.is_empty() && !out.ends_with(' ') {
2919 out.push(' ');
2920 }
2921 }
2922 out.truncate(out.trim_end().len());
2923 out
2924}
2925
2926fn collect_plain_text(node: &AdfNode, out: &mut String) {
2927 if let Some(text) = &node.text {
2928 out.push_str(text);
2929 }
2930 if let Some(children) = &node.content {
2931 for child in children {
2932 collect_plain_text(child, out);
2933 }
2934 }
2935}
2936
2937fn pass_through_local_id(dir_attrs: &Option<crate::atlassian::attrs::Attrs>, node: &mut AdfNode) {
2941 if let Some(ref attrs) = dir_attrs {
2942 if let Some(local_id) = attrs.get("localId") {
2943 if let Some(ref mut node_attrs) = node.attrs {
2944 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2945 } else {
2946 node.attrs = Some(serde_json::json!({"localId": local_id}));
2947 }
2948 }
2949 }
2950}
2951
2952fn pass_through_expand_params(
2955 dir_attrs: &Option<crate::atlassian::attrs::Attrs>,
2956 node: &mut AdfNode,
2957) {
2958 if let Some(ref attrs) = dir_attrs {
2959 if let Some(local_id) = attrs.get("localId") {
2960 node.local_id = Some(local_id.to_string());
2961 }
2962 if let Some(params_str) = attrs.get("params") {
2963 if let Ok(params) = serde_json::from_str(params_str) {
2964 node.parameters = Some(params);
2965 }
2966 }
2967 }
2968}
2969
2970fn extract_trailing_local_id(text: &str) -> (&str, Option<String>, Option<String>) {
2980 let trimmed = text.trim_end();
2981 if !trimmed.ends_with('}') {
2982 return (text, None, None);
2983 }
2984 if let Some(brace_pos) = trimmed.rfind('{') {
2989 if brace_pos > 0 && !trimmed.as_bytes()[brace_pos - 1].is_ascii_whitespace() {
2990 return (text, None, None);
2991 }
2992 let attr_str = &trimmed[brace_pos..];
2993 if let Some((_, attrs)) = parse_attrs(attr_str, 0) {
2994 let local_id = attrs.get("localId").map(str::to_string);
2995 let para_local_id = attrs.get("paraLocalId").map(str::to_string);
2996 if local_id.is_some() || para_local_id.is_some() {
2997 let before = trimmed[..brace_pos]
2998 .strip_suffix(' ')
2999 .unwrap_or(&trimmed[..brace_pos]);
3000 return (before, local_id, para_local_id);
3001 }
3002 }
3003 }
3004 (text, None, None)
3005}
3006
3007fn parse_list_item_first_line(
3017 item_text: &str,
3018 sub_lines: Vec<String>,
3019 local_id: Option<String>,
3020 para_local_id: Option<String>,
3021 depth: usize,
3022) -> Result<AdfNode> {
3023 if item_text.starts_with("```") {
3024 let mut all_lines = vec![item_text.to_string()];
3026 all_lines.extend(sub_lines);
3027 let combined = all_lines.join("\n");
3028 let nested = MarkdownParser::with_depth(&combined, depth).parse_blocks()?;
3029 Ok(list_item_with_local_id(nested, local_id, para_local_id))
3030 } else if let Some(media) = try_parse_media_single_from_line(item_text) {
3031 if sub_lines.is_empty() {
3033 Ok(list_item_with_local_id(
3034 vec![media],
3035 local_id,
3036 para_local_id,
3037 ))
3038 } else {
3039 let sub_text = sub_lines.join("\n");
3040 let mut nested = MarkdownParser::with_depth(&sub_text, depth).parse_blocks()?;
3041 let mut content = vec![media];
3042 content.append(&mut nested);
3043 Ok(list_item_with_local_id(content, local_id, para_local_id))
3044 }
3045 } else {
3046 let first_node = AdfNode::paragraph(parse_inline(item_text));
3047 if sub_lines.is_empty() {
3048 Ok(list_item_with_local_id(
3049 vec![first_node],
3050 local_id,
3051 para_local_id,
3052 ))
3053 } else {
3054 let sub_text = sub_lines.join("\n");
3055 let mut nested = MarkdownParser::with_depth(&sub_text, depth).parse_blocks()?;
3056 let mut content = vec![first_node];
3057 content.append(&mut nested);
3058 Ok(list_item_with_local_id(content, local_id, para_local_id))
3059 }
3060 }
3061}
3062
3063fn list_item_with_local_id(
3064 mut content: Vec<AdfNode>,
3065 local_id: Option<String>,
3066 para_local_id: Option<String>,
3067) -> AdfNode {
3068 if let Some(id) = ¶_local_id {
3069 if let Some(first) = content.first_mut() {
3070 if first.node_type == "paragraph" {
3071 let node_attrs = first.attrs.get_or_insert_with(|| serde_json::json!({}));
3072 node_attrs["localId"] = serde_json::Value::String(id.clone());
3073 }
3074 }
3075 }
3076 let mut item = AdfNode::list_item(content);
3077 if let Some(id) = local_id {
3078 item.attrs = Some(serde_json::json!({"localId": id}));
3079 }
3080 item
3081}
3082
3083fn maybe_push_local_id(attrs: &serde_json::Value, parts: &mut Vec<String>, opts: &RenderOptions) {
3084 if opts.strip_local_ids {
3085 return;
3086 }
3087 if let Some(local_id) = attrs.get("localId").and_then(serde_json::Value::as_str) {
3088 if !local_id.is_empty() && local_id != "00000000-0000-0000-0000-000000000000" {
3089 parts.push(format_kv("localId", local_id));
3090 }
3091 }
3092}
3093
3094fn render_block_children(children: &[AdfNode], output: &mut String, opts: &RenderOptions) {
3096 for (i, child) in children.iter().enumerate() {
3097 if i > 0 {
3098 output.push('\n');
3099 }
3100 render_block_node(child, output, opts);
3101 }
3102}
3103
3104fn fmt_f64_attr(v: f64) -> String {
3107 if v.fract() == 0.0 {
3108 format!("{}", v as i64)
3109 } else {
3110 v.to_string()
3111 }
3112}
3113
3114fn parse_numeric_attr(s: &str) -> Option<serde_json::Value> {
3121 if s.contains('.') || s.contains('e') || s.contains('E') {
3122 s.parse::<f64>().ok().map(serde_json::Value::from)
3123 } else {
3124 s.parse::<i64>().ok().map(serde_json::Value::from)
3125 }
3126}
3127
3128fn fmt_numeric_attr(v: &serde_json::Value) -> Option<String> {
3136 if let Some(n) = v.as_i64() {
3137 return Some(n.to_string());
3138 }
3139 if let Some(n) = v.as_u64() {
3140 return Some(n.to_string());
3141 }
3142 if let Some(n) = v.as_f64() {
3143 if n.fract() == 0.0 && n.is_finite() {
3144 return Some(format!("{n:.1}"));
3145 }
3146 return Some(n.to_string());
3147 }
3148 None
3149}
3150
3151fn render_block_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3153 match node.node_type.as_str() {
3154 "paragraph" => {
3155 let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3156 let dir_attrs = {
3158 let mut parts = Vec::new();
3159 if let Some(ref attrs) = node.attrs {
3160 maybe_push_local_id(attrs, &mut parts, opts);
3161 }
3162 if parts.is_empty() {
3163 String::new()
3164 } else {
3165 format!("{{{}}}", parts.join(" "))
3166 }
3167 };
3168 if is_empty {
3169 output.push_str(&format!("::paragraph{dir_attrs}\n"));
3170 } else {
3171 let mut buf = String::new();
3173 render_inline_content(node, &mut buf, opts);
3174 if buf.trim().is_empty() && !buf.is_empty() {
3175 output.push_str(&format!("::paragraph[{buf}]{dir_attrs}\n"));
3178 } else {
3179 let mut is_first_line = true;
3184 for line in buf.split('\n') {
3185 if is_first_line {
3186 if is_list_start(line) {
3187 output.push_str(&escape_list_marker(line));
3188 } else {
3189 output.push_str(line);
3190 }
3191 is_first_line = false;
3192 } else {
3193 output.push('\n');
3194 if !line.is_empty() {
3195 output.push_str(" ");
3196 }
3197 output.push_str(line);
3198 }
3199 }
3200 output.push('\n');
3201 }
3202 }
3203 }
3204 "heading" => {
3205 let level = node
3206 .attrs
3207 .as_ref()
3208 .and_then(|a| a.get("level"))
3209 .and_then(serde_json::Value::as_u64)
3210 .unwrap_or(1);
3211 for _ in 0..level {
3212 output.push('#');
3213 }
3214 output.push(' ');
3215 let mut buf = String::new();
3216 render_inline_content(node, &mut buf, opts);
3217 let mut is_first_line = true;
3220 for line in buf.split('\n') {
3221 if is_first_line {
3222 output.push_str(line);
3223 is_first_line = false;
3224 } else {
3225 output.push('\n');
3226 if !line.is_empty() {
3227 output.push_str(" ");
3228 }
3229 output.push_str(line);
3230 }
3231 }
3232 output.push('\n');
3233 }
3234 "codeBlock" => {
3235 let language_value = node.attrs.as_ref().and_then(|a| a.get("language"));
3236 let language = language_value
3237 .and_then(serde_json::Value::as_str)
3238 .unwrap_or("");
3239 output.push_str("```");
3240 if language.is_empty() && language_value.is_some() {
3241 output.push_str("\"\"");
3244 } else {
3245 output.push_str(language);
3246 }
3247 output.push('\n');
3248 if let Some(ref content) = node.content {
3249 for child in content {
3250 if let Some(ref text) = child.text {
3251 output.push_str(text);
3252 }
3253 }
3254 }
3255 output.push_str("\n```\n");
3256 }
3257 "blockquote" => {
3258 if let Some(ref content) = node.content {
3259 for (i, child) in content.iter().enumerate() {
3260 if i > 0
3264 && child.node_type == "paragraph"
3265 && content[i - 1].node_type == "paragraph"
3266 {
3267 output.push_str(">\n");
3268 }
3269 let mut inner = String::new();
3270 render_block_node(child, &mut inner, opts);
3271 for line in inner.lines() {
3272 output.push_str("> ");
3273 output.push_str(line);
3274 output.push('\n');
3275 }
3276 }
3277 }
3278 }
3279 "bulletList" => {
3280 if let Some(ref items) = node.content {
3281 for item in items {
3282 output.push_str("- ");
3283 let content_start = output.len();
3284 render_list_item_content(item, output, opts);
3285 if starts_with_task_marker(&output[content_start..]) {
3291 output.insert(content_start, '\\');
3292 }
3293 }
3294 }
3295 }
3296 "orderedList" => {
3297 let start = node
3298 .attrs
3299 .as_ref()
3300 .and_then(|a| a.get("order"))
3301 .and_then(serde_json::Value::as_u64)
3302 .unwrap_or(1);
3303 if let Some(ref items) = node.content {
3304 for (i, item) in items.iter().enumerate() {
3305 let num = start + i as u64;
3306 output.push_str(&format!("{num}. "));
3307 render_list_item_content(item, output, opts);
3308 }
3309 }
3310 }
3311 "taskList" => {
3312 if let Some(ref items) = node.content {
3313 for item in items {
3314 if item.node_type == "taskList" {
3315 let mut nested = String::new();
3319 render_block_node(item, &mut nested, opts);
3320 for line in nested.lines() {
3321 output.push_str(" ");
3322 output.push_str(line);
3323 output.push('\n');
3324 }
3325 } else {
3326 let state = item
3327 .attrs
3328 .as_ref()
3329 .and_then(|a| a.get("state"))
3330 .and_then(serde_json::Value::as_str)
3331 .unwrap_or("TODO");
3332 if state == "DONE" {
3333 output.push_str("- [x] ");
3334 } else {
3335 output.push_str("- [ ] ");
3336 }
3337 render_list_item_content(item, output, opts);
3338 }
3339 }
3340 }
3341 }
3342 "rule" => {
3343 output.push_str("---\n");
3344 }
3345 "table" => {
3346 render_table(node, output, opts);
3347 }
3348 "mediaSingle" => {
3349 if let Some(ref content) = node.content {
3350 for child in content {
3351 if child.node_type == "media" {
3352 render_media(child, node.attrs.as_ref(), output, opts);
3353 }
3354 }
3355 for child in content {
3356 if child.node_type == "caption" {
3357 let mut cap_parts = Vec::new();
3358 if let Some(ref attrs) = child.attrs {
3359 maybe_push_local_id(attrs, &mut cap_parts, opts);
3360 }
3361 if cap_parts.is_empty() {
3362 output.push_str(":::caption\n");
3363 } else {
3364 output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3365 }
3366 if let Some(ref caption_content) = child.content {
3367 for inline in caption_content {
3368 render_inline_node(inline, output, opts);
3369 }
3370 output.push('\n');
3371 }
3372 output.push_str(":::\n");
3373 }
3374 }
3375 }
3376 }
3377 "blockCard" => {
3378 if let Some(ref attrs) = node.attrs {
3379 let url = attrs
3380 .get("url")
3381 .and_then(serde_json::Value::as_str)
3382 .unwrap_or("");
3383 let mut attr_parts = Vec::new();
3384 if url_safe_in_bracket_content(url) {
3385 output.push_str(&format!("::card[{url}]"));
3386 } else {
3387 output.push_str("::card[]");
3389 let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
3390 attr_parts.push(format!("url=\"{escaped}\""));
3391 }
3392 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3393 attr_parts.push(format!("layout={layout}"));
3394 }
3395 if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
3396 attr_parts.push(format!("width={width}"));
3397 }
3398 if !attr_parts.is_empty() {
3399 output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3400 }
3401 output.push('\n');
3402 }
3403 }
3404 "embedCard" => {
3405 if let Some(ref attrs) = node.attrs {
3406 let url = attrs
3407 .get("url")
3408 .and_then(serde_json::Value::as_str)
3409 .unwrap_or("");
3410 output.push_str(&format!("::embed[{url}]"));
3411 let mut attr_parts = Vec::new();
3412 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3413 attr_parts.push(format!("layout={layout}"));
3414 }
3415 if let Some(h) = attrs
3416 .get("originalHeight")
3417 .and_then(serde_json::Value::as_f64)
3418 {
3419 attr_parts.push(format!("originalHeight={}", fmt_f64_attr(h)));
3420 }
3421 if let Some(w) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3422 attr_parts.push(format!("width={}", fmt_f64_attr(w)));
3423 }
3424 if !attr_parts.is_empty() {
3425 output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3426 }
3427 output.push('\n');
3428 }
3429 }
3430 "extension" => {
3431 if let Some(ref attrs) = node.attrs {
3432 let ext_type = attrs
3433 .get("extensionType")
3434 .and_then(serde_json::Value::as_str)
3435 .unwrap_or("");
3436 let ext_key = attrs
3437 .get("extensionKey")
3438 .and_then(serde_json::Value::as_str)
3439 .unwrap_or("");
3440 let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3441 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3442 attr_parts.push(format!("layout={layout}"));
3443 }
3444 if let Some(params) = attrs.get("parameters") {
3445 if let Ok(json_str) = serde_json::to_string(params) {
3446 attr_parts.push(format!("params='{json_str}'"));
3447 }
3448 }
3449 maybe_push_local_id(attrs, &mut attr_parts, opts);
3450 output.push_str(&format!("::extension{{{}}}\n", attr_parts.join(" ")));
3451 }
3452 }
3453 "panel" => {
3454 let panel_type = node
3455 .attrs
3456 .as_ref()
3457 .and_then(|a| a.get("panelType"))
3458 .and_then(serde_json::Value::as_str)
3459 .unwrap_or("info");
3460 let mut attr_parts = vec![format!("type={panel_type}")];
3461 if let Some(ref attrs) = node.attrs {
3462 if let Some(icon) = attrs.get("panelIcon").and_then(serde_json::Value::as_str) {
3463 attr_parts.push(format!("icon=\"{icon}\""));
3464 }
3465 if let Some(color) = attrs.get("panelColor").and_then(serde_json::Value::as_str) {
3466 attr_parts.push(format!("color=\"{color}\""));
3467 }
3468 }
3469 output.push_str(&format!(":::panel{{{}}}\n", attr_parts.join(" ")));
3470 if let Some(ref content) = node.content {
3471 render_block_children(content, output, opts);
3472 }
3473 output.push_str(":::\n");
3474 }
3475 "expand" | "nestedExpand" => {
3476 let directive_name = if node.node_type == "nestedExpand" {
3477 "nested-expand"
3478 } else {
3479 "expand"
3480 };
3481 let mut attr_parts = Vec::new();
3482 if let Some(t) = node
3483 .attrs
3484 .as_ref()
3485 .and_then(|a| a.get("title"))
3486 .and_then(serde_json::Value::as_str)
3487 {
3488 attr_parts.push(format!("title=\"{t}\""));
3489 }
3490 if let Some(ref lid) = node.local_id {
3492 if !opts.strip_local_ids && lid != "00000000-0000-0000-0000-000000000000" {
3493 attr_parts.push(format!("localId={lid}"));
3494 }
3495 } else if let Some(ref attrs) = node.attrs {
3496 maybe_push_local_id(attrs, &mut attr_parts, opts);
3497 }
3498 if let Some(ref params) = node.parameters {
3500 if let Ok(json_str) = serde_json::to_string(params) {
3501 attr_parts.push(format!("params='{json_str}'"));
3502 }
3503 }
3504 if attr_parts.is_empty() {
3505 output.push_str(&format!(":::{directive_name}\n"));
3506 } else {
3507 output.push_str(&format!(
3508 ":::{directive_name}{{{}}}\n",
3509 attr_parts.join(" ")
3510 ));
3511 }
3512 if let Some(ref content) = node.content {
3513 render_block_children(content, output, opts);
3514 }
3515 output.push_str(":::\n");
3516 }
3517 "layoutSection" => {
3518 output.push_str("::::layout\n");
3519 if let Some(ref content) = node.content {
3520 for child in content {
3521 if child.node_type == "layoutColumn" {
3522 let width_str = child
3523 .attrs
3524 .as_ref()
3525 .and_then(|a| a.get("width"))
3526 .and_then(fmt_numeric_attr)
3527 .unwrap_or_else(|| "50".to_string());
3528 let mut parts = vec![format!("width={width_str}")];
3529 if let Some(ref attrs) = child.attrs {
3530 maybe_push_local_id(attrs, &mut parts, opts);
3531 }
3532 output.push_str(&format!(":::column{{{}}}\n", parts.join(" ")));
3533 if let Some(ref col_content) = child.content {
3534 render_block_children(col_content, output, opts);
3535 }
3536 output.push_str(":::\n");
3537 }
3538 }
3539 }
3540 output.push_str("::::\n");
3541 }
3542 "decisionList" => {
3543 output.push_str(":::decisions\n");
3544 if let Some(ref content) = node.content {
3545 for item in content {
3546 output.push_str("- <> ");
3547 render_list_item_content(item, output, opts);
3548 }
3549 }
3550 output.push_str(":::\n");
3551 }
3552 "bodiedExtension" => {
3553 if let Some(ref attrs) = node.attrs {
3554 let ext_type = attrs
3555 .get("extensionType")
3556 .and_then(serde_json::Value::as_str)
3557 .unwrap_or("");
3558 let ext_key = attrs
3559 .get("extensionKey")
3560 .and_then(serde_json::Value::as_str)
3561 .unwrap_or("");
3562 let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3563 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3564 attr_parts.push(format!("layout={layout}"));
3565 }
3566 if let Some(params) = attrs.get("parameters") {
3567 if let Ok(json_str) = serde_json::to_string(params) {
3568 attr_parts.push(format!("params='{json_str}'"));
3569 }
3570 }
3571 maybe_push_local_id(attrs, &mut attr_parts, opts);
3572 output.push_str(&format!(":::extension{{{}}}\n", attr_parts.join(" ")));
3573 if let Some(ref content) = node.content {
3574 render_block_children(content, output, opts);
3575 }
3576 output.push_str(":::\n");
3577 }
3578 }
3579 _ => {
3580 if let Ok(json) = serde_json::to_string_pretty(node) {
3582 output.push_str("```adf-unsupported\n");
3583 output.push_str(&json);
3584 output.push_str("\n```\n");
3585 }
3586 }
3587 }
3588
3589 let mut parts = Vec::new();
3591 if let Some(ref marks) = node.marks {
3592 for mark in marks {
3593 match mark.mark_type.as_str() {
3594 "alignment" => {
3595 if let Some(align) = mark
3596 .attrs
3597 .as_ref()
3598 .and_then(|a| a.get("align"))
3599 .and_then(serde_json::Value::as_str)
3600 {
3601 parts.push(format!("align={align}"));
3602 }
3603 }
3604 "indentation" => {
3605 if let Some(level) = mark
3606 .attrs
3607 .as_ref()
3608 .and_then(|a| a.get("level"))
3609 .and_then(serde_json::Value::as_u64)
3610 {
3611 parts.push(format!("indent={level}"));
3612 }
3613 }
3614 "breakout" => {
3615 if let Some(mode) = mark
3616 .attrs
3617 .as_ref()
3618 .and_then(|a| a.get("mode"))
3619 .and_then(serde_json::Value::as_str)
3620 {
3621 parts.push(format!("breakout={mode}"));
3622 }
3623 if let Some(width) = mark
3624 .attrs
3625 .as_ref()
3626 .and_then(|a| a.get("width"))
3627 .and_then(serde_json::Value::as_u64)
3628 {
3629 parts.push(format!("breakoutWidth={width}"));
3630 }
3631 }
3632 _ => {}
3633 }
3634 }
3635 }
3636 let para_used_directive = node.node_type == "paragraph" && {
3640 let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3641 if is_empty {
3642 true
3643 } else {
3644 let mut buf = String::new();
3645 render_inline_content(node, &mut buf, opts);
3646 buf.trim().is_empty() && !buf.is_empty()
3647 }
3648 };
3649 if !matches!(node.node_type.as_str(), "expand" | "nestedExpand") && !para_used_directive {
3650 if let Some(ref attrs) = node.attrs {
3651 maybe_push_local_id(attrs, &mut parts, opts);
3652 }
3653 }
3654 if node.node_type == "orderedList" {
3659 if let Some(ref attrs) = node.attrs {
3660 if attrs.get("order").and_then(serde_json::Value::as_u64) == Some(1) {
3661 parts.push("order=1".to_string());
3662 }
3663 }
3664 }
3665 if !parts.is_empty() {
3666 output.push_str(&format!("{{{}}}\n", parts.join(" ")));
3667 }
3668}
3669
3670fn render_list_item_content(item: &AdfNode, output: &mut String, opts: &RenderOptions) {
3678 let Some(ref content) = item.content else {
3679 let bare = AdfNode::text("");
3681 emit_list_item_local_ids(item, &bare, output, opts);
3682 output.push('\n');
3683 return;
3684 };
3685 if content.is_empty() {
3686 let bare = AdfNode::text("");
3687 emit_list_item_local_ids(item, &bare, output, opts);
3688 output.push('\n');
3689 return;
3690 }
3691 let first = &content[0];
3692 let rest_start;
3693 if first.node_type == "paragraph" {
3694 let mut buf = String::new();
3695 render_inline_content(first, &mut buf, opts);
3696 let buf = buf.trim_end_matches('\n');
3702 let mut is_first_line = true;
3705 for line in buf.split('\n') {
3706 if is_first_line {
3707 output.push_str(line);
3708 is_first_line = false;
3709 } else {
3710 output.push('\n');
3711 if !line.is_empty() {
3712 output.push_str(" ");
3713 }
3714 output.push_str(line);
3715 }
3716 }
3717 emit_list_item_local_ids(item, first, output, opts);
3719 output.push('\n');
3720 rest_start = 1;
3721 } else if is_inline_node_type(&first.node_type) {
3722 rest_start = content
3724 .iter()
3725 .position(|c| !is_inline_node_type(&c.node_type))
3726 .unwrap_or(content.len());
3727 let mut buf = String::new();
3728 for child in &content[..rest_start] {
3729 render_inline_node(child, &mut buf, opts);
3730 }
3731 let buf = buf.trim_end_matches('\n');
3734 let mut is_first_line = true;
3735 for line in buf.split('\n') {
3736 if is_first_line {
3737 output.push_str(line);
3738 is_first_line = false;
3739 } else {
3740 output.push('\n');
3741 if !line.is_empty() {
3742 output.push_str(" ");
3743 }
3744 output.push_str(line);
3745 }
3746 }
3747 let bare = AdfNode::text("");
3749 emit_list_item_local_ids(item, &bare, output, opts);
3750 output.push('\n');
3751 } else if first.node_type == "taskItem" {
3754 let bare = AdfNode::text("");
3759 emit_list_item_local_ids(item, &bare, output, opts);
3760 output.push('\n');
3761 for child in content {
3762 if child.node_type == "taskItem" {
3763 let state = child
3764 .attrs
3765 .as_ref()
3766 .and_then(|a| a.get("state"))
3767 .and_then(serde_json::Value::as_str)
3768 .unwrap_or("TODO");
3769 let marker = if state == "DONE" { "- [x] " } else { "- [ ] " };
3770 output.push_str(" ");
3771 output.push_str(marker);
3772 render_list_item_content(child, output, opts);
3773 } else {
3774 let mut nested = String::new();
3775 render_block_node(child, &mut nested, opts);
3776 for line in nested.lines() {
3777 output.push_str(" ");
3778 output.push_str(line);
3779 output.push('\n');
3780 }
3781 }
3782 }
3783 return;
3784 } else {
3785 let mut buf = String::new();
3791 render_block_node(first, &mut buf, opts);
3792 let bare = AdfNode::text("");
3793 let mut is_first = true;
3794 for line in buf.lines() {
3795 if is_first {
3796 output.push_str(line);
3797 emit_list_item_local_ids(item, &bare, output, opts);
3798 output.push('\n');
3799 is_first = false;
3800 } else {
3801 output.push_str(" ");
3802 output.push_str(line);
3803 output.push('\n');
3804 }
3805 }
3806 rest_start = 1;
3807 }
3808 let rest = &content[rest_start..];
3809 for (i, child) in rest.iter().enumerate() {
3810 if child.node_type == "paragraph" {
3814 let prev_is_para = if i == 0 {
3815 first.node_type == "paragraph"
3818 } else {
3819 rest[i - 1].node_type == "paragraph"
3820 };
3821 if prev_is_para {
3822 output.push_str(" \n");
3823 }
3824 }
3825 let mut nested = String::new();
3826 render_block_node(child, &mut nested, opts);
3827 for line in nested.lines() {
3828 output.push_str(" ");
3829 output.push_str(line);
3830 output.push('\n');
3831 }
3832 }
3833}
3834
3835fn is_inline_node_type(node_type: &str) -> bool {
3837 matches!(
3838 node_type,
3839 "text"
3840 | "hardBreak"
3841 | "inlineCard"
3842 | "emoji"
3843 | "mention"
3844 | "status"
3845 | "date"
3846 | "placeholder"
3847 | "mediaInline"
3848 )
3849}
3850
3851fn emit_list_item_local_ids(
3854 item: &AdfNode,
3855 paragraph: &AdfNode,
3856 output: &mut String,
3857 opts: &RenderOptions,
3858) {
3859 if opts.strip_local_ids {
3860 return;
3861 }
3862 let mut parts = Vec::new();
3863 if let Some(ref attrs) = item.attrs {
3864 maybe_push_local_id(attrs, &mut parts, opts);
3865 }
3866 if paragraph.node_type == "paragraph" {
3867 let has_real_id = paragraph
3868 .attrs
3869 .as_ref()
3870 .and_then(|a| a.get("localId"))
3871 .and_then(serde_json::Value::as_str)
3872 .filter(|id| !id.is_empty() && *id != "00000000-0000-0000-0000-000000000000");
3873 if let Some(local_id) = has_real_id {
3874 parts.push(format!("paraLocalId={local_id}"));
3875 } else if item.node_type == "taskItem" {
3876 parts.push("paraLocalId=_".to_string());
3880 }
3881 }
3882 if !parts.is_empty() {
3883 output.push_str(&format!(" {{{}}}", parts.join(" ")));
3884 }
3885}
3886
3887fn render_table(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3889 let Some(ref rows) = node.content else {
3890 return;
3891 };
3892
3893 if table_qualifies_for_pipe_syntax(rows) {
3894 render_pipe_table(node, rows, output, opts);
3895 } else {
3896 render_directive_table(node, rows, output, opts);
3897 }
3898}
3899
3900fn table_qualifies_for_pipe_syntax(rows: &[AdfNode]) -> bool {
3907 if rows.iter().any(|n| n.node_type == "caption") {
3909 return false;
3910 }
3911 let mut first_row_has_header = false;
3912 for (row_idx, row) in rows.iter().enumerate() {
3913 let Some(ref cells) = row.content else {
3914 continue;
3915 };
3916 for cell in cells {
3917 if row_idx > 0 && cell.node_type == "tableHeader" {
3919 return false;
3920 }
3921 if row_idx == 0 && cell.node_type == "tableHeader" {
3922 first_row_has_header = true;
3923 }
3924 let Some(ref content) = cell.content else {
3926 continue;
3927 };
3928 if content.len() != 1 || content[0].node_type != "paragraph" {
3929 return false;
3930 }
3931 if cell_contains_hard_break(&content[0]) {
3934 return false;
3935 }
3936 if cell.marks.as_ref().is_some_and(|m| !m.is_empty()) {
3939 return false;
3940 }
3941 if content[0]
3944 .attrs
3945 .as_ref()
3946 .and_then(|a| a.get("localId"))
3947 .is_some()
3948 {
3949 return false;
3950 }
3951 }
3952 }
3953 first_row_has_header
3956}
3957
3958fn cell_contains_hard_break(paragraph: &AdfNode) -> bool {
3960 paragraph
3961 .content
3962 .as_ref()
3963 .is_some_and(|nodes| nodes.iter().any(|n| n.node_type == "hardBreak"))
3964}
3965
3966fn render_pipe_table(node: &AdfNode, rows: &[AdfNode], output: &mut String, opts: &RenderOptions) {
3968 for (row_idx, row) in rows.iter().enumerate() {
3969 let Some(ref cells) = row.content else {
3970 continue;
3971 };
3972
3973 output.push('|');
3974 for cell in cells {
3975 output.push(' ');
3976 let mut cell_buf = String::new();
3977 render_cell_attrs_prefix(cell, &mut cell_buf);
3978 render_inline_content_from_first_paragraph(cell, &mut cell_buf, opts);
3979 output.push_str(&escape_pipes_in_cell(&cell_buf));
3980 output.push_str(" |");
3981 }
3982 output.push('\n');
3983
3984 if row_idx == 0 {
3986 output.push('|');
3987 for cell in cells {
3988 let align = get_cell_paragraph_alignment(cell);
3989 match align {
3990 Some("center") => output.push_str(" :---: |"),
3991 Some("end") => output.push_str(" ---: |"),
3992 _ => output.push_str(" --- |"),
3993 }
3994 }
3995 output.push('\n');
3996 }
3997 }
3998
3999 render_table_level_attrs(node, output, opts);
4001}
4002
4003fn render_directive_table(
4005 node: &AdfNode,
4006 rows: &[AdfNode],
4007 output: &mut String,
4008 opts: &RenderOptions,
4009) {
4010 let mut attr_parts = Vec::new();
4012 if let Some(ref attrs) = node.attrs {
4013 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
4014 attr_parts.push(format!("layout={layout}"));
4015 }
4016 if let Some(numbered) = attrs
4017 .get("isNumberColumnEnabled")
4018 .and_then(serde_json::Value::as_bool)
4019 {
4020 if numbered {
4021 attr_parts.push("numbered".to_string());
4022 } else {
4023 attr_parts.push("numbered=false".to_string());
4024 }
4025 }
4026 if let Some(tw) = attrs.get("width").and_then(serde_json::Value::as_f64) {
4027 let tw_str = if tw.fract() == 0.0 {
4028 (tw as u64).to_string()
4029 } else {
4030 tw.to_string()
4031 };
4032 attr_parts.push(format!("width={tw_str}"));
4033 }
4034 maybe_push_local_id(attrs, &mut attr_parts, opts);
4035 }
4036 if attr_parts.is_empty() {
4037 output.push_str("::::table\n");
4038 } else {
4039 output.push_str(&format!("::::table{{{}}}\n", attr_parts.join(" ")));
4040 }
4041
4042 for row in rows {
4043 if row.node_type == "caption" {
4044 let mut cap_parts = Vec::new();
4045 if let Some(ref attrs) = row.attrs {
4046 maybe_push_local_id(attrs, &mut cap_parts, opts);
4047 }
4048 if cap_parts.is_empty() {
4049 output.push_str(":::caption\n");
4050 } else {
4051 output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
4052 }
4053 if let Some(ref content) = row.content {
4054 for child in content {
4055 render_inline_node(child, output, opts);
4056 }
4057 output.push('\n');
4058 }
4059 output.push_str(":::\n");
4060 continue;
4061 }
4062 let Some(ref cells) = row.content else {
4063 continue;
4064 };
4065 let mut tr_attrs = Vec::new();
4067 if let Some(ref attrs) = row.attrs {
4068 maybe_push_local_id(attrs, &mut tr_attrs, opts);
4069 }
4070 if tr_attrs.is_empty() {
4071 output.push_str(":::tr\n");
4072 } else {
4073 output.push_str(&format!(":::tr{{{}}}\n", tr_attrs.join(" ")));
4074 }
4075 for cell in cells {
4076 let directive_name = if cell.node_type == "tableHeader" {
4077 "th"
4078 } else {
4079 "td"
4080 };
4081 let mut cell_attr_str = build_cell_attrs_string(cell);
4082 if let Some(ref attrs) = cell.attrs {
4084 let mut lid_parts = Vec::new();
4085 maybe_push_local_id(attrs, &mut lid_parts, opts);
4086 if !lid_parts.is_empty() {
4087 if !cell_attr_str.is_empty() {
4088 cell_attr_str.push(' ');
4089 }
4090 cell_attr_str.push_str(&lid_parts.join(" "));
4091 }
4092 }
4093 if let Some(ref marks) = cell.marks {
4095 for mark in marks {
4096 if mark.mark_type == "border" {
4097 if let Some(ref attrs) = mark.attrs {
4098 if let Some(color) =
4099 attrs.get("color").and_then(serde_json::Value::as_str)
4100 {
4101 if !cell_attr_str.is_empty() {
4102 cell_attr_str.push(' ');
4103 }
4104 cell_attr_str.push_str(&format!("border-color={color}"));
4105 }
4106 if let Some(size) =
4107 attrs.get("size").and_then(serde_json::Value::as_u64)
4108 {
4109 if !cell_attr_str.is_empty() {
4110 cell_attr_str.push(' ');
4111 }
4112 cell_attr_str.push_str(&format!("border-size={size}"));
4113 }
4114 }
4115 }
4116 }
4117 }
4118 let has_marks = cell.marks.as_ref().is_some_and(|m| !m.is_empty());
4119 if cell_attr_str.is_empty() && cell.attrs.is_none() && !has_marks {
4120 output.push_str(&format!(":::{directive_name}\n"));
4121 } else {
4122 output.push_str(&format!(":::{directive_name}{{{cell_attr_str}}}\n"));
4123 }
4124 if let Some(ref content) = cell.content {
4125 render_block_children(content, output, opts);
4126 }
4127 output.push_str(":::\n");
4128 }
4129 output.push_str(":::\n");
4130 }
4131
4132 output.push_str("::::\n");
4133}
4134
4135fn needs_attr_quoting(value: &str) -> bool {
4139 value.contains(|c: char| c.is_whitespace() || c == '}' || c == '(' || c == ')' || c == ',')
4140}
4141
4142fn build_cell_attrs_string(cell: &AdfNode) -> String {
4144 let Some(ref attrs) = cell.attrs else {
4145 return String::new();
4146 };
4147 let mut parts = Vec::new();
4148 if let Some(colspan) = attrs.get("colspan").and_then(serde_json::Value::as_u64) {
4149 parts.push(format!("colspan={colspan}"));
4150 }
4151 if let Some(rowspan) = attrs.get("rowspan").and_then(serde_json::Value::as_u64) {
4152 parts.push(format!("rowspan={rowspan}"));
4153 }
4154 if let Some(bg) = attrs.get("background").and_then(serde_json::Value::as_str) {
4155 if needs_attr_quoting(bg) {
4156 let escaped = bg.replace('\\', "\\\\").replace('"', "\\\"");
4157 parts.push(format!("bg=\"{escaped}\""));
4158 } else {
4159 parts.push(format!("bg={bg}"));
4160 }
4161 }
4162 if let Some(colwidth) = attrs.get("colwidth").and_then(serde_json::Value::as_array) {
4163 let widths: Vec<String> = colwidth
4164 .iter()
4165 .filter_map(|v| {
4166 if let Some(n) = v.as_u64() {
4169 Some(n.to_string())
4170 } else if let Some(n) = v.as_f64() {
4171 if n.fract() == 0.0 {
4172 format!("{n:.1}")
4173 } else {
4174 n.to_string()
4175 }
4176 .into()
4177 } else {
4178 None
4179 }
4180 })
4181 .collect();
4182 if !widths.is_empty() {
4183 parts.push(format!("colwidth={}", widths.join(",")));
4184 }
4185 }
4186 parts.join(" ")
4187}
4188
4189fn render_cell_attrs_prefix(cell: &AdfNode, output: &mut String) {
4191 let Some(ref _attrs) = cell.attrs else {
4192 return;
4193 };
4194 let attr_str = build_cell_attrs_string(cell);
4195 if attr_str.is_empty() {
4196 output.push_str("{} ");
4197 } else {
4198 output.push_str(&format!("{{{attr_str}}} "));
4199 }
4200}
4201
4202fn get_cell_paragraph_alignment(cell: &AdfNode) -> Option<&str> {
4204 let content = cell.content.as_ref()?;
4205 let para = content.first()?;
4206 let marks = para.marks.as_ref()?;
4207 marks.iter().find_map(|m| {
4208 if m.mark_type == "alignment" {
4209 m.attrs
4210 .as_ref()
4211 .and_then(|a| a.get("align"))
4212 .and_then(serde_json::Value::as_str)
4213 } else {
4214 None
4215 }
4216 })
4217}
4218
4219fn render_table_level_attrs(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4221 if let Some(ref attrs) = node.attrs {
4222 let mut parts = Vec::new();
4223 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
4224 parts.push(format!("layout={layout}"));
4225 }
4226 if let Some(numbered) = attrs
4227 .get("isNumberColumnEnabled")
4228 .and_then(serde_json::Value::as_bool)
4229 {
4230 if numbered {
4231 parts.push("numbered".to_string());
4232 } else {
4233 parts.push("numbered=false".to_string());
4234 }
4235 }
4236 if let Some(tw_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4237 parts.push(format!("width={tw_str}"));
4238 }
4239 maybe_push_local_id(attrs, &mut parts, opts);
4240 if !parts.is_empty() {
4241 output.push_str(&format!("{{{}}}\n", parts.join(" ")));
4242 }
4243 }
4244}
4245
4246fn render_inline_content_from_first_paragraph(
4248 cell: &AdfNode,
4249 output: &mut String,
4250 opts: &RenderOptions,
4251) {
4252 if let Some(ref content) = cell.content {
4253 if let Some(first) = content.first() {
4254 if first.node_type == "paragraph" {
4255 render_inline_content(first, output, opts);
4256 }
4257 }
4258 }
4259}
4260
4261fn push_border_mark_attrs(marks: &Option<Vec<AdfMark>>, parts: &mut Vec<String>) {
4263 if let Some(ref marks) = marks {
4264 for mark in marks {
4265 if mark.mark_type == "border" {
4266 if let Some(ref attrs) = mark.attrs {
4267 if let Some(color) = attrs.get("color").and_then(serde_json::Value::as_str) {
4268 parts.push(format!("border-color={color}"));
4269 }
4270 if let Some(size) = attrs.get("size").and_then(serde_json::Value::as_u64) {
4271 parts.push(format!("border-size={size}"));
4272 }
4273 }
4274 }
4275 }
4276 }
4277}
4278
4279fn render_media(
4281 node: &AdfNode,
4282 parent_attrs: Option<&serde_json::Value>,
4283 output: &mut String,
4284 opts: &RenderOptions,
4285) {
4286 if let Some(ref attrs) = node.attrs {
4287 let media_type = attrs
4288 .get("type")
4289 .and_then(serde_json::Value::as_str)
4290 .unwrap_or("external");
4291 let alt = attrs
4292 .get("alt")
4293 .and_then(serde_json::Value::as_str)
4294 .unwrap_or("");
4295
4296 if media_type == "file" {
4297 output.push_str(&format!("![{alt}]()"));
4299 let mut parts = vec!["type=file".to_string()];
4300 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4301 parts.push(format_kv("id", id));
4302 }
4303 if let Some(collection) = attrs.get("collection").and_then(serde_json::Value::as_str) {
4304 parts.push(format_kv("collection", collection));
4305 }
4306 if let Some(occurrence_key) = attrs
4307 .get("occurrenceKey")
4308 .and_then(serde_json::Value::as_str)
4309 {
4310 parts.push(format_kv("occurrenceKey", occurrence_key));
4311 }
4312 if let Some(height_str) = attrs.get("height").and_then(fmt_numeric_attr) {
4313 parts.push(format!("height={height_str}"));
4314 }
4315 if let Some(width_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4316 parts.push(format!("width={width_str}"));
4317 }
4318 maybe_push_local_id(attrs, &mut parts, opts);
4319 if let Some(p_attrs) = parent_attrs {
4321 if let Some(layout) = p_attrs.get("layout").and_then(serde_json::Value::as_str) {
4322 if layout != "center" {
4323 parts.push(format!("layout={layout}"));
4324 }
4325 }
4326 if let Some(ms_width_str) = p_attrs.get("width").and_then(fmt_numeric_attr) {
4327 parts.push(format!("mediaWidth={ms_width_str}"));
4328 }
4329 if let Some(wt) = p_attrs.get("widthType").and_then(serde_json::Value::as_str) {
4330 parts.push(format!("widthType={wt}"));
4331 }
4332 if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4333 parts.push(format!("mode={mode}"));
4334 }
4335 }
4336 push_border_mark_attrs(&node.marks, &mut parts);
4337 output.push_str(&format!("{{{}}}", parts.join(" ")));
4338 } else {
4339 let url = attrs
4341 .get("url")
4342 .and_then(serde_json::Value::as_str)
4343 .unwrap_or("");
4344 output.push_str(&format!(""));
4345
4346 {
4348 let mut parts = Vec::new();
4349 if let Some(p_attrs) = parent_attrs {
4350 let layout = p_attrs.get("layout").and_then(serde_json::Value::as_str);
4351 let width_str = p_attrs.get("width").and_then(fmt_numeric_attr);
4352 let width_type = p_attrs.get("widthType").and_then(serde_json::Value::as_str);
4353 if let Some(l) = layout {
4354 if l != "center" {
4355 parts.push(format!("layout={l}"));
4356 }
4357 }
4358 if let Some(w) = width_str {
4359 parts.push(format!("width={w}"));
4360 }
4361 if let Some(wt) = width_type {
4362 parts.push(format!("widthType={wt}"));
4363 }
4364 if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4365 parts.push(format!("mode={mode}"));
4366 }
4367 }
4368 maybe_push_local_id(attrs, &mut parts, opts);
4369 push_border_mark_attrs(&node.marks, &mut parts);
4370 if !parts.is_empty() {
4371 output.push_str(&format!("{{{}}}", parts.join(" ")));
4372 }
4373 }
4374 }
4375
4376 output.push('\n');
4377 }
4378}
4379
4380fn render_inline_content(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4382 if let Some(ref content) = node.content {
4383 for child in content {
4384 render_inline_node(child, output, opts);
4385 }
4386 }
4387}
4388
4389fn render_inline_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4391 match node.node_type.as_str() {
4392 "text" => {
4393 let text = node.text.as_deref().unwrap_or("");
4394 let marks = node.marks.as_deref().unwrap_or(&[]);
4395 let has_code = marks.iter().any(|m| m.mark_type == "code");
4396 let owned;
4401 let text = if !has_code {
4402 owned = text.replace('\\', "\\\\");
4403 owned.as_str()
4404 } else {
4405 text
4406 };
4407 let owned_nl;
4412 let text = if text.contains('\n') {
4413 owned_nl = text.replace('\n', "\\n");
4414 owned_nl.as_str()
4415 } else {
4416 text
4417 };
4418 let owned_ts;
4424 let text = if !has_code && text.ends_with(" ") {
4425 let mut s = text.to_string();
4426 s.insert(s.len() - 1, '\\');
4428 owned_ts = s;
4429 owned_ts.as_str()
4430 } else {
4431 text
4432 };
4433 render_marked_text(text, marks, output);
4434 }
4435 "hardBreak" => {
4436 output.push_str("\\\n");
4437 }
4438 other => {
4439 let mut body = String::new();
4443 render_non_text_inline_body(other, node, &mut body, opts);
4444
4445 let annotations: Vec<&AdfMark> = node
4446 .marks
4447 .as_deref()
4448 .unwrap_or(&[])
4449 .iter()
4450 .filter(|m| m.mark_type == "annotation")
4451 .collect();
4452
4453 if annotations.is_empty() {
4454 output.push_str(&body);
4455 } else {
4456 let mut attr_parts = Vec::new();
4457 for ann in &annotations {
4458 if let Some(ref attrs) = ann.attrs {
4459 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4460 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4461 attr_parts.push(format!("annotation-id=\"{escaped}\""));
4462 }
4463 if let Some(at) = attrs
4464 .get("annotationType")
4465 .and_then(serde_json::Value::as_str)
4466 {
4467 attr_parts.push(format!("annotation-type={at}"));
4468 }
4469 }
4470 }
4471 output.push('[');
4472 output.push_str(&body);
4473 output.push_str("]{");
4474 output.push_str(&attr_parts.join(" "));
4475 output.push('}');
4476 }
4477 }
4478 }
4479}
4480
4481fn render_non_text_inline_body(
4483 node_type: &str,
4484 node: &AdfNode,
4485 output: &mut String,
4486 opts: &RenderOptions,
4487) {
4488 match node_type {
4489 "inlineCard" => {
4490 if let Some(ref attrs) = node.attrs {
4491 if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4492 let mut attr_parts = Vec::new();
4493 if url_safe_in_bracket_content(url) {
4494 output.push_str(":card[");
4495 output.push_str(url);
4496 output.push(']');
4497 } else {
4498 output.push_str(":card[]");
4502 let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
4503 attr_parts.push(format!("url=\"{escaped}\""));
4504 }
4505 maybe_push_local_id(attrs, &mut attr_parts, opts);
4506 if !attr_parts.is_empty() {
4507 output.push('{');
4508 output.push_str(&attr_parts.join(" "));
4509 output.push('}');
4510 }
4511 }
4512 }
4513 }
4514 "emoji" => {
4515 if let Some(ref attrs) = node.attrs {
4516 if let Some(short_name) = attrs.get("shortName").and_then(serde_json::Value::as_str)
4517 {
4518 output.push(':');
4519 let name = short_name.strip_prefix(':').unwrap_or(short_name);
4520 let name = name.strip_suffix(':').unwrap_or(name);
4521 output.push_str(name);
4522 output.push(':');
4523
4524 let mut parts = Vec::new();
4525 let escaped_sn = short_name.replace('\\', "\\\\").replace('"', "\\\"");
4526 parts.push(format!("shortName=\"{escaped_sn}\""));
4527 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4528 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4529 parts.push(format!("id=\"{escaped}\""));
4530 }
4531 if let Some(text) = attrs.get("text").and_then(serde_json::Value::as_str) {
4532 let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
4533 parts.push(format!("text=\"{escaped}\""));
4534 }
4535 maybe_push_local_id(attrs, &mut parts, opts);
4536 output.push('{');
4537 output.push_str(&parts.join(" "));
4538 output.push('}');
4539 }
4540 }
4541 }
4542 "status" => {
4543 if let Some(ref attrs) = node.attrs {
4544 let text = attrs
4545 .get("text")
4546 .and_then(serde_json::Value::as_str)
4547 .unwrap_or("");
4548 let color = attrs
4549 .get("color")
4550 .and_then(serde_json::Value::as_str)
4551 .unwrap_or("neutral");
4552 let mut attr_parts = vec![format!("color={color}")];
4553 if let Some(style) = attrs.get("style").and_then(serde_json::Value::as_str) {
4554 attr_parts.push(format!("style={style}"));
4555 }
4556 maybe_push_local_id(attrs, &mut attr_parts, opts);
4557 output.push_str(&format!(":status[{text}]{{{}}}", attr_parts.join(" ")));
4558 }
4559 }
4560 "date" => {
4561 if let Some(ref attrs) = node.attrs {
4562 if let Some(timestamp) = attrs.get("timestamp").and_then(serde_json::Value::as_str)
4563 {
4564 let display = epoch_ms_to_iso_date(timestamp);
4565 let mut attr_parts = vec![format!("timestamp={timestamp}")];
4566 maybe_push_local_id(attrs, &mut attr_parts, opts);
4567 output.push_str(&format!(":date[{display}]{{{}}}", attr_parts.join(" ")));
4568 }
4569 }
4570 }
4571 "mention" => {
4572 if let Some(ref attrs) = node.attrs {
4573 let id = attrs
4574 .get("id")
4575 .and_then(serde_json::Value::as_str)
4576 .unwrap_or("");
4577 let text = attrs
4578 .get("text")
4579 .and_then(serde_json::Value::as_str)
4580 .unwrap_or("");
4581 let mut attr_parts = vec![format!("id={id}")];
4582 if let Some(ut) = attrs.get("userType").and_then(serde_json::Value::as_str) {
4583 attr_parts.push(format!("userType={ut}"));
4584 }
4585 if let Some(al) = attrs.get("accessLevel").and_then(serde_json::Value::as_str) {
4586 attr_parts.push(format!("accessLevel={al}"));
4587 }
4588 maybe_push_local_id(attrs, &mut attr_parts, opts);
4589 output.push_str(&format!(":mention[{text}]{{{}}}", attr_parts.join(" ")));
4590 }
4591 }
4592 "placeholder" => {
4593 if let Some(ref attrs) = node.attrs {
4594 let text = attrs
4595 .get("text")
4596 .and_then(serde_json::Value::as_str)
4597 .unwrap_or("");
4598 output.push_str(&format!(":placeholder[{text}]"));
4599 }
4600 }
4601 "inlineExtension" => {
4602 if let Some(ref attrs) = node.attrs {
4603 let ext_type = attrs
4604 .get("extensionType")
4605 .and_then(serde_json::Value::as_str)
4606 .unwrap_or("");
4607 let ext_key = attrs
4608 .get("extensionKey")
4609 .and_then(serde_json::Value::as_str)
4610 .unwrap_or("");
4611 let fallback = node.text.as_deref().unwrap_or("");
4612 output.push_str(&format!(
4613 ":extension[{fallback}]{{type={ext_type} key={ext_key}}}"
4614 ));
4615 }
4616 }
4617 "mediaInline" => {
4618 if let Some(ref attrs) = node.attrs {
4619 let mut attr_parts = Vec::new();
4620 if let Some(media_type) = attrs.get("type").and_then(serde_json::Value::as_str) {
4621 attr_parts.push(format_kv("type", media_type));
4622 }
4623 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4624 attr_parts.push(format_kv("id", id));
4625 }
4626 if let Some(collection) =
4627 attrs.get("collection").and_then(serde_json::Value::as_str)
4628 {
4629 attr_parts.push(format_kv("collection", collection));
4630 }
4631 if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4632 attr_parts.push(format_kv("url", url));
4633 }
4634 if let Some(alt) = attrs.get("alt").and_then(serde_json::Value::as_str) {
4635 attr_parts.push(format_kv("alt", alt));
4636 }
4637 if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
4638 attr_parts.push(format!("width={width}"));
4639 }
4640 if let Some(height) = attrs.get("height").and_then(serde_json::Value::as_u64) {
4641 attr_parts.push(format!("height={height}"));
4642 }
4643 maybe_push_local_id(attrs, &mut attr_parts, opts);
4644 output.push_str(&format!(":media-inline[]{{{}}}", attr_parts.join(" ")));
4645 }
4646 }
4647 _ => {
4648 if let Ok(json) = serde_json::to_string(node) {
4655 output.push_str(":adf-unsupported[]{");
4656 output.push_str(&format_kv("json", &json));
4657 output.push('}');
4658 }
4659 }
4660 }
4661}
4662
4663fn render_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4676 if marks.iter().any(|m| m.mark_type == "code") {
4677 render_code_marked_text(text, marks, output);
4678 return;
4679 }
4680
4681 let has_link = marks.iter().any(|m| m.mark_type == "link");
4682 let has_strong = marks.iter().any(|m| m.mark_type == "strong");
4683 let has_em = marks.iter().any(|m| m.mark_type == "em");
4684
4685 if marks.len() == 2 && marks[0].mark_type == "strong" && marks[1].mark_type == "em" {
4689 let escaped = escape_emphasis_markers(text);
4690 let escaped = escape_emoji_shortcodes(&escaped);
4691 let escaped = escape_backticks(&escaped);
4692 let escaped = escape_bare_urls(&escaped);
4693 output.push_str("***");
4694 output.push_str(&escaped);
4695 output.push_str("***");
4696 return;
4697 }
4698
4699 let em_delim = if has_strong && has_em { "_" } else { "*" };
4703
4704 let escaped = if em_delim == "_" {
4707 escape_emphasis_markers_with_underscore(text)
4708 } else {
4709 escape_emphasis_markers(text)
4710 };
4711 let escaped = escape_emoji_shortcodes(&escaped);
4712 let escaped = escape_backticks(&escaped);
4713 let escaped = escape_bare_urls(&escaped);
4721 let escaped = if has_link {
4722 escape_link_brackets(&escaped)
4723 } else {
4724 escaped
4725 };
4726
4727 let mut wrappers: Vec<(String, String)> = Vec::new();
4733 let mut i = 0;
4734 while i < marks.len() {
4735 match marks[i].mark_type.as_str() {
4736 "em" => {
4737 wrappers.push((em_delim.to_string(), em_delim.to_string()));
4738 i += 1;
4739 }
4740 "strong" => {
4741 wrappers.push(("**".to_string(), "**".to_string()));
4742 i += 1;
4743 }
4744 "strike" => {
4745 wrappers.push(("~~".to_string(), "~~".to_string()));
4746 i += 1;
4747 }
4748 "link" => {
4749 let href = link_href(&marks[i]);
4750 wrappers.push(("[".to_string(), format!("]({href})")));
4751 i += 1;
4752 }
4753 "textColor" | "backgroundColor" | "subsup" => {
4754 let start = i;
4755 while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4756 i += 1;
4757 }
4758 emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4759 }
4760 "underline" | "annotation" => {
4761 let start = i;
4762 while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4763 i += 1;
4764 }
4765 emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4766 }
4767 _ => {
4768 i += 1;
4769 }
4770 }
4771 }
4772
4773 let mut result = escaped;
4775 for (open, close) in wrappers.iter().rev() {
4776 result.insert_str(0, open);
4777 result.push_str(close);
4778 }
4779 output.push_str(&result);
4780}
4781
4782fn render_code_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4790 let link_mark = marks.iter().find(|m| m.mark_type == "link");
4791
4792 let mut code_str = String::new();
4793 if let Some(link_mark) = link_mark {
4794 let href = link_href(link_mark);
4795 code_str.push('[');
4796 render_inline_code(text, &mut code_str);
4797 code_str.push_str("](");
4798 code_str.push_str(href);
4799 code_str.push(')');
4800 } else {
4801 render_inline_code(text, &mut code_str);
4802 }
4803
4804 let mut wrappers: Vec<(String, String)> = Vec::new();
4807 let mut i = 0;
4808 while i < marks.len() {
4809 match marks[i].mark_type.as_str() {
4810 "textColor" | "backgroundColor" | "subsup" => {
4811 let start = i;
4812 while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4813 i += 1;
4814 }
4815 emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4816 }
4817 "underline" | "annotation" => {
4818 let start = i;
4819 while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4820 i += 1;
4821 }
4822 emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4823 }
4824 _ => {
4825 i += 1;
4826 }
4827 }
4828 }
4829
4830 let mut result = code_str;
4832 for (open, close) in wrappers.iter().rev() {
4833 result.insert_str(0, open);
4834 result.push_str(close);
4835 }
4836 output.push_str(&result);
4837}
4838
4839fn collect_span_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4841 match mark.mark_type.as_str() {
4842 "textColor" => {
4843 if let Some(c) = mark
4844 .attrs
4845 .as_ref()
4846 .and_then(|a| a.get("color"))
4847 .and_then(serde_json::Value::as_str)
4848 {
4849 attrs.push(format!("color={c}"));
4850 }
4851 }
4852 "backgroundColor" => {
4853 if let Some(c) = mark
4854 .attrs
4855 .as_ref()
4856 .and_then(|a| a.get("color"))
4857 .and_then(serde_json::Value::as_str)
4858 {
4859 attrs.push(format!("bg={c}"));
4860 }
4861 }
4862 "subsup" => {
4863 if let Some(kind) = mark
4864 .attrs
4865 .as_ref()
4866 .and_then(|a| a.get("type"))
4867 .and_then(serde_json::Value::as_str)
4868 {
4869 attrs.push(kind.to_string());
4870 }
4871 }
4872 _ => {}
4873 }
4874}
4875
4876fn collect_bracketed_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4878 match mark.mark_type.as_str() {
4879 "underline" => attrs.push("underline".to_string()),
4880 "annotation" => {
4881 if let Some(ref a) = mark.attrs {
4882 if let Some(id) = a.get("id").and_then(serde_json::Value::as_str) {
4883 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4884 attrs.push(format!("annotation-id=\"{escaped}\""));
4885 }
4886 if let Some(at) = a.get("annotationType").and_then(serde_json::Value::as_str) {
4887 attrs.push(format!("annotation-type={at}"));
4888 }
4889 }
4890 }
4891 _ => {}
4892 }
4893}
4894
4895fn is_span_attr_mark(mark_type: &str) -> bool {
4896 matches!(mark_type, "textColor" | "backgroundColor" | "subsup")
4897}
4898
4899fn is_bracketed_span_mark(mark_type: &str) -> bool {
4900 matches!(mark_type, "underline" | "annotation")
4901}
4902
4903fn span_attr_order(mark_type: &str) -> u8 {
4907 match mark_type {
4908 "textColor" => 0,
4909 "backgroundColor" => 1,
4910 "subsup" => 2,
4911 _ => u8::MAX,
4912 }
4913}
4914
4915fn span_run_is_canonical(run: &[AdfMark]) -> bool {
4920 let mut prev = 0;
4921 for m in run {
4922 let order = span_attr_order(&m.mark_type);
4923 if order == u8::MAX || order < prev {
4924 return false;
4925 }
4926 prev = order;
4927 }
4928 true
4929}
4930
4931fn bracketed_run_is_canonical(run: &[AdfMark]) -> bool {
4936 let mut seen_annotation = false;
4937 for m in run {
4938 match m.mark_type.as_str() {
4939 "underline" => {
4940 if seen_annotation {
4941 return false;
4942 }
4943 }
4944 "annotation" => seen_annotation = true,
4945 _ => return false,
4946 }
4947 }
4948 true
4949}
4950
4951fn emit_span_attr_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4955 if span_run_is_canonical(run) {
4956 let mut attrs = Vec::new();
4957 for m in run {
4958 collect_span_attr(m, &mut attrs);
4959 }
4960 wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4961 return;
4962 }
4963 for m in run {
4964 let mut attrs = Vec::new();
4965 collect_span_attr(m, &mut attrs);
4966 wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4967 }
4968}
4969
4970fn emit_bracketed_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4974 if bracketed_run_is_canonical(run) {
4975 let mut attrs = Vec::new();
4976 for m in run {
4977 collect_bracketed_attr(m, &mut attrs);
4978 }
4979 wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4980 return;
4981 }
4982 for m in run {
4983 let mut attrs = Vec::new();
4984 collect_bracketed_attr(m, &mut attrs);
4985 wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4986 }
4987}
4988
4989fn link_href(mark: &AdfMark) -> &str {
4991 mark.attrs
4992 .as_ref()
4993 .and_then(|a| a.get("href"))
4994 .and_then(serde_json::Value::as_str)
4995 .unwrap_or("")
4996}
4997
4998#[cfg(test)]
4999#[allow(
5000 clippy::unwrap_used,
5001 clippy::expect_used,
5002 clippy::needless_update,
5003 clippy::needless_collect,
5004 duplicate_macro_attributes
5005)]
5006mod tests {
5007 use super::*;
5008
5009 #[test]
5012 fn adf_to_plain_text_single_paragraph() {
5013 let doc = markdown_to_adf("Hello world").unwrap();
5014 assert_eq!(adf_to_plain_text(&doc), "Hello world");
5015 }
5016
5017 #[test]
5018 fn adf_to_plain_text_multiple_paragraphs_space_separated() {
5019 let doc = markdown_to_adf("Alpha\n\nBeta").unwrap();
5020 let plain = adf_to_plain_text(&doc);
5021 assert!(plain.contains("Alpha"));
5023 assert!(plain.contains("Beta"));
5024 assert_eq!(plain, "Alpha Beta");
5025 }
5026
5027 #[test]
5028 fn adf_to_plain_text_drops_marks_but_keeps_text() {
5029 let doc = markdown_to_adf("Hello **bold** world").unwrap();
5030 assert_eq!(adf_to_plain_text(&doc), "Hello bold world");
5031 }
5032
5033 #[test]
5034 fn adf_to_plain_text_empty_doc() {
5035 let doc = AdfDocument::new();
5036 assert_eq!(adf_to_plain_text(&doc), "");
5037 }
5038
5039 #[test]
5040 fn adf_to_plain_text_leading_empty_block_emits_no_extra_space() {
5041 let doc = AdfDocument {
5044 version: 1,
5045 doc_type: "doc".to_string(),
5046 content: vec![
5047 AdfNode {
5048 node_type: "paragraph".to_string(),
5049 attrs: None,
5050 content: Some(vec![]),
5051 text: None,
5052 marks: None,
5053 local_id: None,
5054 parameters: None,
5055 },
5056 AdfNode {
5057 node_type: "paragraph".to_string(),
5058 attrs: None,
5059 content: Some(vec![AdfNode::text("Hello")]),
5060 text: None,
5061 marks: None,
5062 local_id: None,
5063 parameters: None,
5064 },
5065 ],
5066 };
5067 assert_eq!(adf_to_plain_text(&doc), "Hello");
5068 }
5069
5070 #[test]
5073 fn paragraph() {
5074 let doc = markdown_to_adf("Hello world").unwrap();
5075 assert_eq!(doc.content.len(), 1);
5076 assert_eq!(doc.content[0].node_type, "paragraph");
5077 }
5078
5079 #[test]
5080 fn heading_levels() {
5081 for level in 1..=6 {
5082 let hashes = "#".repeat(level);
5083 let md = format!("{hashes} Title");
5084 let doc = markdown_to_adf(&md).unwrap();
5085 assert_eq!(doc.content[0].node_type, "heading");
5086 let attrs = doc.content[0].attrs.as_ref().unwrap();
5087 assert_eq!(attrs["level"], level as u64);
5088 }
5089 }
5090
5091 #[test]
5094 fn heading_inline_code_mark_stripped() {
5095 let doc = markdown_to_adf("### `GET /api`").unwrap();
5098 let heading = &doc.content[0];
5099 assert_eq!(heading.node_type, "heading");
5100 let content = heading.content.as_ref().unwrap();
5101 assert_eq!(content.len(), 1);
5102 assert_eq!(content[0].text.as_deref(), Some("GET /api"));
5103 assert!(
5104 content[0].marks.is_none(),
5105 "expected no marks, got: {:?}",
5106 content[0].marks
5107 );
5108 }
5109
5110 #[test]
5111 fn heading_code_strip_preserves_sibling_strong() {
5112 let doc = markdown_to_adf("### **`x`**").unwrap();
5114 let content = doc.content[0].content.as_ref().unwrap();
5115 let marks = content[0].marks.as_ref().unwrap();
5116 assert!(marks.iter().any(|m| m.mark_type == "strong"));
5117 assert!(!marks.iter().any(|m| m.mark_type == "code"));
5118 }
5119
5120 #[test]
5121 fn heading_code_strip_preserves_link() {
5122 let doc = markdown_to_adf("### [`x`](https://e.com)").unwrap();
5124 let content = doc.content[0].content.as_ref().unwrap();
5125 let marks = content[0].marks.as_ref().unwrap();
5126 assert!(marks.iter().any(|m| m.mark_type == "link"));
5127 assert!(!marks.iter().any(|m| m.mark_type == "code"));
5128 }
5129
5130 #[test]
5131 fn heading_with_code_now_passes_validation() {
5132 let doc = markdown_to_adf("### `GET /api`").unwrap();
5135 let violations = crate::atlassian::adf_schema::validate_document(&doc);
5136 assert!(violations.is_empty(), "got: {violations:?}");
5137 }
5138
5139 #[test]
5140 fn paragraph_inline_code_mark_preserved() {
5141 let doc = markdown_to_adf("`x`").unwrap();
5144 let content = doc.content[0].content.as_ref().unwrap();
5145 let marks = content[0].marks.as_ref().unwrap();
5146 assert!(marks.iter().any(|m| m.mark_type == "code"));
5147 }
5148
5149 #[test]
5150 fn code_block() {
5151 let md = "```rust\nfn main() {}\n```";
5152 let doc = markdown_to_adf(md).unwrap();
5153 assert_eq!(doc.content[0].node_type, "codeBlock");
5154 let attrs = doc.content[0].attrs.as_ref().unwrap();
5155 assert_eq!(attrs["language"], "rust");
5156 }
5157
5158 #[test]
5159 fn code_block_no_language() {
5160 let md = "```\nsome code\n```";
5161 let doc = markdown_to_adf(md).unwrap();
5162 assert_eq!(doc.content[0].node_type, "codeBlock");
5163 assert!(doc.content[0].attrs.is_none());
5164 }
5165
5166 #[test]
5167 fn code_block_empty_language() {
5168 let md = "```\"\"\nsome code\n```";
5169 let doc = markdown_to_adf(md).unwrap();
5170 assert_eq!(doc.content[0].node_type, "codeBlock");
5171 let attrs = doc.content[0].attrs.as_ref().unwrap();
5172 assert_eq!(attrs["language"], "");
5173 }
5174
5175 #[test]
5176 fn horizontal_rule() {
5177 let doc = markdown_to_adf("---").unwrap();
5178 assert_eq!(doc.content[0].node_type, "rule");
5179 }
5180
5181 #[test]
5182 fn horizontal_rule_stars() {
5183 let doc = markdown_to_adf("***").unwrap();
5184 assert_eq!(doc.content[0].node_type, "rule");
5185 }
5186
5187 #[test]
5188 fn blockquote() {
5189 let md = "> This is a quote\n> Second line";
5190 let doc = markdown_to_adf(md).unwrap();
5191 assert_eq!(doc.content[0].node_type, "blockquote");
5192 }
5193
5194 #[test]
5195 fn bullet_list() {
5196 let md = "- Item 1\n- Item 2\n- Item 3";
5197 let doc = markdown_to_adf(md).unwrap();
5198 assert_eq!(doc.content[0].node_type, "bulletList");
5199 let items = doc.content[0].content.as_ref().unwrap();
5200 assert_eq!(items.len(), 3);
5201 }
5202
5203 #[test]
5204 fn ordered_list() {
5205 let md = "1. First\n2. Second\n3. Third";
5206 let doc = markdown_to_adf(md).unwrap();
5207 assert_eq!(doc.content[0].node_type, "orderedList");
5208 let items = doc.content[0].content.as_ref().unwrap();
5209 assert_eq!(items.len(), 3);
5210 }
5211
5212 #[test]
5213 fn task_list() {
5214 let md = "- [ ] Todo item\n- [x] Done item";
5215 let doc = markdown_to_adf(md).unwrap();
5216 assert_eq!(doc.content[0].node_type, "taskList");
5217 let items = doc.content[0].content.as_ref().unwrap();
5218 assert_eq!(items.len(), 2);
5219 assert_eq!(items[0].node_type, "taskItem");
5220 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5221 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5222 }
5223
5224 #[test]
5225 fn task_list_uppercase_x() {
5226 let md = "- [X] Done item";
5227 let doc = markdown_to_adf(md).unwrap();
5228 assert_eq!(doc.content[0].node_type, "taskList");
5229 let item = &doc.content[0].content.as_ref().unwrap()[0];
5230 assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
5231 }
5232
5233 #[test]
5236 fn task_list_empty_todo_no_trailing_space() {
5237 let md = "- [ ]";
5238 let doc = markdown_to_adf(md).unwrap();
5239 assert_eq!(doc.content[0].node_type, "taskList");
5240 let items = doc.content[0].content.as_ref().unwrap();
5241 assert_eq!(items.len(), 1);
5242 assert_eq!(items[0].node_type, "taskItem");
5243 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5244 assert!(items[0].content.is_none());
5245 }
5246
5247 #[test]
5249 fn task_list_empty_done_no_trailing_space() {
5250 let md = "- [x]\n- [X]";
5251 let doc = markdown_to_adf(md).unwrap();
5252 assert_eq!(doc.content[0].node_type, "taskList");
5253 let items = doc.content[0].content.as_ref().unwrap();
5254 assert_eq!(items.len(), 2);
5255 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
5256 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5257 }
5258
5259 #[test]
5262 fn task_list_body_has_no_leading_space() {
5263 let md = "- [ ] Buy groceries";
5264 let doc = markdown_to_adf(md).unwrap();
5265 let item = &doc.content[0].content.as_ref().unwrap()[0];
5266 let text = item.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5267 assert_eq!(text, "Buy groceries");
5268 }
5269
5270 #[test]
5274 fn round_trip_empty_task_items_stripped_trailing_spaces() {
5275 let json = r#"{
5276 "version": 1,
5277 "type": "doc",
5278 "content": [{
5279 "type": "taskList",
5280 "attrs": {"localId": "abc"},
5281 "content": [
5282 {"type": "taskItem", "attrs": {"localId": "def", "state": "TODO"}},
5283 {"type": "taskItem", "attrs": {"localId": "ghi", "state": "DONE"}}
5284 ]
5285 }]
5286 }"#;
5287 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5288 let md = adf_to_markdown(&doc).unwrap();
5289 let stripped: String = md.lines().map(str::trim_end).collect::<Vec<_>>().join("\n");
5290 let parsed = markdown_to_adf(&stripped).unwrap();
5291 assert_eq!(parsed.content[0].node_type, "taskList");
5292 let items = parsed.content[0].content.as_ref().unwrap();
5293 assert_eq!(items.len(), 2);
5294 assert_eq!(items[0].node_type, "taskItem");
5295 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5296 assert_eq!(items[1].node_type, "taskItem");
5297 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5298 }
5299
5300 #[test]
5301 fn try_parse_task_marker_accepts_bare_checkbox() {
5302 assert_eq!(try_parse_task_marker("[ ]"), Some(("TODO", "")));
5303 assert_eq!(try_parse_task_marker("[x]"), Some(("DONE", "")));
5304 assert_eq!(try_parse_task_marker("[X]"), Some(("DONE", "")));
5305 assert_eq!(try_parse_task_marker("[ ] foo"), Some(("TODO", "foo")));
5306 assert_eq!(try_parse_task_marker("[x] foo"), Some(("DONE", "foo")));
5307 assert_eq!(try_parse_task_marker("[ ]foo"), None);
5308 assert_eq!(try_parse_task_marker("[x]foo"), None);
5309 assert_eq!(try_parse_task_marker("[y] foo"), None);
5310 }
5311
5312 #[test]
5313 fn starts_with_task_marker_matches_parser() {
5314 assert!(starts_with_task_marker("[ ]"));
5317 assert!(starts_with_task_marker("[x]"));
5318 assert!(starts_with_task_marker("[X]"));
5319 assert!(starts_with_task_marker("[ ] foo"));
5320 assert!(starts_with_task_marker("[x] foo\n"));
5321 assert!(starts_with_task_marker("[ ]\n"));
5322 assert!(!starts_with_task_marker("[ ]foo"));
5324 assert!(!starts_with_task_marker("[y] foo"));
5325 assert!(!starts_with_task_marker("foo [ ] bar"));
5326 assert!(!starts_with_task_marker(""));
5327 }
5328
5329 #[test]
5333 fn round_trip_bullet_list_with_literal_checkbox_text() {
5334 let json = r#"{
5335 "version": 1,
5336 "type": "doc",
5337 "content": [{
5338 "type": "bulletList",
5339 "content": [{
5340 "type": "listItem",
5341 "content": [{
5342 "type": "paragraph",
5343 "content": [
5344 {"type": "text", "text": "[ ] Review the "},
5345 {"type": "text", "text": "config.yaml", "marks": [{"type": "code"}]},
5346 {"type": "text", "text": " file"}
5347 ]
5348 }]
5349 }]
5350 }]
5351 }"#;
5352 let original: AdfDocument = serde_json::from_str(json).unwrap();
5353 let md = adf_to_markdown(&original).unwrap();
5354 assert!(
5356 md.contains(r"- \[ ] Review the "),
5357 "rendered markdown: {md:?}"
5358 );
5359 let parsed = markdown_to_adf(&md).unwrap();
5360 assert_eq!(parsed.content[0].node_type, "bulletList");
5361 let item = &parsed.content[0].content.as_ref().unwrap()[0];
5362 assert_eq!(item.node_type, "listItem");
5363 let para = &item.content.as_ref().unwrap()[0];
5364 assert_eq!(para.node_type, "paragraph");
5365 let text_nodes = para.content.as_ref().unwrap();
5366 assert_eq!(text_nodes[0].text.as_deref().unwrap(), "[ ] Review the ");
5367 assert_eq!(text_nodes[1].text.as_deref().unwrap(), "config.yaml");
5368 assert_eq!(text_nodes[2].text.as_deref().unwrap(), " file");
5369 }
5370
5371 #[test]
5373 fn round_trip_bullet_list_with_literal_done_checkbox_text() {
5374 let json = r#"{
5375 "version": 1,
5376 "type": "doc",
5377 "content": [{
5378 "type": "bulletList",
5379 "content": [{
5380 "type": "listItem",
5381 "content": [{
5382 "type": "paragraph",
5383 "content": [{"type": "text", "text": "[x] not actually done"}]
5384 }]
5385 }]
5386 }]
5387 }"#;
5388 let original: AdfDocument = serde_json::from_str(json).unwrap();
5389 let md = adf_to_markdown(&original).unwrap();
5390 assert!(md.contains(r"- \[x] "), "rendered markdown: {md:?}");
5391 let parsed = markdown_to_adf(&md).unwrap();
5392 assert_eq!(parsed.content[0].node_type, "bulletList");
5393 let item = &parsed.content[0].content.as_ref().unwrap()[0];
5394 let para = &item.content.as_ref().unwrap()[0];
5395 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5396 assert_eq!(text, "[x] not actually done");
5397 }
5398
5399 #[test]
5401 fn round_trip_bullet_list_with_bare_literal_checkbox() {
5402 let json = r#"{
5403 "version": 1,
5404 "type": "doc",
5405 "content": [{
5406 "type": "bulletList",
5407 "content": [{
5408 "type": "listItem",
5409 "content": [{
5410 "type": "paragraph",
5411 "content": [{"type": "text", "text": "[ ]"}]
5412 }]
5413 }]
5414 }]
5415 }"#;
5416 let original: AdfDocument = serde_json::from_str(json).unwrap();
5417 let md = adf_to_markdown(&original).unwrap();
5418 let parsed = markdown_to_adf(&md).unwrap();
5419 assert_eq!(parsed.content[0].node_type, "bulletList");
5420 let item = &parsed.content[0].content.as_ref().unwrap()[0];
5421 let para = &item.content.as_ref().unwrap()[0];
5422 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5423 assert_eq!(text, "[ ]");
5424 }
5425
5426 #[test]
5429 fn bullet_list_non_task_bracket_text_not_escaped() {
5430 let json = r#"{
5431 "version": 1,
5432 "type": "doc",
5433 "content": [{
5434 "type": "bulletList",
5435 "content": [{
5436 "type": "listItem",
5437 "content": [{
5438 "type": "paragraph",
5439 "content": [{"type": "text", "text": "[?] unsure"}]
5440 }]
5441 }]
5442 }]
5443 }"#;
5444 let original: AdfDocument = serde_json::from_str(json).unwrap();
5445 let md = adf_to_markdown(&original).unwrap();
5446 assert!(!md.contains(r"\["), "should not escape: {md:?}");
5447 assert!(md.contains("- [?] unsure"), "rendered: {md:?}");
5448 }
5449
5450 #[test]
5453 fn round_trip_nested_bullet_list_with_literal_checkbox_text() {
5454 let json = r#"{
5455 "version": 1,
5456 "type": "doc",
5457 "content": [{
5458 "type": "bulletList",
5459 "content": [{
5460 "type": "listItem",
5461 "content": [
5462 {"type": "paragraph", "content": [{"type": "text", "text": "outer"}]},
5463 {"type": "bulletList", "content": [{
5464 "type": "listItem",
5465 "content": [{
5466 "type": "paragraph",
5467 "content": [{"type": "text", "text": "[ ] inner literal"}]
5468 }]
5469 }]}
5470 ]
5471 }]
5472 }]
5473 }"#;
5474 let original: AdfDocument = serde_json::from_str(json).unwrap();
5475 let md = adf_to_markdown(&original).unwrap();
5476 let parsed = markdown_to_adf(&md).unwrap();
5477 let outer = &parsed.content[0];
5478 assert_eq!(outer.node_type, "bulletList");
5479 let outer_item = &outer.content.as_ref().unwrap()[0];
5480 let inner_list = &outer_item.content.as_ref().unwrap()[1];
5481 assert_eq!(inner_list.node_type, "bulletList");
5482 let inner_item = &inner_list.content.as_ref().unwrap()[0];
5483 assert_eq!(inner_item.node_type, "listItem");
5484 let para = &inner_item.content.as_ref().unwrap()[0];
5485 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5486 assert_eq!(text, "[ ] inner literal");
5487 }
5488
5489 #[test]
5490 fn adf_task_list_to_markdown() {
5491 let doc = AdfDocument {
5492 version: 1,
5493 doc_type: "doc".to_string(),
5494 content: vec![AdfNode::task_list(vec![
5495 AdfNode::task_item(
5496 "TODO",
5497 vec![AdfNode::paragraph(vec![AdfNode::text("Todo")])],
5498 ),
5499 AdfNode::task_item(
5500 "DONE",
5501 vec![AdfNode::paragraph(vec![AdfNode::text("Done")])],
5502 ),
5503 ])],
5504 };
5505 let md = adf_to_markdown(&doc).unwrap();
5506 assert!(md.contains("- [ ] Todo"));
5507 assert!(md.contains("- [x] Done"));
5508 }
5509
5510 #[test]
5511 fn round_trip_task_list() {
5512 let md = "- [ ] Todo item\n- [x] Done item\n";
5513 let doc = markdown_to_adf(md).unwrap();
5514 let result = adf_to_markdown(&doc).unwrap();
5515 assert!(result.contains("- [ ] Todo item"));
5516 assert!(result.contains("- [x] Done item"));
5517 }
5518
5519 #[test]
5521 fn adf_task_item_unwrapped_inline_content() {
5522 let json = r#"{
5524 "version": 1,
5525 "type": "doc",
5526 "content": [{
5527 "type": "taskList",
5528 "attrs": {"localId": "list-001"},
5529 "content": [{
5530 "type": "taskItem",
5531 "attrs": {"localId": "task-001", "state": "TODO"},
5532 "content": [{"type": "text", "text": "Do something"}]
5533 }]
5534 }]
5535 }"#;
5536 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5537 let md = adf_to_markdown(&doc).unwrap();
5538 assert!(md.contains("- [ ] Do something"), "got: {md}");
5539 assert!(!md.contains("adf-unsupported"), "got: {md}");
5540 }
5541
5542 #[test]
5544 fn adf_task_list_multiple_unwrapped_items() {
5545 let json = r#"{
5546 "version": 1,
5547 "type": "doc",
5548 "content": [{
5549 "type": "taskList",
5550 "attrs": {"localId": "list-001"},
5551 "content": [
5552 {
5553 "type": "taskItem",
5554 "attrs": {"localId": "task-001", "state": "TODO"},
5555 "content": [{"type": "text", "text": "First task"}]
5556 },
5557 {
5558 "type": "taskItem",
5559 "attrs": {"localId": "task-002", "state": "DONE"},
5560 "content": [{"type": "text", "text": "Second task"}]
5561 }
5562 ]
5563 }]
5564 }"#;
5565 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5566 let md = adf_to_markdown(&doc).unwrap();
5567 assert!(md.contains("- [ ] First task"), "got: {md}");
5568 assert!(md.contains("- [x] Second task"), "got: {md}");
5569 assert!(!md.contains("adf-unsupported"), "got: {md}");
5570 }
5571
5572 #[test]
5574 fn adf_task_item_unwrapped_inline_with_marks() {
5575 let json = r#"{
5576 "version": 1,
5577 "type": "doc",
5578 "content": [{
5579 "type": "taskList",
5580 "attrs": {"localId": "list-001"},
5581 "content": [{
5582 "type": "taskItem",
5583 "attrs": {"localId": "task-001", "state": "TODO"},
5584 "content": [
5585 {"type": "text", "text": "Buy "},
5586 {"type": "text", "text": "groceries", "marks": [{"type": "strong"}]},
5587 {"type": "text", "text": " today"}
5588 ]
5589 }]
5590 }]
5591 }"#;
5592 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5593 let md = adf_to_markdown(&doc).unwrap();
5594 assert!(md.contains("- [ ] Buy **groceries** today"), "got: {md}");
5595 }
5596
5597 #[test]
5599 fn adf_task_item_unwrapped_preserves_local_id() {
5600 let json = r#"{
5601 "version": 1,
5602 "type": "doc",
5603 "content": [{
5604 "type": "taskList",
5605 "attrs": {"localId": "list-001"},
5606 "content": [{
5607 "type": "taskItem",
5608 "attrs": {"localId": "task-001", "state": "TODO"},
5609 "content": [{"type": "text", "text": "Do something"}]
5610 }]
5611 }]
5612 }"#;
5613 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5614 let md = adf_to_markdown(&doc).unwrap();
5615 assert!(md.contains("{localId=task-001}"), "got: {md}");
5616 assert!(md.contains("{localId=list-001}"), "got: {md}");
5617 }
5618
5619 #[test]
5621 fn round_trip_task_list_unwrapped_inline() {
5622 let json = r#"{
5623 "version": 1,
5624 "type": "doc",
5625 "content": [{
5626 "type": "taskList",
5627 "attrs": {"localId": "list-001"},
5628 "content": [
5629 {
5630 "type": "taskItem",
5631 "attrs": {"localId": "task-001", "state": "TODO"},
5632 "content": [{"type": "text", "text": "Do something"}]
5633 },
5634 {
5635 "type": "taskItem",
5636 "attrs": {"localId": "task-002", "state": "DONE"},
5637 "content": [{"type": "text", "text": "Already done"}]
5638 }
5639 ]
5640 }]
5641 }"#;
5642 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5643 let md = adf_to_markdown(&doc).unwrap();
5644
5645 let doc2 = markdown_to_adf(&md).unwrap();
5647 assert_eq!(doc2.content[0].node_type, "taskList");
5648
5649 let items = doc2.content[0].content.as_ref().unwrap();
5650 assert_eq!(items.len(), 2);
5651 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5652 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5653
5654 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5656 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "task-002");
5657 assert_eq!(
5658 doc2.content[0].attrs.as_ref().unwrap()["localId"],
5659 "list-001"
5660 );
5661 }
5662
5663 #[test]
5665 fn adf_task_item_unwrapped_inline_then_block() {
5666 let json = r#"{
5667 "version": 1,
5668 "type": "doc",
5669 "content": [{
5670 "type": "taskList",
5671 "attrs": {"localId": "list-001"},
5672 "content": [{
5673 "type": "taskItem",
5674 "attrs": {"localId": "task-001", "state": "TODO"},
5675 "content": [
5676 {"type": "text", "text": "Parent task"},
5677 {
5678 "type": "bulletList",
5679 "content": [{
5680 "type": "listItem",
5681 "content": [{
5682 "type": "paragraph",
5683 "content": [{"type": "text", "text": "sub-item"}]
5684 }]
5685 }]
5686 }
5687 ]
5688 }]
5689 }]
5690 }"#;
5691 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5692 let md = adf_to_markdown(&doc).unwrap();
5693 assert!(md.contains("- [ ] Parent task"), "got: {md}");
5694 assert!(md.contains(" - sub-item"), "got: {md}");
5695 assert!(!md.contains("adf-unsupported"), "got: {md}");
5696 }
5697
5698 #[test]
5700 fn adf_task_item_empty_content() {
5701 let json = r#"{
5702 "version": 1,
5703 "type": "doc",
5704 "content": [{
5705 "type": "taskList",
5706 "attrs": {"localId": "list-001"},
5707 "content": [{
5708 "type": "taskItem",
5709 "attrs": {"localId": "task-001", "state": "TODO"},
5710 "content": []
5711 }]
5712 }]
5713 }"#;
5714 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5715 let md = adf_to_markdown(&doc).unwrap();
5716 assert!(md.contains("- [ ] "), "got: {md}");
5717 assert!(!md.contains("adf-unsupported"), "got: {md}");
5718 }
5719
5720 #[test]
5723 fn adf_nested_task_item_renders_without_corruption() {
5724 let json = r#"{
5725 "type": "doc",
5726 "version": 1,
5727 "content": [{
5728 "type": "taskList",
5729 "attrs": {"localId": ""},
5730 "content": [
5731 {
5732 "type": "taskItem",
5733 "attrs": {"localId": "aabbccdd-1234-5678-abcd-aabbccdd1234", "state": "TODO"},
5734 "content": [{"type": "text", "text": "Normal task"}]
5735 },
5736 {
5737 "type": "taskItem",
5738 "attrs": {"localId": ""},
5739 "content": [
5740 {
5741 "type": "taskItem",
5742 "attrs": {"localId": "bbccddee-2345-6789-bcde-bbccddee2345", "state": "TODO"},
5743 "content": [{"type": "text", "text": "Nested task one"}]
5744 },
5745 {
5746 "type": "taskItem",
5747 "attrs": {"localId": "ccddee11-3456-7890-cdef-ccddee113456", "state": "DONE"},
5748 "content": [{"type": "text", "text": "Nested task two"}]
5749 }
5750 ]
5751 }
5752 ]
5753 }]
5754 }"#;
5755 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5756 let md = adf_to_markdown(&doc).unwrap();
5757 assert!(md.contains("- [ ] Normal task"), "got: {md}");
5759 assert!(!md.contains("adf-unsupported"), "got: {md}");
5761 assert!(md.contains(" - [ ] Nested task one"), "got: {md}");
5762 assert!(md.contains(" - [x] Nested task two"), "got: {md}");
5763 }
5764
5765 #[test]
5767 fn round_trip_nested_task_item() {
5768 let json = r#"{
5769 "type": "doc",
5770 "version": 1,
5771 "content": [{
5772 "type": "taskList",
5773 "attrs": {"localId": ""},
5774 "content": [
5775 {
5776 "type": "taskItem",
5777 "attrs": {"localId": "task-001", "state": "TODO"},
5778 "content": [{"type": "text", "text": "Normal task"}]
5779 },
5780 {
5781 "type": "taskItem",
5782 "attrs": {"localId": ""},
5783 "content": [
5784 {
5785 "type": "taskItem",
5786 "attrs": {"localId": "task-002", "state": "TODO"},
5787 "content": [{"type": "text", "text": "Nested one"}]
5788 },
5789 {
5790 "type": "taskItem",
5791 "attrs": {"localId": "task-003", "state": "DONE"},
5792 "content": [{"type": "text", "text": "Nested two"}]
5793 }
5794 ]
5795 }
5796 ]
5797 }]
5798 }"#;
5799 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5800 let md = adf_to_markdown(&doc).unwrap();
5801 let doc2 = markdown_to_adf(&md).unwrap();
5802
5803 assert_eq!(doc2.content[0].node_type, "taskList");
5805 let items = doc2.content[0].content.as_ref().unwrap();
5806 assert_eq!(items.len(), 2, "expected 2 top-level items, got: {items:?}");
5807
5808 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5810 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5811 let first_content = items[0].content.as_ref().unwrap();
5812 assert_eq!(first_content[0].text.as_deref(), Some("Normal task"));
5813
5814 let container = &items[1];
5816 assert_eq!(container.node_type, "taskItem");
5817 let c_attrs = container.attrs.as_ref().unwrap();
5818 assert!(
5819 c_attrs.get("state").is_none(),
5820 "container should have no state attr, got: {c_attrs:?}"
5821 );
5822
5823 let container_content = container.content.as_ref().unwrap();
5825 assert_eq!(
5826 container_content.len(),
5827 2,
5828 "expected 2 bare taskItem children"
5829 );
5830 assert_eq!(container_content[0].node_type, "taskItem");
5831 assert_eq!(
5832 container_content[0].attrs.as_ref().unwrap()["state"],
5833 "TODO"
5834 );
5835 assert_eq!(
5836 container_content[0].attrs.as_ref().unwrap()["localId"],
5837 "task-002"
5838 );
5839 assert_eq!(container_content[1].node_type, "taskItem");
5840 assert_eq!(
5841 container_content[1].attrs.as_ref().unwrap()["state"],
5842 "DONE"
5843 );
5844 assert_eq!(
5845 container_content[1].attrs.as_ref().unwrap()["localId"],
5846 "task-003"
5847 );
5848 }
5849
5850 #[test]
5852 fn adf_nested_task_item_preserves_local_ids() {
5853 let json = r#"{
5854 "type": "doc",
5855 "version": 1,
5856 "content": [{
5857 "type": "taskList",
5858 "attrs": {"localId": "list-001"},
5859 "content": [{
5860 "type": "taskItem",
5861 "attrs": {"localId": "container-001", "state": "TODO"},
5862 "content": [{
5863 "type": "taskItem",
5864 "attrs": {"localId": "child-001", "state": "DONE"},
5865 "content": [{"type": "text", "text": "Nested child"}]
5866 }]
5867 }]
5868 }]
5869 }"#;
5870 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5871 let md = adf_to_markdown(&doc).unwrap();
5872 assert!(
5874 md.contains("localId=container-001"),
5875 "container localId missing: {md}"
5876 );
5877 assert!(
5879 md.contains("localId=child-001"),
5880 "child localId missing: {md}"
5881 );
5882 assert!(!md.contains("adf-unsupported"), "got: {md}");
5883 }
5884
5885 #[test]
5888 fn adf_nested_task_item_mixed_with_block_node() {
5889 let json = r#"{
5890 "type": "doc",
5891 "version": 1,
5892 "content": [{
5893 "type": "taskList",
5894 "attrs": {"localId": ""},
5895 "content": [{
5896 "type": "taskItem",
5897 "attrs": {"localId": "", "state": "TODO"},
5898 "content": [
5899 {
5900 "type": "taskItem",
5901 "attrs": {"localId": "", "state": "TODO"},
5902 "content": [{"type": "text", "text": "A nested task"}]
5903 },
5904 {
5905 "type": "paragraph",
5906 "content": [{"type": "text", "text": "Stray paragraph"}]
5907 }
5908 ]
5909 }]
5910 }]
5911 }"#;
5912 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5913 let md = adf_to_markdown(&doc).unwrap();
5914 assert!(md.contains(" - [ ] A nested task"), "got: {md}");
5915 assert!(md.contains(" Stray paragraph"), "got: {md}");
5916 assert!(!md.contains("adf-unsupported"), "got: {md}");
5917 }
5918
5919 #[test]
5923 fn task_item_with_text_and_nested_sub_content() {
5924 let md = "- [ ] Parent task\n - [ ] Sub task\n";
5925 let doc = markdown_to_adf(md).unwrap();
5926 assert_eq!(doc.content[0].node_type, "taskList");
5927 let items = doc.content[0].content.as_ref().unwrap();
5928 assert_eq!(items.len(), 2, "got: {items:?}");
5931 let parent = &items[0];
5932 assert_eq!(parent.attrs.as_ref().unwrap()["state"], "TODO");
5933 let parent_content = parent.content.as_ref().unwrap();
5934 assert_eq!(parent_content[0].text.as_deref(), Some("Parent task"));
5935 assert_eq!(items[1].node_type, "taskList");
5937 let nested = items[1].content.as_ref().unwrap();
5938 assert_eq!(nested.len(), 1);
5939 assert_eq!(nested[0].attrs.as_ref().unwrap()["state"], "TODO");
5940 }
5941
5942 #[test]
5946 fn task_item_empty_with_non_tasklist_sub_content() {
5947 let md = "- [ ] \n Some paragraph text\n";
5948 let doc = markdown_to_adf(md).unwrap();
5949 assert_eq!(doc.content[0].node_type, "taskList");
5950 let items = doc.content[0].content.as_ref().unwrap();
5951 assert_eq!(items.len(), 1);
5952 let item = &items[0];
5953 assert_eq!(item.attrs.as_ref().unwrap()["state"], "TODO");
5954 let content = item.content.as_ref().unwrap();
5955 assert_eq!(content[0].node_type, "paragraph");
5957 }
5958
5959 #[test]
5961 fn adf_nested_task_item_single_child() {
5962 let json = r#"{
5963 "type": "doc",
5964 "version": 1,
5965 "content": [{
5966 "type": "taskList",
5967 "attrs": {"localId": ""},
5968 "content": [{
5969 "type": "taskItem",
5970 "attrs": {"localId": "", "state": "TODO"},
5971 "content": [{
5972 "type": "taskItem",
5973 "attrs": {"localId": "", "state": "DONE"},
5974 "content": [{"type": "text", "text": "Only child"}]
5975 }]
5976 }]
5977 }]
5978 }"#;
5979 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5980 let md = adf_to_markdown(&doc).unwrap();
5981 assert!(md.contains(" - [x] Only child"), "got: {md}");
5982 assert!(!md.contains("adf-unsupported"), "got: {md}");
5983 }
5984
5985 #[test]
5988 fn adf_nested_tasklist_sibling_renders_indented() {
5989 let json = r#"{
5990 "version": 1,
5991 "type": "doc",
5992 "content": [{
5993 "type": "taskList",
5994 "attrs": {"localId": ""},
5995 "content": [
5996 {
5997 "type": "taskItem",
5998 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000001", "state": "TODO"},
5999 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
6000 },
6001 {
6002 "type": "taskList",
6003 "attrs": {"localId": ""},
6004 "content": [{
6005 "type": "taskItem",
6006 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000002", "state": "TODO"},
6007 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
6008 }]
6009 },
6010 {
6011 "type": "taskItem",
6012 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000003", "state": "TODO"},
6013 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
6014 }
6015 ]
6016 }]
6017 }"#;
6018 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6019 let md = adf_to_markdown(&doc).unwrap();
6020 assert!(md.contains("- [ ] parent task one"), "got: {md}");
6022 assert!(md.contains(" - [ ] nested sub-task"), "got: {md}");
6023 assert!(md.contains("- [ ] parent task two"), "got: {md}");
6024 }
6025
6026 #[test]
6028 fn round_trip_nested_tasklist_preserves_type() {
6029 let json = r#"{
6030 "version": 1,
6031 "type": "doc",
6032 "content": [{
6033 "type": "taskList",
6034 "attrs": {"localId": ""},
6035 "content": [
6036 {
6037 "type": "taskItem",
6038 "attrs": {"localId": "", "state": "TODO"},
6039 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
6040 },
6041 {
6042 "type": "taskList",
6043 "attrs": {"localId": ""},
6044 "content": [{
6045 "type": "taskItem",
6046 "attrs": {"localId": "", "state": "TODO"},
6047 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
6048 }]
6049 },
6050 {
6051 "type": "taskItem",
6052 "attrs": {"localId": "", "state": "TODO"},
6053 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
6054 }
6055 ]
6056 }]
6057 }"#;
6058 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6059 let md = adf_to_markdown(&doc).unwrap();
6060 let rt_doc = markdown_to_adf(&md).unwrap();
6061 assert_eq!(rt_doc.content[0].node_type, "taskList");
6063 let items = rt_doc.content[0].content.as_ref().unwrap();
6064 assert_eq!(items.len(), 3, "got: {items:?}");
6067 assert_eq!(items[0].node_type, "taskItem");
6068 assert_eq!(
6069 items[1].node_type, "taskList",
6070 "nested taskList should survive round-trip"
6071 );
6072 assert_eq!(items[2].node_type, "taskItem");
6073 let nested_items = items[1].content.as_ref().unwrap();
6074 assert_eq!(nested_items[0].attrs.as_ref().unwrap()["state"], "TODO");
6075 }
6076
6077 #[test]
6079 fn adf_nested_tasklist_done_state() {
6080 let json = r#"{
6081 "version": 1,
6082 "type": "doc",
6083 "content": [{
6084 "type": "taskList",
6085 "attrs": {"localId": ""},
6086 "content": [
6087 {
6088 "type": "taskItem",
6089 "attrs": {"localId": "", "state": "TODO"},
6090 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
6091 },
6092 {
6093 "type": "taskList",
6094 "attrs": {"localId": ""},
6095 "content": [{
6096 "type": "taskItem",
6097 "attrs": {"localId": "", "state": "DONE"},
6098 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "done child"}]}]
6099 }]
6100 }
6101 ]
6102 }]
6103 }"#;
6104 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6105 let md = adf_to_markdown(&doc).unwrap();
6106 assert!(md.contains(" - [x] done child"), "got: {md}");
6107 let rt_doc = markdown_to_adf(&md).unwrap();
6109 let items = rt_doc.content[0].content.as_ref().unwrap();
6110 assert_eq!(
6111 items[1].node_type, "taskList",
6112 "nested taskList should survive round-trip"
6113 );
6114 let nested_item = &items[1].content.as_ref().unwrap()[0];
6115 assert_eq!(nested_item.attrs.as_ref().unwrap()["state"], "DONE");
6116 }
6117
6118 #[test]
6120 fn adf_multiple_nested_tasklists() {
6121 let json = r#"{
6122 "version": 1,
6123 "type": "doc",
6124 "content": [{
6125 "type": "taskList",
6126 "attrs": {"localId": ""},
6127 "content": [
6128 {
6129 "type": "taskItem",
6130 "attrs": {"localId": "", "state": "TODO"},
6131 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "first parent"}]}]
6132 },
6133 {
6134 "type": "taskList",
6135 "attrs": {"localId": ""},
6136 "content": [{
6137 "type": "taskItem",
6138 "attrs": {"localId": "", "state": "TODO"},
6139 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child A"}]}]
6140 }]
6141 },
6142 {
6143 "type": "taskItem",
6144 "attrs": {"localId": "", "state": "TODO"},
6145 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "second parent"}]}]
6146 },
6147 {
6148 "type": "taskList",
6149 "attrs": {"localId": ""},
6150 "content": [{
6151 "type": "taskItem",
6152 "attrs": {"localId": "", "state": "DONE"},
6153 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child B"}]}]
6154 }]
6155 }
6156 ]
6157 }]
6158 }"#;
6159 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6160 let md = adf_to_markdown(&doc).unwrap();
6161 assert!(md.contains("- [ ] first parent"), "got: {md}");
6162 assert!(md.contains(" - [ ] child A"), "got: {md}");
6163 assert!(md.contains("- [ ] second parent"), "got: {md}");
6164 assert!(md.contains(" - [x] child B"), "got: {md}");
6165 }
6166
6167 #[test]
6170 fn round_trip_nested_tasklist_stable() {
6171 let json = r#"{
6172 "version": 1,
6173 "type": "doc",
6174 "content": [{
6175 "type": "taskList",
6176 "attrs": {"localId": ""},
6177 "content": [
6178 {
6179 "type": "taskItem",
6180 "attrs": {"localId": "", "state": "TODO"},
6181 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
6182 },
6183 {
6184 "type": "taskList",
6185 "attrs": {"localId": ""},
6186 "content": [{
6187 "type": "taskItem",
6188 "attrs": {"localId": "", "state": "TODO"},
6189 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child"}]}]
6190 }]
6191 }
6192 ]
6193 }]
6194 }"#;
6195 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6196 let md1 = adf_to_markdown(&doc).unwrap();
6198 let rt1 = markdown_to_adf(&md1).unwrap();
6199 let md2 = adf_to_markdown(&rt1).unwrap();
6201 let rt2 = markdown_to_adf(&md2).unwrap();
6202 assert_eq!(md1, md2, "markdown should be stable across round-trips");
6204 let rt1_json = serde_json::to_string(&rt1).unwrap();
6206 let rt2_json = serde_json::to_string(&rt2).unwrap();
6207 assert_eq!(
6208 rt1_json, rt2_json,
6209 "ADF should be stable across round-trips"
6210 );
6211 }
6212
6213 #[test]
6218 fn task_item_mixed_sub_content_splits_siblings() {
6219 let md = "- [ ] Parent task\n - [ ] Sub task\n Some paragraph\n";
6220 let doc = markdown_to_adf(md).unwrap();
6221 let items = doc.content[0].content.as_ref().unwrap();
6222 assert_eq!(items.len(), 2, "got: {items:?}");
6224 assert_eq!(items[0].node_type, "taskItem");
6225 let parent_content = items[0].content.as_ref().unwrap();
6226 assert!(
6228 parent_content.iter().any(|n| n.node_type == "paragraph"),
6229 "non-taskList sub-content should stay as child: {parent_content:?}"
6230 );
6231 assert_eq!(items[1].node_type, "taskList");
6233 }
6234
6235 #[test]
6239 fn empty_task_item_mixed_sub_content_none_arm() {
6240 let md = "- [ ] \n Some paragraph\n - [ ] Sub task\n";
6241 let doc = markdown_to_adf(md).unwrap();
6242 let items = doc.content[0].content.as_ref().unwrap();
6243 assert_eq!(items.len(), 2, "got: {items:?}");
6245 assert_eq!(items[0].node_type, "taskItem");
6246 let parent_content = items[0].content.as_ref().unwrap();
6247 assert!(
6248 parent_content.iter().any(|n| n.node_type == "paragraph"),
6249 "paragraph should be assigned to taskItem: {parent_content:?}"
6250 );
6251 assert_eq!(items[1].node_type, "taskList");
6252 }
6253
6254 #[test]
6259 fn task_item_text_with_non_tasklist_sub_content_only() {
6260 let md = "- [ ] My task\n Extra paragraph content\n";
6261 let doc = markdown_to_adf(md).unwrap();
6262 let items = doc.content[0].content.as_ref().unwrap();
6263 assert_eq!(items.len(), 1, "got: {items:?}");
6265 assert_eq!(items[0].node_type, "taskItem");
6266 let content = items[0].content.as_ref().unwrap();
6267 assert!(
6269 content.iter().any(|n| n.node_type == "paragraph"),
6270 "paragraph sub-content should be a child of taskItem: {content:?}"
6271 );
6272 }
6273
6274 #[test]
6277 fn adf_list_item_leading_block_node() {
6278 let json = r#"{
6279 "version": 1,
6280 "type": "doc",
6281 "content": [{
6282 "type": "bulletList",
6283 "content": [{
6284 "type": "listItem",
6285 "content": [{
6286 "type": "codeBlock",
6287 "attrs": {"language": "rust"},
6288 "content": [{"type": "text", "text": "let x = 1;"}]
6289 }]
6290 }]
6291 }]
6292 }"#;
6293 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6294 let md = adf_to_markdown(&doc).unwrap();
6295 assert!(md.contains("```rust"), "got: {md}");
6296 assert!(md.contains("let x = 1;"), "got: {md}");
6297 for line in md.lines() {
6300 if line.starts_with("- ") {
6301 continue; }
6303 if line.trim().is_empty() {
6304 continue;
6305 }
6306 assert!(
6307 line.starts_with(" "),
6308 "continuation line not indented: {line:?}"
6309 );
6310 }
6311 }
6312
6313 #[test]
6316 fn code_block_in_list_item_backtick_roundtrip() {
6317 let json = r#"{
6318 "version": 1,
6319 "type": "doc",
6320 "content": [{
6321 "type": "bulletList",
6322 "content": [{
6323 "type": "listItem",
6324 "content": [{
6325 "type": "codeBlock",
6326 "attrs": {"language": ""},
6327 "content": [{"type": "text", "text": "error: some value with a backtick ` at end"}]
6328 }]
6329 }]
6330 }]
6331 }"#;
6332 let original: AdfDocument = serde_json::from_str(json).unwrap();
6333 let md = adf_to_markdown(&original).unwrap();
6334 let roundtripped = markdown_to_adf(&md).unwrap();
6335 let list = &roundtripped.content[0];
6336 assert_eq!(list.node_type, "bulletList", "top node: {}", list.node_type);
6337 let item = &list.content.as_ref().unwrap()[0];
6338 let first_child = &item.content.as_ref().unwrap()[0];
6339 assert_eq!(
6340 first_child.node_type, "codeBlock",
6341 "expected codeBlock, got: {}",
6342 first_child.node_type
6343 );
6344 let text = first_child.content.as_ref().unwrap()[0]
6345 .text
6346 .as_deref()
6347 .unwrap();
6348 assert_eq!(text, "error: some value with a backtick ` at end");
6349 }
6350
6351 #[test]
6353 fn code_block_with_language_in_list_item_roundtrip() {
6354 let json = r#"{
6355 "version": 1,
6356 "type": "doc",
6357 "content": [{
6358 "type": "bulletList",
6359 "content": [{
6360 "type": "listItem",
6361 "content": [{
6362 "type": "codeBlock",
6363 "attrs": {"language": "rust"},
6364 "content": [{"type": "text", "text": "fn main() {\n println!(\"hello\");\n}"}]
6365 }]
6366 }]
6367 }]
6368 }"#;
6369 let original: AdfDocument = serde_json::from_str(json).unwrap();
6370 let md = adf_to_markdown(&original).unwrap();
6371 let roundtripped = markdown_to_adf(&md).unwrap();
6372 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6373 let code = &item.content.as_ref().unwrap()[0];
6374 assert_eq!(code.node_type, "codeBlock");
6375 let lang = code
6376 .attrs
6377 .as_ref()
6378 .and_then(|a| a.get("language"))
6379 .and_then(serde_json::Value::as_str)
6380 .unwrap_or("");
6381 assert_eq!(lang, "rust");
6382 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6383 assert!(text.contains("println!"), "code content: {text}");
6384 }
6385
6386 #[test]
6388 fn code_block_in_ordered_list_item_roundtrip() {
6389 let json = r#"{
6390 "version": 1,
6391 "type": "doc",
6392 "content": [{
6393 "type": "orderedList",
6394 "attrs": {"order": 1},
6395 "content": [{
6396 "type": "listItem",
6397 "content": [{
6398 "type": "codeBlock",
6399 "attrs": {"language": ""},
6400 "content": [{"type": "text", "text": "backtick ` here"}]
6401 }]
6402 }]
6403 }]
6404 }"#;
6405 let original: AdfDocument = serde_json::from_str(json).unwrap();
6406 let md = adf_to_markdown(&original).unwrap();
6407 let roundtripped = markdown_to_adf(&md).unwrap();
6408 let list = &roundtripped.content[0];
6409 assert_eq!(list.node_type, "orderedList");
6410 let item = &list.content.as_ref().unwrap()[0];
6411 let code = &item.content.as_ref().unwrap()[0];
6412 assert_eq!(code.node_type, "codeBlock");
6413 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6414 assert_eq!(text, "backtick ` here");
6415 }
6416
6417 #[test]
6419 fn code_block_then_paragraph_in_list_item() {
6420 let json = r#"{
6421 "version": 1,
6422 "type": "doc",
6423 "content": [{
6424 "type": "bulletList",
6425 "content": [{
6426 "type": "listItem",
6427 "content": [
6428 {
6429 "type": "codeBlock",
6430 "attrs": {"language": ""},
6431 "content": [{"type": "text", "text": "code with ` backtick"}]
6432 },
6433 {
6434 "type": "paragraph",
6435 "content": [{"type": "text", "text": "description"}]
6436 }
6437 ]
6438 }]
6439 }]
6440 }"#;
6441 let original: AdfDocument = serde_json::from_str(json).unwrap();
6442 let md = adf_to_markdown(&original).unwrap();
6443 let roundtripped = markdown_to_adf(&md).unwrap();
6444 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6445 let children = item.content.as_ref().unwrap();
6446 assert_eq!(children[0].node_type, "codeBlock");
6447 assert_eq!(children[1].node_type, "paragraph");
6448 }
6449
6450 #[test]
6452 fn code_block_multiple_backticks_in_list_item() {
6453 let json = r#"{
6454 "version": 1,
6455 "type": "doc",
6456 "content": [{
6457 "type": "bulletList",
6458 "content": [{
6459 "type": "listItem",
6460 "content": [{
6461 "type": "codeBlock",
6462 "attrs": {"language": ""},
6463 "content": [{"type": "text", "text": "a ` b `` c ``` d"}]
6464 }]
6465 }]
6466 }]
6467 }"#;
6468 let original: AdfDocument = serde_json::from_str(json).unwrap();
6469 let md = adf_to_markdown(&original).unwrap();
6470 let roundtripped = markdown_to_adf(&md).unwrap();
6471 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6472 let code = &item.content.as_ref().unwrap()[0];
6473 assert_eq!(code.node_type, "codeBlock");
6474 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6475 assert_eq!(text, "a ` b `` c ``` d");
6476 }
6477
6478 #[test]
6481 fn media_first_child_with_sub_content_in_list_item() {
6482 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
6483 {"type":"listItem","content":[
6484 {"type":"mediaSingle","attrs":{"layout":"center"},
6485 "content":[{"type":"media","attrs":{"type":"file","id":"img-99","collection":"col-x","height":50,"width":100}}]},
6486 {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
6487 ]}
6488 ]}]}"#;
6489 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6490 let md = adf_to_markdown(&doc).unwrap();
6491 let rt = markdown_to_adf(&md).unwrap();
6492 let item = &rt.content[0].content.as_ref().unwrap()[0];
6493 let children = item.content.as_ref().unwrap();
6494 assert_eq!(
6495 children.len(),
6496 2,
6497 "expected 2 children, got {}",
6498 children.len()
6499 );
6500 assert_eq!(children[0].node_type, "mediaSingle");
6501 let media = &children[0].content.as_ref().unwrap()[0];
6502 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-99");
6503 assert_eq!(children[1].node_type, "paragraph");
6504 }
6505
6506 #[test]
6507 fn inline_bold() {
6508 let doc = markdown_to_adf("Some **bold** text").unwrap();
6509 let content = doc.content[0].content.as_ref().unwrap();
6510 assert!(content.len() >= 3);
6511 let bold_node = &content[1];
6512 assert_eq!(bold_node.text.as_deref(), Some("bold"));
6513 let marks = bold_node.marks.as_ref().unwrap();
6514 assert_eq!(marks[0].mark_type, "strong");
6515 }
6516
6517 #[test]
6518 fn inline_italic() {
6519 let doc = markdown_to_adf("Some *italic* text").unwrap();
6520 let content = doc.content[0].content.as_ref().unwrap();
6521 let italic_node = &content[1];
6522 assert_eq!(italic_node.text.as_deref(), Some("italic"));
6523 let marks = italic_node.marks.as_ref().unwrap();
6524 assert_eq!(marks[0].mark_type, "em");
6525 }
6526
6527 #[test]
6528 fn inline_code() {
6529 let doc = markdown_to_adf("Use `code` here").unwrap();
6530 let content = doc.content[0].content.as_ref().unwrap();
6531 let code_node = &content[1];
6532 assert_eq!(code_node.text.as_deref(), Some("code"));
6533 let marks = code_node.marks.as_ref().unwrap();
6534 assert_eq!(marks[0].mark_type, "code");
6535 }
6536
6537 #[test]
6541 fn inline_code_with_backtick_emitted_with_double_delimiters() {
6542 let doc = AdfDocument {
6543 version: 1,
6544 doc_type: "doc".to_string(),
6545 content: vec![AdfNode::paragraph(vec![
6546 AdfNode::text("Run "),
6547 AdfNode::text_with_marks(
6548 "ADD `custom_threshold` TEXT NOT NULL",
6549 vec![AdfMark::code()],
6550 ),
6551 AdfNode::text(" to update the schema."),
6552 ])],
6553 };
6554 let md = adf_to_markdown(&doc).unwrap();
6555 assert!(
6556 md.contains("``ADD `custom_threshold` TEXT NOT NULL``"),
6557 "expected double-backtick delimiters, got: {md}"
6558 );
6559 }
6560
6561 #[test]
6564 fn inline_code_double_backtick_delimiters_parse() {
6565 let doc = markdown_to_adf("Run ``ADD `custom_threshold` TEXT NOT NULL`` now").unwrap();
6566 let content = doc.content[0].content.as_ref().unwrap();
6567 assert_eq!(content.len(), 3, "content: {content:?}");
6568 let code_node = &content[1];
6569 assert_eq!(
6570 code_node.text.as_deref(),
6571 Some("ADD `custom_threshold` TEXT NOT NULL")
6572 );
6573 let marks = code_node.marks.as_ref().unwrap();
6574 assert_eq!(marks[0].mark_type, "code");
6575 }
6576
6577 #[test]
6580 fn inline_code_with_backtick_roundtrip() {
6581 let json = r#"{
6582 "version": 1,
6583 "type": "doc",
6584 "content": [{
6585 "type": "paragraph",
6586 "content": [
6587 {"type": "text", "text": "Run "},
6588 {
6589 "type": "text",
6590 "text": "ADD `custom_threshold` TEXT NOT NULL",
6591 "marks": [{"type": "code"}]
6592 },
6593 {"type": "text", "text": " to update the schema."}
6594 ]
6595 }]
6596 }"#;
6597 let original: AdfDocument = serde_json::from_str(json).unwrap();
6598 let md = adf_to_markdown(&original).unwrap();
6599 let roundtripped = markdown_to_adf(&md).unwrap();
6600 let para = &roundtripped.content[0];
6601 let children = para.content.as_ref().unwrap();
6602 assert_eq!(children.len(), 3, "expected 3 children, got: {children:?}");
6603 assert_eq!(children[0].text.as_deref(), Some("Run "));
6604 assert_eq!(
6605 children[1].text.as_deref(),
6606 Some("ADD `custom_threshold` TEXT NOT NULL")
6607 );
6608 let marks = children[1].marks.as_ref().unwrap();
6609 assert_eq!(marks.len(), 1);
6610 assert_eq!(marks[0].mark_type, "code");
6611 assert_eq!(children[2].text.as_deref(), Some(" to update the schema."));
6612 }
6613
6614 #[test]
6619 fn inline_code_with_double_backtick_roundtrip() {
6620 let doc = AdfDocument {
6621 version: 1,
6622 doc_type: "doc".to_string(),
6623 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6624 "x `` y",
6625 vec![AdfMark::code()],
6626 )])],
6627 };
6628 let md = adf_to_markdown(&doc).unwrap();
6629 let roundtripped = markdown_to_adf(&md).unwrap();
6630 let content = roundtripped.content[0].content.as_ref().unwrap();
6631 assert_eq!(content.len(), 1);
6632 assert_eq!(content[0].text.as_deref(), Some("x `` y"));
6633 let marks = content[0].marks.as_ref().unwrap();
6634 assert_eq!(marks[0].mark_type, "code");
6635 }
6636
6637 #[test]
6640 fn inline_code_leading_backtick_roundtrip() {
6641 let doc = AdfDocument {
6642 version: 1,
6643 doc_type: "doc".to_string(),
6644 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6645 "`start",
6646 vec![AdfMark::code()],
6647 )])],
6648 };
6649 let md = adf_to_markdown(&doc).unwrap();
6650 let roundtripped = markdown_to_adf(&md).unwrap();
6651 let content = roundtripped.content[0].content.as_ref().unwrap();
6652 assert_eq!(content[0].text.as_deref(), Some("`start"));
6653 assert_eq!(content[0].marks.as_ref().unwrap()[0].mark_type, "code");
6654 }
6655
6656 #[test]
6658 fn inline_code_trailing_backtick_roundtrip() {
6659 let doc = AdfDocument {
6660 version: 1,
6661 doc_type: "doc".to_string(),
6662 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6663 "end`",
6664 vec![AdfMark::code()],
6665 )])],
6666 };
6667 let md = adf_to_markdown(&doc).unwrap();
6668 let roundtripped = markdown_to_adf(&md).unwrap();
6669 let content = roundtripped.content[0].content.as_ref().unwrap();
6670 assert_eq!(content[0].text.as_deref(), Some("end`"));
6671 }
6672
6673 #[test]
6676 fn inline_code_space_padded_content_roundtrip() {
6677 let doc = AdfDocument {
6678 version: 1,
6679 doc_type: "doc".to_string(),
6680 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6681 " foo ",
6682 vec![AdfMark::code()],
6683 )])],
6684 };
6685 let md = adf_to_markdown(&doc).unwrap();
6686 let roundtripped = markdown_to_adf(&md).unwrap();
6687 let content = roundtripped.content[0].content.as_ref().unwrap();
6688 assert_eq!(content[0].text.as_deref(), Some(" foo "));
6689 }
6690
6691 #[test]
6694 fn inline_code_all_spaces_roundtrip() {
6695 let doc = AdfDocument {
6696 version: 1,
6697 doc_type: "doc".to_string(),
6698 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6699 " ",
6700 vec![AdfMark::code()],
6701 )])],
6702 };
6703 let md = adf_to_markdown(&doc).unwrap();
6704 let roundtripped = markdown_to_adf(&md).unwrap();
6705 let content = roundtripped.content[0].content.as_ref().unwrap();
6706 assert_eq!(content[0].text.as_deref(), Some(" "));
6707 }
6708
6709 #[test]
6712 fn inline_code_with_link_and_backtick_roundtrip() {
6713 let doc = AdfDocument {
6714 version: 1,
6715 doc_type: "doc".to_string(),
6716 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6717 "fn `inner`",
6718 vec![AdfMark::code(), AdfMark::link("https://example.com")],
6719 )])],
6720 };
6721 let md = adf_to_markdown(&doc).unwrap();
6722 assert!(
6723 md.contains("`` fn `inner` ``"),
6724 "expected padded double-backtick delimiters inside link, got: {md}"
6725 );
6726 let roundtripped = markdown_to_adf(&md).unwrap();
6727 let content = roundtripped.content[0].content.as_ref().unwrap();
6728 assert_eq!(content[0].text.as_deref(), Some("fn `inner`"));
6729 let mark_types: Vec<&str> = content[0]
6730 .marks
6731 .as_ref()
6732 .unwrap()
6733 .iter()
6734 .map(|m| m.mark_type.as_str())
6735 .collect();
6736 assert!(mark_types.contains(&"code"));
6737 assert!(mark_types.contains(&"link"));
6738 }
6739
6740 #[test]
6742 fn inline_code_unmatched_run_is_plain_text() {
6743 let doc = markdown_to_adf("foo ``bar baz").unwrap();
6744 let content = doc.content[0].content.as_ref().unwrap();
6745 assert_eq!(content.len(), 1);
6746 assert_eq!(content[0].text.as_deref(), Some("foo ``bar baz"));
6747 assert!(content[0].marks.is_none());
6748 }
6749
6750 #[test]
6754 fn inline_code_mismatched_delimiters_is_plain_text() {
6755 let doc = markdown_to_adf("``foo` bar").unwrap();
6756 let content = doc.content[0].content.as_ref().unwrap();
6757 assert_eq!(content.len(), 1);
6758 assert_eq!(content[0].text.as_deref(), Some("``foo` bar"));
6759 assert!(content[0].marks.is_none());
6760 }
6761
6762 #[test]
6763 fn inline_code_delimiter_chooses_correct_length() {
6764 assert_eq!(inline_code_delimiter("no ticks"), (1, false));
6765 assert_eq!(inline_code_delimiter("one ` here"), (2, false));
6766 assert_eq!(inline_code_delimiter("two `` here"), (3, false));
6767 assert_eq!(inline_code_delimiter("three ``` here"), (4, false));
6768 assert_eq!(inline_code_delimiter("`leading"), (2, true));
6769 assert_eq!(inline_code_delimiter("trailing`"), (2, true));
6770 assert_eq!(inline_code_delimiter(" foo "), (1, true));
6771 assert_eq!(inline_code_delimiter(" "), (1, false));
6772 assert_eq!(inline_code_delimiter(" "), (1, false));
6773 assert_eq!(inline_code_delimiter(" foo"), (1, false));
6774 }
6775
6776 #[test]
6777 fn try_parse_inline_code_strips_paired_spaces() {
6778 let (end, content) = try_parse_inline_code("`` `foo` ``", 0).unwrap();
6779 assert_eq!(end, 11);
6780 assert_eq!(content, "`foo`");
6781 }
6782
6783 #[test]
6784 fn try_parse_inline_code_all_space_content_is_preserved() {
6785 let (_end, content) = try_parse_inline_code("` `", 0).unwrap();
6786 assert_eq!(content, " ");
6787 }
6788
6789 #[test]
6790 fn try_parse_inline_code_single_run_matches_first_close() {
6791 let (end, content) = try_parse_inline_code("`foo` tail", 0).unwrap();
6792 assert_eq!(end, 5);
6793 assert_eq!(content, "foo");
6794 }
6795
6796 #[test]
6797 fn try_parse_inline_code_no_match_returns_none() {
6798 assert!(try_parse_inline_code("``unmatched", 0).is_none());
6799 assert!(try_parse_inline_code("plain text", 0).is_none());
6800 }
6801
6802 #[test]
6803 fn is_code_fence_opener_rejects_info_with_backtick() {
6804 assert!(is_code_fence_opener("```"));
6805 assert!(is_code_fence_opener("```rust"));
6806 assert!(is_code_fence_opener("```\"\""));
6807 assert!(!is_code_fence_opener("```x `` y```"));
6808 assert!(!is_code_fence_opener("``not-enough"));
6809 assert!(!is_code_fence_opener("no fence"));
6810 }
6811
6812 #[test]
6813 fn inline_strikethrough() {
6814 let doc = markdown_to_adf("Some ~~deleted~~ text").unwrap();
6815 let content = doc.content[0].content.as_ref().unwrap();
6816 let strike_node = &content[1];
6817 assert_eq!(strike_node.text.as_deref(), Some("deleted"));
6818 let marks = strike_node.marks.as_ref().unwrap();
6819 assert_eq!(marks[0].mark_type, "strike");
6820 }
6821
6822 #[test]
6823 fn inline_link() {
6824 let doc = markdown_to_adf("Click [here](https://example.com) now").unwrap();
6825 let content = doc.content[0].content.as_ref().unwrap();
6826 let link_node = &content[1];
6827 assert_eq!(link_node.text.as_deref(), Some("here"));
6828 let marks = link_node.marks.as_ref().unwrap();
6829 assert_eq!(marks[0].mark_type, "link");
6830 }
6831
6832 #[test]
6833 fn block_image() {
6834 let md = "";
6835 let doc = markdown_to_adf(md).unwrap();
6836 assert_eq!(doc.content[0].node_type, "mediaSingle");
6837 }
6838
6839 #[test]
6840 fn table() {
6841 let md = "| A | B |\n| --- | --- |\n| 1 | 2 |";
6842 let doc = markdown_to_adf(md).unwrap();
6843 assert_eq!(doc.content[0].node_type, "table");
6844 let rows = doc.content[0].content.as_ref().unwrap();
6845 assert_eq!(rows.len(), 2); }
6847
6848 #[test]
6851 fn adf_paragraph_to_markdown() {
6852 let doc = AdfDocument {
6853 version: 1,
6854 doc_type: "doc".to_string(),
6855 content: vec![AdfNode::paragraph(vec![AdfNode::text("Hello world")])],
6856 };
6857 let md = adf_to_markdown(&doc).unwrap();
6858 assert_eq!(md.trim(), "Hello world");
6859 }
6860
6861 #[test]
6862 fn adf_heading_to_markdown() {
6863 let doc = AdfDocument {
6864 version: 1,
6865 doc_type: "doc".to_string(),
6866 content: vec![AdfNode::heading(2, vec![AdfNode::text("Title")])],
6867 };
6868 let md = adf_to_markdown(&doc).unwrap();
6869 assert_eq!(md.trim(), "## Title");
6870 }
6871
6872 #[test]
6873 fn adf_bold_to_markdown() {
6874 let doc = AdfDocument {
6875 version: 1,
6876 doc_type: "doc".to_string(),
6877 content: vec![AdfNode::paragraph(vec![
6878 AdfNode::text("Normal "),
6879 AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
6880 AdfNode::text(" text"),
6881 ])],
6882 };
6883 let md = adf_to_markdown(&doc).unwrap();
6884 assert_eq!(md.trim(), "Normal **bold** text");
6885 }
6886
6887 #[test]
6888 fn adf_code_block_to_markdown() {
6889 let doc = AdfDocument {
6890 version: 1,
6891 doc_type: "doc".to_string(),
6892 content: vec![AdfNode::code_block(Some("rust"), "let x = 1;")],
6893 };
6894 let md = adf_to_markdown(&doc).unwrap();
6895 assert!(md.contains("```rust"));
6896 assert!(md.contains("let x = 1;"));
6897 assert!(md.contains("```"));
6898 }
6899
6900 #[test]
6901 fn adf_rule_to_markdown() {
6902 let doc = AdfDocument {
6903 version: 1,
6904 doc_type: "doc".to_string(),
6905 content: vec![AdfNode::rule()],
6906 };
6907 let md = adf_to_markdown(&doc).unwrap();
6908 assert!(md.contains("---"));
6909 }
6910
6911 #[test]
6912 fn adf_bullet_list_to_markdown() {
6913 let doc = AdfDocument {
6914 version: 1,
6915 doc_type: "doc".to_string(),
6916 content: vec![AdfNode::bullet_list(vec![
6917 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("A")])]),
6918 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("B")])]),
6919 ])],
6920 };
6921 let md = adf_to_markdown(&doc).unwrap();
6922 assert!(md.contains("- A"));
6923 assert!(md.contains("- B"));
6924 }
6925
6926 #[test]
6927 fn adf_link_to_markdown() {
6928 let doc = AdfDocument {
6929 version: 1,
6930 doc_type: "doc".to_string(),
6931 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6932 "click",
6933 vec![AdfMark::link("https://example.com")],
6934 )])],
6935 };
6936 let md = adf_to_markdown(&doc).unwrap();
6937 assert_eq!(md.trim(), "[click](https://example.com)");
6938 }
6939
6940 #[test]
6941 fn unsupported_block_preserved_as_json() {
6942 let doc = AdfDocument {
6943 version: 1,
6944 doc_type: "doc".to_string(),
6945 content: vec![AdfNode {
6946 node_type: "unknownBlock".to_string(),
6947 attrs: Some(serde_json::json!({"key": "value"})),
6948 content: None,
6949 text: None,
6950 marks: None,
6951 local_id: None,
6952 parameters: None,
6953 }],
6954 };
6955 let md = adf_to_markdown(&doc).unwrap();
6956 assert!(md.contains("```adf-unsupported"));
6957 assert!(md.contains("\"unknownBlock\""));
6958 }
6959
6960 #[test]
6961 fn unsupported_block_round_trips() {
6962 let original = AdfDocument {
6963 version: 1,
6964 doc_type: "doc".to_string(),
6965 content: vec![AdfNode {
6966 node_type: "unknownBlock".to_string(),
6967 attrs: Some(serde_json::json!({"key": "value"})),
6968 content: None,
6969 text: None,
6970 marks: None,
6971 local_id: None,
6972 parameters: None,
6973 }],
6974 };
6975 let md = adf_to_markdown(&original).unwrap();
6976 let restored = markdown_to_adf(&md).unwrap();
6977 assert_eq!(restored.content[0].node_type, "unknownBlock");
6978 assert_eq!(restored.content[0].attrs.as_ref().unwrap()["key"], "value");
6979 }
6980
6981 #[test]
6984 fn round_trip_simple_document() {
6985 let md = "# Hello\n\nSome text with **bold** and *italic*.\n\n- Item 1\n- Item 2\n";
6986 let adf = markdown_to_adf(md).unwrap();
6987 let restored = adf_to_markdown(&adf).unwrap();
6988
6989 assert!(restored.contains("# Hello"));
6990 assert!(restored.contains("**bold**"));
6991 assert!(restored.contains("*italic*"));
6992 assert!(restored.contains("- Item 1"));
6993 assert!(restored.contains("- Item 2"));
6994 }
6995
6996 #[test]
6997 fn round_trip_code_block() {
6998 let md = "```python\nprint('hello')\n```\n";
6999 let adf = markdown_to_adf(md).unwrap();
7000 let restored = adf_to_markdown(&adf).unwrap();
7001
7002 assert!(restored.contains("```python"));
7003 assert!(restored.contains("print('hello')"));
7004 }
7005
7006 #[test]
7007 fn round_trip_code_block_no_attrs() {
7008 let adf_json = r#"{"version":1,"type":"doc","content":[
7009 {"type":"codeBlock","content":[{"type":"text","text":"plain code"}]}
7010 ]}"#;
7011 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7012 assert!(doc.content[0].attrs.is_none());
7013 let md = adf_to_markdown(&doc).unwrap();
7014 let round_tripped = markdown_to_adf(&md).unwrap();
7015 assert!(round_tripped.content[0].attrs.is_none());
7016 }
7017
7018 #[test]
7019 fn round_trip_code_block_empty_language() {
7020 let adf_json = r#"{"version":1,"type":"doc","content":[
7021 {"type":"codeBlock","attrs":{"language":""},"content":[{"type":"text","text":"simple code block no backtick"}]}
7022 ]}"#;
7023 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7024 let attrs = doc.content[0].attrs.as_ref().unwrap();
7025 assert_eq!(attrs["language"], "");
7026 let md = adf_to_markdown(&doc).unwrap();
7027 let round_tripped = markdown_to_adf(&md).unwrap();
7028 let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
7029 assert_eq!(rt_attrs["language"], "");
7030 }
7031
7032 #[test]
7033 fn round_trip_code_block_with_language() {
7034 let adf_json = r#"{"version":1,"type":"doc","content":[
7035 {"type":"codeBlock","attrs":{"language":"python"},"content":[{"type":"text","text":"print('hi')"}]}
7036 ]}"#;
7037 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7038 let md = adf_to_markdown(&doc).unwrap();
7039 let round_tripped = markdown_to_adf(&md).unwrap();
7040 let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
7041 assert_eq!(rt_attrs["language"], "python");
7042 }
7043
7044 #[test]
7045 fn multiple_paragraphs() {
7046 let md = "First paragraph.\n\nSecond paragraph.\n";
7047 let adf = markdown_to_adf(md).unwrap();
7048 assert_eq!(adf.content.len(), 2);
7049 assert_eq!(adf.content[0].node_type, "paragraph");
7050 assert_eq!(adf.content[1].node_type, "paragraph");
7051 }
7052
7053 #[test]
7056 fn horizontal_rule_underscores() {
7057 let doc = markdown_to_adf("___").unwrap();
7058 assert_eq!(doc.content[0].node_type, "rule");
7059 }
7060
7061 #[test]
7062 fn not_a_horizontal_rule_too_short() {
7063 let doc = markdown_to_adf("--").unwrap();
7064 assert_eq!(doc.content[0].node_type, "paragraph");
7065 }
7066
7067 #[test]
7068 fn bullet_list_star_marker() {
7069 let md = "* Apple\n* Banana";
7070 let doc = markdown_to_adf(md).unwrap();
7071 assert_eq!(doc.content[0].node_type, "bulletList");
7072 let items = doc.content[0].content.as_ref().unwrap();
7073 assert_eq!(items.len(), 2);
7074 }
7075
7076 #[test]
7077 fn bullet_list_plus_marker() {
7078 let md = "+ One\n+ Two";
7079 let doc = markdown_to_adf(md).unwrap();
7080 assert_eq!(doc.content[0].node_type, "bulletList");
7081 }
7082
7083 #[test]
7084 fn ordered_list_non_one_start() {
7085 let md = "5. Fifth\n6. Sixth";
7086 let doc = markdown_to_adf(md).unwrap();
7087 let node = &doc.content[0];
7088 assert_eq!(node.node_type, "orderedList");
7089 let attrs = node.attrs.as_ref().unwrap();
7090 assert_eq!(attrs["order"], 5);
7091 }
7092
7093 #[test]
7094 fn ordered_list_start_at_one_omits_order_attr() {
7095 let md = "1. First\n2. Second";
7099 let doc = markdown_to_adf(md).unwrap();
7100 let node = &doc.content[0];
7101 assert_eq!(node.node_type, "orderedList");
7102 assert!(
7103 node.attrs.is_none(),
7104 "attrs should be omitted when order=1, got: {:?}",
7105 node.attrs
7106 );
7107 }
7108
7109 #[test]
7110 fn blockquote_bare_marker() {
7111 let md = ">quoted text";
7113 let doc = markdown_to_adf(md).unwrap();
7114 assert_eq!(doc.content[0].node_type, "blockquote");
7115 }
7116
7117 #[test]
7118 fn image_no_alt() {
7119 let md = "";
7120 let doc = markdown_to_adf(md).unwrap();
7121 let node = &doc.content[0];
7122 assert_eq!(node.node_type, "mediaSingle");
7123 let media = &node.content.as_ref().unwrap()[0];
7125 let attrs = media.attrs.as_ref().unwrap();
7126 assert!(attrs.get("alt").is_none());
7127 }
7128
7129 #[test]
7130 fn image_with_alt() {
7131 let md = "";
7132 let doc = markdown_to_adf(md).unwrap();
7133 let media = &doc.content[0].content.as_ref().unwrap()[0];
7134 let attrs = media.attrs.as_ref().unwrap();
7135 assert_eq!(attrs["alt"], "A photo");
7136 }
7137
7138 #[test]
7139 fn table_multi_body_rows() {
7140 let md = "| H1 | H2 |\n| --- | --- |\n| a | b |\n| c | d |";
7141 let doc = markdown_to_adf(md).unwrap();
7142 let rows = doc.content[0].content.as_ref().unwrap();
7143 assert_eq!(rows.len(), 3); let header_cells = rows[0].content.as_ref().unwrap();
7146 assert_eq!(header_cells[0].node_type, "tableHeader");
7147 let body_cells = rows[1].content.as_ref().unwrap();
7149 assert_eq!(body_cells[0].node_type, "tableCell");
7150 }
7151
7152 #[test]
7153 fn table_no_separator_is_not_table() {
7154 let md = "| not | a table |";
7156 let doc = markdown_to_adf(md).unwrap();
7157 assert_eq!(doc.content[0].node_type, "paragraph");
7158 }
7159
7160 #[test]
7161 fn inline_underscore_bold() {
7162 let doc = markdown_to_adf("Some __bold__ text").unwrap();
7163 let content = doc.content[0].content.as_ref().unwrap();
7164 let bold_node = &content[1];
7165 assert_eq!(bold_node.text.as_deref(), Some("bold"));
7166 let marks = bold_node.marks.as_ref().unwrap();
7167 assert_eq!(marks[0].mark_type, "strong");
7168 }
7169
7170 #[test]
7171 fn inline_underscore_italic() {
7172 let doc = markdown_to_adf("Some _italic_ text").unwrap();
7173 let content = doc.content[0].content.as_ref().unwrap();
7174 let italic_node = &content[1];
7175 assert_eq!(italic_node.text.as_deref(), Some("italic"));
7176 let marks = italic_node.marks.as_ref().unwrap();
7177 assert_eq!(marks[0].mark_type, "em");
7178 }
7179
7180 #[test]
7181 fn intraword_underscore_not_emphasis() {
7182 let doc = markdown_to_adf("call do_something_useful now").unwrap();
7184 let content = doc.content[0].content.as_ref().unwrap();
7185 assert_eq!(content.len(), 1, "should be a single text node");
7186 assert_eq!(
7187 content[0].text.as_deref(),
7188 Some("call do_something_useful now")
7189 );
7190 assert!(content[0].marks.is_none());
7191 }
7192
7193 #[test]
7194 fn intraword_underscore_multiple() {
7195 let doc = markdown_to_adf("use a_b_c_d here").unwrap();
7197 let content = doc.content[0].content.as_ref().unwrap();
7198 assert_eq!(content.len(), 1);
7199 assert_eq!(content[0].text.as_deref(), Some("use a_b_c_d here"));
7200 assert!(content[0].marks.is_none());
7201 }
7202
7203 #[test]
7204 fn intraword_double_underscore_not_bold() {
7205 let doc = markdown_to_adf("foo__bar__baz").unwrap();
7207 let content = doc.content[0].content.as_ref().unwrap();
7208 assert_eq!(content.len(), 1);
7209 assert_eq!(content[0].text.as_deref(), Some("foo__bar__baz"));
7210 assert!(content[0].marks.is_none());
7211 }
7212
7213 #[test]
7214 fn intraword_triple_underscore_not_bold_italic() {
7215 let doc = markdown_to_adf("x___y___z").unwrap();
7217 let content = doc.content[0].content.as_ref().unwrap();
7218 assert_eq!(content.len(), 1);
7219 assert_eq!(content[0].text.as_deref(), Some("x___y___z"));
7220 assert!(content[0].marks.is_none());
7221 }
7222
7223 #[test]
7224 fn underscore_emphasis_still_works_with_spaces() {
7225 let doc = markdown_to_adf("some _italic_ here").unwrap();
7227 let content = doc.content[0].content.as_ref().unwrap();
7228 assert_eq!(content.len(), 3);
7229 assert_eq!(content[1].text.as_deref(), Some("italic"));
7230 let marks = content[1].marks.as_ref().unwrap();
7231 assert_eq!(marks[0].mark_type, "em");
7232 }
7233
7234 #[test]
7235 fn underscore_bold_still_works_with_spaces() {
7236 let doc = markdown_to_adf("some __bold__ here").unwrap();
7238 let content = doc.content[0].content.as_ref().unwrap();
7239 assert_eq!(content.len(), 3);
7240 assert_eq!(content[1].text.as_deref(), Some("bold"));
7241 let marks = content[1].marks.as_ref().unwrap();
7242 assert_eq!(marks[0].mark_type, "strong");
7243 }
7244
7245 #[test]
7246 fn intraword_underscore_closing_only() {
7247 let doc = markdown_to_adf("_foo_bar").unwrap();
7249 let content = doc.content[0].content.as_ref().unwrap();
7250 assert_eq!(content.len(), 1);
7251 assert_eq!(content[0].text.as_deref(), Some("_foo_bar"));
7252 }
7253
7254 #[test]
7255 fn intraword_double_underscore_closing_only() {
7256 let doc = markdown_to_adf("__foo__bar").unwrap();
7258 let content = doc.content[0].content.as_ref().unwrap();
7259 assert_eq!(content.len(), 1);
7260 assert_eq!(content[0].text.as_deref(), Some("__foo__bar"));
7261 }
7262
7263 #[test]
7264 fn intraword_triple_underscore_closing_only() {
7265 let doc = markdown_to_adf("___foo___bar").unwrap();
7267 let content = doc.content[0].content.as_ref().unwrap();
7268 assert_eq!(content.len(), 1);
7269 assert_eq!(content[0].text.as_deref(), Some("___foo___bar"));
7270 }
7271
7272 #[test]
7273 fn asterisk_emphasis_unaffected_by_intraword_fix() {
7274 let doc = markdown_to_adf("foo*bar*baz").unwrap();
7276 let content = doc.content[0].content.as_ref().unwrap();
7277 assert!(content.len() > 1 || content[0].marks.is_some());
7279 }
7280
7281 #[test]
7282 fn intraword_underscore_at_start_of_text() {
7283 let doc = markdown_to_adf("_italic_ word").unwrap();
7285 let content = doc.content[0].content.as_ref().unwrap();
7286 assert_eq!(content[0].text.as_deref(), Some("italic"));
7287 let marks = content[0].marks.as_ref().unwrap();
7288 assert_eq!(marks[0].mark_type, "em");
7289 }
7290
7291 #[test]
7292 fn intraword_underscore_at_end_of_text() {
7293 let doc = markdown_to_adf("word _italic_").unwrap();
7295 let content = doc.content[0].content.as_ref().unwrap();
7296 let last = content.last().unwrap();
7297 assert_eq!(last.text.as_deref(), Some("italic"));
7298 let marks = last.marks.as_ref().unwrap();
7299 assert_eq!(marks[0].mark_type, "em");
7300 }
7301
7302 #[test]
7303 fn intraword_underscore_opening_only() {
7304 let doc = markdown_to_adf("a_b c_d").unwrap();
7307 let content = doc.content[0].content.as_ref().unwrap();
7308 assert_eq!(content.len(), 1);
7309 assert_eq!(content[0].text.as_deref(), Some("a_b c_d"));
7310 }
7311
7312 #[test]
7313 fn intraword_underscore_roundtrip() {
7314 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call the do_something_useful function"}]}]}"#;
7316 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7317 let jfm = adf_to_markdown(&adf).unwrap();
7318 let roundtripped = markdown_to_adf(&jfm).unwrap();
7319 let content = roundtripped.content[0].content.as_ref().unwrap();
7320 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7321 assert_eq!(
7322 content[0].text.as_deref(),
7323 Some("call the do_something_useful function")
7324 );
7325 assert!(content[0].marks.is_none());
7326 }
7327
7328 #[test]
7329 fn asterisk_emphasis_roundtrip() {
7330 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status: *confirmed* and active"}]}]}"#;
7332 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7333 let jfm = adf_to_markdown(&adf).unwrap();
7334 let roundtripped = markdown_to_adf(&jfm).unwrap();
7335 let content = roundtripped.content[0].content.as_ref().unwrap();
7336 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7337 assert_eq!(
7338 content[0].text.as_deref(),
7339 Some("Status: *confirmed* and active")
7340 );
7341 assert!(content[0].marks.is_none());
7342 }
7343
7344 #[test]
7345 fn double_asterisk_roundtrip() {
7346 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use **kwargs in Python"}]}]}"#;
7348 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7349 let jfm = adf_to_markdown(&adf).unwrap();
7350 let roundtripped = markdown_to_adf(&jfm).unwrap();
7351 let content = roundtripped.content[0].content.as_ref().unwrap();
7352 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7353 assert_eq!(content[0].text.as_deref(), Some("Use **kwargs in Python"));
7354 assert!(content[0].marks.is_none());
7355 }
7356
7357 #[test]
7358 fn asterisk_with_em_mark_roundtrip() {
7359 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a*b","marks":[{"type":"em"}]}]}]}"#;
7361 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7362 let jfm = adf_to_markdown(&adf).unwrap();
7363 let roundtripped = markdown_to_adf(&jfm).unwrap();
7364 let content = roundtripped.content[0].content.as_ref().unwrap();
7365 let em_node = content.iter().find(|n| {
7367 n.marks
7368 .as_ref()
7369 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7370 });
7371 assert!(em_node.is_some(), "should have an em-marked node");
7372 assert_eq!(em_node.unwrap().text.as_deref(), Some("a*b"));
7373 }
7374
7375 #[test]
7376 fn lone_asterisk_roundtrip() {
7377 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"rating: 5 * stars"}]}]}"#;
7379 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7380 let jfm = adf_to_markdown(&adf).unwrap();
7381 let roundtripped = markdown_to_adf(&jfm).unwrap();
7382 let content = roundtripped.content[0].content.as_ref().unwrap();
7383 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7384 assert_eq!(content[0].text.as_deref(), Some("rating: 5 * stars"));
7385 }
7386
7387 #[test]
7388 fn escape_emphasis_markers_unit() {
7389 assert_eq!(escape_emphasis_markers("hello"), "hello");
7390 assert_eq!(escape_emphasis_markers("*bold*"), r"\*bold\*");
7391 assert_eq!(escape_emphasis_markers("**strong**"), r"\*\*strong\*\*");
7392 assert_eq!(escape_emphasis_markers("no stars"), "no stars");
7393 assert_eq!(escape_emphasis_markers("a * b"), r"a \* b");
7394 assert_eq!(escape_emphasis_markers(""), "");
7395 }
7396
7397 #[test]
7398 fn escape_emphasis_markers_underscore_intraword() {
7399 assert_eq!(escape_emphasis_markers("foo_bar"), "foo_bar");
7402 assert_eq!(escape_emphasis_markers("a_b_c"), "a_b_c");
7403 assert_eq!(escape_emphasis_markers("foo__bar"), "foo__bar");
7404 assert_eq!(
7405 escape_emphasis_markers("call do_something_useful"),
7406 "call do_something_useful"
7407 );
7408 }
7409
7410 #[test]
7411 fn escape_emphasis_markers_underscore_at_boundary() {
7412 assert_eq!(escape_emphasis_markers("_Action"), r"\_Action");
7415 assert_eq!(escape_emphasis_markers("Action_"), r"Action\_");
7416 assert_eq!(escape_emphasis_markers("_ "), r"\_ ");
7417 assert_eq!(escape_emphasis_markers(" _"), r" \_");
7418 assert_eq!(escape_emphasis_markers("_"), r"\_");
7419 }
7420
7421 #[test]
7422 fn escape_emphasis_markers_underscore_with_punctuation() {
7423 assert_eq!(escape_emphasis_markers("foo _bar"), r"foo \_bar");
7425 assert_eq!(escape_emphasis_markers("foo_ bar"), r"foo\_ bar");
7426 assert_eq!(escape_emphasis_markers("(_x_)"), r"(\_x\_)");
7427 }
7428
7429 #[test]
7430 fn find_unescaped_skips_backslash_escaped() {
7431 assert_eq!(find_unescaped(r"a\*\*b**", "**"), Some(6));
7433 assert_eq!(find_unescaped(r"a\*\*b", "**"), None);
7435 assert_eq!(find_unescaped("a**b", "**"), Some(1));
7437 assert_eq!(find_unescaped("", "**"), None);
7439 }
7440
7441 #[test]
7442 fn find_unescaped_char_skips_backslash_escaped() {
7443 assert_eq!(find_unescaped_char(r"a\*b*", b'*'), Some(4));
7445 assert_eq!(find_unescaped_char(r"\*", b'*'), None);
7447 assert_eq!(find_unescaped_char("a*b", b'*'), Some(1));
7449 assert_eq!(find_unescaped_char("", b'*'), None);
7451 }
7452
7453 #[test]
7454 fn double_asterisk_in_strong_mark_roundtrip() {
7455 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call **kwargs","marks":[{"type":"strong"}]}]}]}"#;
7457 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7458 let jfm = adf_to_markdown(&adf).unwrap();
7459 let roundtripped = markdown_to_adf(&jfm).unwrap();
7460 let content = roundtripped.content[0].content.as_ref().unwrap();
7461 let strong_node = content.iter().find(|n| {
7462 n.marks
7463 .as_ref()
7464 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7465 });
7466 assert!(strong_node.is_some(), "should have a strong-marked node");
7467 assert_eq!(strong_node.unwrap().text.as_deref(), Some("call **kwargs"));
7468 }
7469
7470 #[test]
7471 fn backtick_code_roundtrip() {
7472 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Set `max_retries` to 3 in the config"}]}]}"#;
7474 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7475 let jfm = adf_to_markdown(&adf).unwrap();
7476 let roundtripped = markdown_to_adf(&jfm).unwrap();
7477 let content = roundtripped.content[0].content.as_ref().unwrap();
7478 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7479 assert_eq!(
7480 content[0].text.as_deref(),
7481 Some("Set `max_retries` to 3 in the config")
7482 );
7483 assert!(content[0].marks.is_none());
7484 }
7485
7486 #[test]
7487 fn multiple_backtick_spans_roundtrip() {
7488 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use `foo` and `bar` together"}]}]}"#;
7490 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7491 let jfm = adf_to_markdown(&adf).unwrap();
7492 let roundtripped = markdown_to_adf(&jfm).unwrap();
7493 let content = roundtripped.content[0].content.as_ref().unwrap();
7494 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7495 assert_eq!(
7496 content[0].text.as_deref(),
7497 Some("Use `foo` and `bar` together")
7498 );
7499 assert!(content[0].marks.is_none());
7500 }
7501
7502 #[test]
7503 fn lone_backtick_roundtrip() {
7504 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use a ` character"}]}]}"#;
7506 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7507 let jfm = adf_to_markdown(&adf).unwrap();
7508 let roundtripped = markdown_to_adf(&jfm).unwrap();
7509 let content = roundtripped.content[0].content.as_ref().unwrap();
7510 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7511 assert_eq!(content[0].text.as_deref(), Some("Use a ` character"));
7512 assert!(content[0].marks.is_none());
7513 }
7514
7515 #[test]
7516 fn backtick_with_code_mark_roundtrip() {
7517 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"max_retries","marks":[{"type":"code"}]}]}]}"#;
7519 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7520 let jfm = adf_to_markdown(&adf).unwrap();
7521 assert_eq!(jfm.trim(), "`max_retries`");
7522 let roundtripped = markdown_to_adf(&jfm).unwrap();
7523 let content = roundtripped.content[0].content.as_ref().unwrap();
7524 let code_node = content.iter().find(|n| {
7525 n.marks
7526 .as_ref()
7527 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7528 });
7529 assert!(code_node.is_some(), "should have a code-marked node");
7530 assert_eq!(code_node.unwrap().text.as_deref(), Some("max_retries"));
7531 }
7532
7533 #[test]
7534 fn backtick_with_em_mark_roundtrip() {
7535 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"use `cfg`","marks":[{"type":"em"}]}]}]}"#;
7537 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7538 let jfm = adf_to_markdown(&adf).unwrap();
7539 let roundtripped = markdown_to_adf(&jfm).unwrap();
7540 let content = roundtripped.content[0].content.as_ref().unwrap();
7541 let em_node = content.iter().find(|n| {
7542 n.marks
7543 .as_ref()
7544 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7545 });
7546 assert!(em_node.is_some(), "should have an em-marked node");
7547 assert_eq!(em_node.unwrap().text.as_deref(), Some("use `cfg`"));
7548 }
7549
7550 #[test]
7551 fn escape_pipes_in_cell_unit() {
7552 assert_eq!(escape_pipes_in_cell("hello"), "hello");
7553 assert_eq!(escape_pipes_in_cell("a|b"), r"a\|b");
7554 assert_eq!(escape_pipes_in_cell("|"), r"\|");
7555 assert_eq!(escape_pipes_in_cell("|a|b|"), r"\|a\|b\|");
7556 assert_eq!(escape_pipes_in_cell(""), "");
7557 assert_eq!(
7558 escape_pipes_in_cell("`parser.decode[T|json]`"),
7559 r"`parser.decode[T\|json]`"
7560 );
7561 }
7562
7563 #[test]
7564 fn escape_backticks_unit() {
7565 assert_eq!(escape_backticks("hello"), "hello");
7566 assert_eq!(escape_backticks("`code`"), r"\`code\`");
7567 assert_eq!(escape_backticks("no ticks"), "no ticks");
7568 assert_eq!(escape_backticks("a ` b"), r"a \` b");
7569 assert_eq!(escape_backticks(""), "");
7570 assert_eq!(escape_backticks("`a` and `b`"), r"\`a\` and \`b\`");
7571 }
7572
7573 #[test]
7576 fn backslash_in_text_roundtrip() {
7577 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"The path is C:\\Users\\admin\\file.txt"}]}]}"#;
7579 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7580 let jfm = adf_to_markdown(&adf).unwrap();
7581 let roundtripped = markdown_to_adf(&jfm).unwrap();
7582 let content = roundtripped.content[0].content.as_ref().unwrap();
7583 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7584 assert_eq!(
7585 content[0].text.as_deref(),
7586 Some(r"The path is C:\Users\admin\file.txt")
7587 );
7588 }
7589
7590 #[test]
7591 fn backslash_emitted_as_double_backslash() {
7592 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\b"}]}]}"#;
7593 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7594 let jfm = adf_to_markdown(&adf).unwrap();
7595 assert!(
7596 jfm.contains(r"a\\b"),
7597 "JFM should contain escaped backslash: {jfm}"
7598 );
7599 }
7600
7601 #[test]
7602 fn consecutive_backslashes_roundtrip() {
7603 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\\\b"}]}]}"#;
7604 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7605 let jfm = adf_to_markdown(&adf).unwrap();
7606 let roundtripped = markdown_to_adf(&jfm).unwrap();
7607 let content = roundtripped.content[0].content.as_ref().unwrap();
7608 assert_eq!(
7609 content[0].text.as_deref(),
7610 Some(r"a\\b"),
7611 "consecutive backslashes should survive round-trip"
7612 );
7613 }
7614
7615 #[test]
7616 fn backslash_with_strong_mark_roundtrip() {
7617 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"strong"}]}]}]}"#;
7618 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7619 let jfm = adf_to_markdown(&adf).unwrap();
7620 let roundtripped = markdown_to_adf(&jfm).unwrap();
7621 let content = roundtripped.content[0].content.as_ref().unwrap();
7622 let strong_node = content.iter().find(|n| {
7623 n.marks
7624 .as_ref()
7625 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7626 });
7627 assert!(strong_node.is_some(), "should have a strong-marked node");
7628 assert_eq!(strong_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7629 }
7630
7631 #[test]
7632 fn backslash_with_code_mark_not_escaped() {
7633 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"code"}]}]}]}"#;
7635 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7636 let jfm = adf_to_markdown(&adf).unwrap();
7637 assert_eq!(jfm.trim(), r"`C:\Users`");
7638 let roundtripped = markdown_to_adf(&jfm).unwrap();
7639 let content = roundtripped.content[0].content.as_ref().unwrap();
7640 let code_node = content.iter().find(|n| {
7641 n.marks
7642 .as_ref()
7643 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7644 });
7645 assert!(code_node.is_some(), "should have a code-marked node");
7646 assert_eq!(code_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7647 }
7648
7649 #[test]
7650 fn backslash_before_special_chars_roundtrip() {
7651 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"\\*not bold\\*"}]}]}"#;
7653 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7654 let jfm = adf_to_markdown(&adf).unwrap();
7655 let roundtripped = markdown_to_adf(&jfm).unwrap();
7656 let content = roundtripped.content[0].content.as_ref().unwrap();
7657 assert_eq!(
7658 content[0].text.as_deref(),
7659 Some(r"\*not bold\*"),
7660 "backslash before special char should survive round-trip"
7661 );
7662 }
7663
7664 #[test]
7665 fn backslash_and_newline_in_text_roundtrip() {
7666 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\path\nline2"}]}]}"#;
7668 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7669 let jfm = adf_to_markdown(&adf).unwrap();
7670 let roundtripped = markdown_to_adf(&jfm).unwrap();
7671 let content = roundtripped.content[0].content.as_ref().unwrap();
7672 assert_eq!(
7673 content[0].text.as_deref(),
7674 Some("C:\\path\nline2"),
7675 "backslash and newline should both survive round-trip"
7676 );
7677 }
7678
7679 #[test]
7680 fn lone_backslash_roundtrip() {
7681 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a \\ b"}]}]}"#;
7682 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7683 let jfm = adf_to_markdown(&adf).unwrap();
7684 let roundtripped = markdown_to_adf(&jfm).unwrap();
7685 let content = roundtripped.content[0].content.as_ref().unwrap();
7686 assert_eq!(content[0].text.as_deref(), Some(r"a \ b"));
7687 }
7688
7689 #[test]
7690 fn trailing_backslash_in_text_roundtrip() {
7691 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\"}]}]}"#;
7693 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7694 let jfm = adf_to_markdown(&adf).unwrap();
7695 let roundtripped = markdown_to_adf(&jfm).unwrap();
7696 let content = roundtripped.content[0].content.as_ref().unwrap();
7697 assert_eq!(
7698 content[0].text.as_deref(),
7699 Some(r"end\"),
7700 "trailing backslash should survive round-trip"
7701 );
7702 }
7703
7704 #[test]
7705 fn escape_bare_urls_unit() {
7706 assert_eq!(escape_bare_urls("hello"), "hello");
7707 assert_eq!(escape_bare_urls(""), "");
7708 assert_eq!(
7709 escape_bare_urls("https://example.com"),
7710 r"\https://example.com"
7711 );
7712 assert_eq!(
7713 escape_bare_urls("http://example.com"),
7714 r"\http://example.com"
7715 );
7716 assert_eq!(
7717 escape_bare_urls("see https://a.com and https://b.com"),
7718 r"see \https://a.com and \https://b.com"
7719 );
7720 assert_eq!(escape_bare_urls("http header"), "http header");
7722 assert_eq!(escape_bare_urls("https is secure"), "https is secure");
7723 }
7724
7725 #[test]
7726 fn heading_not_valid_without_space() {
7727 let doc = markdown_to_adf("#Title").unwrap();
7729 assert_eq!(doc.content[0].node_type, "paragraph");
7730 }
7731
7732 #[test]
7733 fn heading_level_too_high() {
7734 let doc = markdown_to_adf("####### Not a heading").unwrap();
7736 assert_eq!(doc.content[0].node_type, "paragraph");
7737 }
7738
7739 #[test]
7740 fn empty_document() {
7741 let doc = markdown_to_adf("").unwrap();
7742 assert!(doc.content.is_empty());
7743 }
7744
7745 #[test]
7746 fn only_blank_lines() {
7747 let doc = markdown_to_adf("\n\n\n").unwrap();
7748 assert!(doc.content.is_empty());
7749 }
7750
7751 #[test]
7752 fn code_block_unterminated() {
7753 let md = "```rust\nfn main() {}";
7755 let doc = markdown_to_adf(md).unwrap();
7756 assert_eq!(doc.content[0].node_type, "codeBlock");
7757 }
7758
7759 #[test]
7760 fn mixed_document() {
7761 let md = "# Title\n\nA paragraph.\n\n- Item\n\n```\ncode\n```\n\n> quote\n\n---\n\n1. numbered\n";
7762 let doc = markdown_to_adf(md).unwrap();
7763 let types: Vec<&str> = doc.content.iter().map(|n| n.node_type.as_str()).collect();
7764 assert_eq!(
7765 types,
7766 vec![
7767 "heading",
7768 "paragraph",
7769 "bulletList",
7770 "codeBlock",
7771 "blockquote",
7772 "rule",
7773 "orderedList",
7774 ]
7775 );
7776 }
7777
7778 #[test]
7781 fn adf_ordered_list_to_markdown() {
7782 let doc = AdfDocument {
7783 version: 1,
7784 doc_type: "doc".to_string(),
7785 content: vec![AdfNode::ordered_list(
7786 vec![
7787 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("First")])]),
7788 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("Second")])]),
7789 ],
7790 None,
7791 )],
7792 };
7793 let md = adf_to_markdown(&doc).unwrap();
7794 assert!(md.contains("1. First"));
7795 assert!(md.contains("2. Second"));
7796 }
7797
7798 #[test]
7799 fn adf_ordered_list_custom_start() {
7800 let doc = AdfDocument {
7801 version: 1,
7802 doc_type: "doc".to_string(),
7803 content: vec![AdfNode::ordered_list(
7804 vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
7805 AdfNode::text("Third"),
7806 ])])],
7807 Some(3),
7808 )],
7809 };
7810 let md = adf_to_markdown(&doc).unwrap();
7811 assert!(md.contains("3. Third"));
7812 }
7813
7814 #[test]
7815 fn adf_blockquote_to_markdown() {
7816 let doc = AdfDocument {
7817 version: 1,
7818 doc_type: "doc".to_string(),
7819 content: vec![AdfNode::blockquote(vec![AdfNode::paragraph(vec![
7820 AdfNode::text("A quote"),
7821 ])])],
7822 };
7823 let md = adf_to_markdown(&doc).unwrap();
7824 assert!(md.contains("> A quote"));
7825 }
7826
7827 #[test]
7828 fn adf_table_to_markdown() {
7829 let doc = AdfDocument {
7830 version: 1,
7831 doc_type: "doc".to_string(),
7832 content: vec![AdfNode::table(vec![
7833 AdfNode::table_row(vec![
7834 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Name")])]),
7835 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Value")])]),
7836 ]),
7837 AdfNode::table_row(vec![
7838 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a")])]),
7839 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("1")])]),
7840 ]),
7841 ])],
7842 };
7843 let md = adf_to_markdown(&doc).unwrap();
7844 assert!(md.contains("| Name | Value |"));
7845 assert!(md.contains("| --- | --- |"));
7846 assert!(md.contains("| a | 1 |"));
7847 }
7848
7849 #[test]
7850 fn adf_media_to_markdown() {
7851 let doc = AdfDocument {
7852 version: 1,
7853 doc_type: "doc".to_string(),
7854 content: vec![AdfNode::media_single(
7855 "https://example.com/img.png",
7856 Some("Alt"),
7857 )],
7858 };
7859 let md = adf_to_markdown(&doc).unwrap();
7860 assert!(md.contains(""));
7861 }
7862
7863 #[test]
7864 fn adf_media_no_alt_to_markdown() {
7865 let doc = AdfDocument {
7866 version: 1,
7867 doc_type: "doc".to_string(),
7868 content: vec![AdfNode::media_single("https://example.com/img.png", None)],
7869 };
7870 let md = adf_to_markdown(&doc).unwrap();
7871 assert!(md.contains(""));
7872 }
7873
7874 #[test]
7875 fn adf_italic_to_markdown() {
7876 let doc = AdfDocument {
7877 version: 1,
7878 doc_type: "doc".to_string(),
7879 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7880 "emphasis",
7881 vec![AdfMark::em()],
7882 )])],
7883 };
7884 let md = adf_to_markdown(&doc).unwrap();
7885 assert_eq!(md.trim(), "*emphasis*");
7886 }
7887
7888 #[test]
7889 fn adf_strikethrough_to_markdown() {
7890 let doc = AdfDocument {
7891 version: 1,
7892 doc_type: "doc".to_string(),
7893 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7894 "deleted",
7895 vec![AdfMark::strike()],
7896 )])],
7897 };
7898 let md = adf_to_markdown(&doc).unwrap();
7899 assert_eq!(md.trim(), "~~deleted~~");
7900 }
7901
7902 #[test]
7903 fn adf_inline_code_to_markdown() {
7904 let doc = AdfDocument {
7905 version: 1,
7906 doc_type: "doc".to_string(),
7907 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7908 "code",
7909 vec![AdfMark::code()],
7910 )])],
7911 };
7912 let md = adf_to_markdown(&doc).unwrap();
7913 assert_eq!(md.trim(), "`code`");
7914 }
7915
7916 #[test]
7917 fn adf_code_with_link_to_markdown() {
7918 let doc = AdfDocument {
7919 version: 1,
7920 doc_type: "doc".to_string(),
7921 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7922 "func",
7923 vec![AdfMark::code(), AdfMark::link("https://example.com")],
7924 )])],
7925 };
7926 let md = adf_to_markdown(&doc).unwrap();
7927 assert_eq!(md.trim(), "[`func`](https://example.com)");
7928 }
7929
7930 #[test]
7931 fn adf_bold_italic_to_markdown() {
7932 let doc = AdfDocument {
7933 version: 1,
7934 doc_type: "doc".to_string(),
7935 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7936 "both",
7937 vec![AdfMark::strong(), AdfMark::em()],
7938 )])],
7939 };
7940 let md = adf_to_markdown(&doc).unwrap();
7941 assert_eq!(md.trim(), "***both***");
7942 }
7943
7944 #[test]
7945 fn adf_bold_link_to_markdown() {
7946 let doc = AdfDocument {
7947 version: 1,
7948 doc_type: "doc".to_string(),
7949 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7950 "bold link",
7951 vec![AdfMark::strong(), AdfMark::link("https://example.com")],
7952 )])],
7953 };
7954 let md = adf_to_markdown(&doc).unwrap();
7955 assert_eq!(md.trim(), "**[bold link](https://example.com)**");
7956 }
7957
7958 #[test]
7959 fn adf_strikethrough_bold_to_markdown() {
7960 let doc = AdfDocument {
7961 version: 1,
7962 doc_type: "doc".to_string(),
7963 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7964 "struck",
7965 vec![AdfMark::strike(), AdfMark::strong()],
7966 )])],
7967 };
7968 let md = adf_to_markdown(&doc).unwrap();
7969 assert_eq!(md.trim(), "~~**struck**~~");
7970 }
7971
7972 #[test]
7973 fn adf_hard_break_to_markdown() {
7974 let doc = AdfDocument {
7975 version: 1,
7976 doc_type: "doc".to_string(),
7977 content: vec![AdfNode::paragraph(vec![
7978 AdfNode::text("Line 1"),
7979 AdfNode::hard_break(),
7980 AdfNode::text("Line 2"),
7981 ])],
7982 };
7983 let md = adf_to_markdown(&doc).unwrap();
7984 assert!(md.contains("Line 1\\\n Line 2"));
7985 }
7986
7987 #[test]
7988 fn adf_unsupported_inline_to_markdown() {
7989 let doc = AdfDocument {
7990 version: 1,
7991 doc_type: "doc".to_string(),
7992 content: vec![AdfNode::paragraph(vec![AdfNode {
7993 node_type: "unknownInline".to_string(),
7994 attrs: Some(serde_json::json!({"key": "value"})),
7995 content: None,
7996 text: None,
7997 marks: None,
7998 local_id: None,
7999 parameters: None,
8000 }])],
8001 };
8002 let md = adf_to_markdown(&doc).unwrap();
8003 assert!(md.contains(":adf-unsupported[]{json="));
8007 assert!(md.contains("unknownInline"));
8008 assert!(!md.contains("<!-- unsupported inline"));
8009 }
8010
8011 #[test]
8012 fn unsupported_inline_round_trips() {
8013 let original = AdfDocument {
8014 version: 1,
8015 doc_type: "doc".to_string(),
8016 content: vec![AdfNode::paragraph(vec![
8017 AdfNode::text("before "),
8018 AdfNode {
8019 node_type: "unknownInline".to_string(),
8020 attrs: Some(serde_json::json!({"key": "value", "n": 3})),
8021 content: None,
8022 text: Some("fallback".to_string()),
8023 marks: None,
8024 local_id: None,
8025 parameters: None,
8026 },
8027 AdfNode::text(" after"),
8028 ])],
8029 };
8030 let md = adf_to_markdown(&original).unwrap();
8031 let restored = markdown_to_adf(&md).unwrap();
8032 let para = &restored.content[0];
8034 let unknown = para
8035 .content
8036 .as_ref()
8037 .unwrap()
8038 .iter()
8039 .find(|n| n.node_type == "unknownInline")
8040 .expect("unknownInline node preserved");
8041 assert_eq!(unknown.attrs.as_ref().unwrap()["key"], "value");
8042 assert_eq!(unknown.attrs.as_ref().unwrap()["n"], 3);
8043 assert_eq!(unknown.text.as_deref(), Some("fallback"));
8044 }
8045
8046 #[test]
8049 fn adf_media_inline_to_markdown() {
8050 let doc = AdfDocument {
8051 version: 1,
8052 doc_type: "doc".to_string(),
8053 content: vec![AdfNode::paragraph(vec![
8054 AdfNode::text("see "),
8055 AdfNode::media_inline(serde_json::json!({
8056 "type": "image",
8057 "id": "abcdef01-2345-6789-abcd-abcdef012345",
8058 "collection": "contentId-111111",
8059 "width": 200,
8060 "height": 100
8061 })),
8062 AdfNode::text(" for details"),
8063 ])],
8064 };
8065 let md = adf_to_markdown(&doc).unwrap();
8066 assert!(md.contains(":media-inline[]{"), "got: {md}");
8067 assert!(md.contains("type=image"));
8068 assert!(md.contains("id=abcdef01-2345-6789-abcd-abcdef012345"));
8069 assert!(md.contains("collection=contentId-111111"));
8070 assert!(md.contains("width=200"));
8071 assert!(md.contains("height=100"));
8072 assert!(!md.contains("<!-- unsupported inline"));
8073 }
8074
8075 #[test]
8076 fn media_inline_round_trip() {
8077 let doc = AdfDocument {
8078 version: 1,
8079 doc_type: "doc".to_string(),
8080 content: vec![AdfNode::paragraph(vec![
8081 AdfNode::text("see "),
8082 AdfNode::media_inline(serde_json::json!({
8083 "type": "image",
8084 "id": "abcdef01-2345-6789-abcd-abcdef012345",
8085 "collection": "contentId-111111",
8086 "width": 200,
8087 "height": 100
8088 })),
8089 AdfNode::text(" for details"),
8090 ])],
8091 };
8092 let md = adf_to_markdown(&doc).unwrap();
8093 let rt = markdown_to_adf(&md).unwrap();
8094
8095 let content = rt.content[0].content.as_ref().unwrap();
8096 assert_eq!(content[0].text.as_deref(), Some("see "));
8097 assert_eq!(content[1].node_type, "mediaInline");
8098 let attrs = content[1].attrs.as_ref().unwrap();
8099 assert_eq!(attrs["type"], "image");
8100 assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
8101 assert_eq!(attrs["collection"], "contentId-111111");
8102 assert_eq!(attrs["width"], 200);
8103 assert_eq!(attrs["height"], 100);
8104 assert_eq!(content[2].text.as_deref(), Some(" for details"));
8105 }
8106
8107 #[test]
8108 fn media_inline_external_url_round_trip() {
8109 let doc = AdfDocument {
8110 version: 1,
8111 doc_type: "doc".to_string(),
8112 content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
8113 serde_json::json!({
8114 "type": "external",
8115 "url": "https://example.com/image.png",
8116 "alt": "example",
8117 "width": 400,
8118 "height": 300
8119 }),
8120 )])],
8121 };
8122 let md = adf_to_markdown(&doc).unwrap();
8123 let rt = markdown_to_adf(&md).unwrap();
8124
8125 let content = rt.content[0].content.as_ref().unwrap();
8126 assert_eq!(content[0].node_type, "mediaInline");
8127 let attrs = content[0].attrs.as_ref().unwrap();
8128 assert_eq!(attrs["type"], "external");
8129 assert_eq!(attrs["url"], "https://example.com/image.png");
8130 assert_eq!(attrs["alt"], "example");
8131 assert_eq!(attrs["width"], 400);
8132 assert_eq!(attrs["height"], 300);
8133 }
8134
8135 #[test]
8136 fn media_inline_minimal_attrs() {
8137 let doc = AdfDocument {
8138 version: 1,
8139 doc_type: "doc".to_string(),
8140 content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
8141 serde_json::json!({"type": "file", "id": "abc-123"}),
8142 )])],
8143 };
8144 let md = adf_to_markdown(&doc).unwrap();
8145 let rt = markdown_to_adf(&md).unwrap();
8146
8147 let content = rt.content[0].content.as_ref().unwrap();
8148 assert_eq!(content[0].node_type, "mediaInline");
8149 let attrs = content[0].attrs.as_ref().unwrap();
8150 assert_eq!(attrs["type"], "file");
8151 assert_eq!(attrs["id"], "abc-123");
8152 }
8153
8154 #[test]
8155 fn media_inline_from_issue_476_reproducer() {
8156 let adf_json: serde_json::Value = serde_json::json!({
8158 "type": "doc",
8159 "version": 1,
8160 "content": [
8161 {
8162 "type": "paragraph",
8163 "content": [
8164 {"type": "text", "text": "see "},
8165 {
8166 "type": "mediaInline",
8167 "attrs": {
8168 "collection": "contentId-111111",
8169 "height": 100,
8170 "id": "abcdef01-2345-6789-abcd-abcdef012345",
8171 "localId": "aabbccdd-1234-5678-abcd-aabbccdd1234",
8172 "type": "image",
8173 "width": 200
8174 }
8175 },
8176 {"type": "text", "text": " for details"}
8177 ]
8178 }
8179 ]
8180 });
8181 let doc: AdfDocument = serde_json::from_value(adf_json).unwrap();
8182 let md = adf_to_markdown(&doc).unwrap();
8183 assert!(
8184 !md.contains("<!-- unsupported inline"),
8185 "mediaInline should not be unsupported; got: {md}"
8186 );
8187
8188 let rt = markdown_to_adf(&md).unwrap();
8189 let content = rt.content[0].content.as_ref().unwrap();
8190 assert_eq!(content[1].node_type, "mediaInline");
8191 let attrs = content[1].attrs.as_ref().unwrap();
8192 assert_eq!(attrs["type"], "image");
8193 assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
8194 assert_eq!(attrs["collection"], "contentId-111111");
8195 assert_eq!(attrs["width"], 200);
8196 assert_eq!(attrs["height"], 100);
8197 assert_eq!(attrs["localId"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
8198 }
8199
8200 #[test]
8201 fn emoji_shortcode() {
8202 let doc = markdown_to_adf("Hello :wave: world").unwrap();
8203 let content = doc.content[0].content.as_ref().unwrap();
8204 assert_eq!(content[0].text.as_deref(), Some("Hello "));
8205 assert_eq!(content[1].node_type, "emoji");
8206 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":wave:");
8207 assert_eq!(content[2].text.as_deref(), Some(" world"));
8208 }
8209
8210 #[test]
8211 fn adf_emoji_to_markdown() {
8212 let doc = AdfDocument {
8213 version: 1,
8214 doc_type: "doc".to_string(),
8215 content: vec![AdfNode::paragraph(vec![AdfNode::emoji("thumbsup")])],
8216 };
8217 let md = adf_to_markdown(&doc).unwrap();
8218 assert!(md.contains(":thumbsup:"));
8219 }
8220
8221 #[test]
8222 fn adf_emoji_with_colon_prefix_to_markdown() {
8223 let doc = AdfDocument {
8225 version: 1,
8226 doc_type: "doc".to_string(),
8227 content: vec![AdfNode::paragraph(vec![AdfNode {
8228 node_type: "emoji".to_string(),
8229 attrs: Some(serde_json::json!({"shortName": ":thumbsup:"})),
8230 content: None,
8231 text: None,
8232 marks: None,
8233 local_id: None,
8234 parameters: None,
8235 }])],
8236 };
8237 let md = adf_to_markdown(&doc).unwrap();
8238 assert!(md.contains(":thumbsup:"));
8239 assert!(!md.contains("::thumbsup::"));
8241 }
8242
8243 #[test]
8244 fn round_trip_emoji() {
8245 let md = "Hello :wave: world\n";
8246 let doc = markdown_to_adf(md).unwrap();
8247 let result = adf_to_markdown(&doc).unwrap();
8248 assert!(result.contains(":wave:"));
8249 }
8250
8251 #[test]
8252 fn emoji_with_id_and_text_round_trips() {
8253 let doc = AdfDocument {
8254 version: 1,
8255 doc_type: "doc".to_string(),
8256 content: vec![AdfNode::paragraph(vec![AdfNode {
8257 node_type: "emoji".to_string(),
8258 attrs: Some(
8259 serde_json::json!({"shortName": ":check_mark:", "id": "2705", "text": "✅"}),
8260 ),
8261 content: None,
8262 text: None,
8263 marks: None,
8264 local_id: None,
8265 parameters: None,
8266 }])],
8267 };
8268 let md = adf_to_markdown(&doc).unwrap();
8269 assert!(md.contains(":check_mark:"), "shortcode present: {md}");
8270 assert!(md.contains("id="), "id attr present: {md}");
8271 assert!(md.contains("text="), "text attr present: {md}");
8272
8273 let round_tripped = markdown_to_adf(&md).unwrap();
8275 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8276 let attrs = emoji.attrs.as_ref().unwrap();
8277 assert_eq!(attrs["shortName"], ":check_mark:");
8278 assert_eq!(attrs["id"], "2705");
8279 assert_eq!(attrs["text"], "✅");
8280 }
8281
8282 #[test]
8283 fn emoji_without_extra_attrs_still_works() {
8284 let md = "Hello :wave: world\n";
8285 let doc = markdown_to_adf(md).unwrap();
8286 let emoji = &doc.content[0].content.as_ref().unwrap()[1];
8287 assert_eq!(emoji.attrs.as_ref().unwrap()["shortName"], ":wave:");
8288 assert!(emoji.attrs.as_ref().unwrap().get("id").is_none());
8290 }
8291
8292 #[test]
8293 fn emoji_shortname_preserves_colons_round_trip() {
8294 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8296 {"type":"emoji","attrs":{"shortName":":cross_mark:","id":"atlassian-cross_mark","text":"❌"}}
8297 ]}]}"#;
8298 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8299
8300 let md = adf_to_markdown(&doc).unwrap();
8302 let round_tripped = markdown_to_adf(&md).unwrap();
8303 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8304 let attrs = emoji.attrs.as_ref().unwrap();
8305 assert_eq!(
8306 attrs["shortName"], ":cross_mark:",
8307 "shortName should preserve colons, got: {}",
8308 attrs["shortName"]
8309 );
8310 assert_eq!(attrs["id"], "atlassian-cross_mark");
8311 assert_eq!(attrs["text"], "❌");
8312 }
8313
8314 #[test]
8315 fn emoji_shortname_without_colons_preserved() {
8316 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8318 {"type":"emoji","attrs":{"shortName":"white_check_mark","id":"2705","text":"✅"}}
8319 ]}]}"#;
8320 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8321 let md = adf_to_markdown(&doc).unwrap();
8322 let round_tripped = markdown_to_adf(&md).unwrap();
8323 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8324 let attrs = emoji.attrs.as_ref().unwrap();
8325 assert_eq!(
8326 attrs["shortName"], "white_check_mark",
8327 "shortName without colons should stay without colons, got: {}",
8328 attrs["shortName"]
8329 );
8330 }
8331
8332 #[test]
8333 fn colon_in_text_not_emoji() {
8334 let doc = markdown_to_adf("Time is 10:30 today").unwrap();
8336 let content = doc.content[0].content.as_ref().unwrap();
8337 assert_eq!(content.len(), 1);
8338 assert_eq!(content[0].node_type, "text");
8339 }
8340
8341 #[test]
8342 fn text_with_shortcode_pattern_round_trips_as_text() {
8343 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Alert :fire: triggered on pod:pod42"}]}]}"#;
8345 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8346
8347 let md = adf_to_markdown(&doc).unwrap();
8348 let round_tripped = markdown_to_adf(&md).unwrap();
8349 let content = round_tripped.content[0].content.as_ref().unwrap();
8350
8351 assert_eq!(
8352 content.len(),
8353 1,
8354 "should be a single text node, got: {content:?}"
8355 );
8356 assert_eq!(content[0].node_type, "text");
8357 assert_eq!(
8358 content[0].text.as_deref().unwrap(),
8359 "Alert :fire: triggered on pod:pod42"
8360 );
8361 }
8362
8363 #[test]
8364 fn double_colon_pattern_round_trips_as_text() {
8365 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status::Active::Running"}]}]}"#;
8367 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8368
8369 let md = adf_to_markdown(&doc).unwrap();
8370 let round_tripped = markdown_to_adf(&md).unwrap();
8371 let content = round_tripped.content[0].content.as_ref().unwrap();
8372
8373 assert_eq!(
8374 content.len(),
8375 1,
8376 "should be a single text node, got: {content:?}"
8377 );
8378 assert_eq!(content[0].node_type, "text");
8379 assert_eq!(
8380 content[0].text.as_deref().unwrap(),
8381 "Status::Active::Running"
8382 );
8383 }
8384
8385 #[test]
8386 fn real_emoji_node_still_round_trips() {
8387 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8389 {"type":"text","text":"Hello "},
8390 {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8391 {"type":"text","text":" world"}
8392 ]}]}"#;
8393 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8394
8395 let md = adf_to_markdown(&doc).unwrap();
8396 let round_tripped = markdown_to_adf(&md).unwrap();
8397 let content = round_tripped.content[0].content.as_ref().unwrap();
8398
8399 assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8401 assert_eq!(content[0].text.as_deref(), Some("Hello "));
8402 assert_eq!(content[1].node_type, "emoji");
8403 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":fire:");
8404 assert_eq!(content[2].text.as_deref(), Some(" world"));
8405 }
8406
8407 #[test]
8408 fn combined_emoji_shortname_round_trips_as_single_node() {
8409 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8414 {"type":"text","text":"Thanks for the help "},
8415 {"type":"emoji","attrs":{"shortName":":slightly_smiling_face::bow:","id":"","text":""}}
8416 ]}]}"#;
8417 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8418
8419 let md = adf_to_markdown(&doc).unwrap();
8420 let round_tripped = markdown_to_adf(&md).unwrap();
8421 let content = round_tripped.content[0].content.as_ref().unwrap();
8422
8423 assert_eq!(
8424 content.len(),
8425 2,
8426 "should have text + single combined emoji: {content:?}"
8427 );
8428 assert_eq!(content[0].text.as_deref(), Some("Thanks for the help "));
8429 assert_eq!(content[1].node_type, "emoji");
8430 let attrs = content[1].attrs.as_ref().unwrap();
8431 assert_eq!(attrs["shortName"], ":slightly_smiling_face::bow:");
8432 assert_eq!(attrs["id"], "");
8433 assert_eq!(attrs["text"], "");
8434 }
8435
8436 #[test]
8437 fn triple_combined_emoji_shortname_round_trips_as_single_node() {
8438 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8440 {"type":"emoji","attrs":{"shortName":":a::b::c:","id":"x","text":""}}
8441 ]}]}"#;
8442 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8443
8444 let md = adf_to_markdown(&doc).unwrap();
8445 let round_tripped = markdown_to_adf(&md).unwrap();
8446 let content = round_tripped.content[0].content.as_ref().unwrap();
8447
8448 assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8449 assert_eq!(content[0].node_type, "emoji");
8450 let attrs = content[0].attrs.as_ref().unwrap();
8451 assert_eq!(attrs["shortName"], ":a::b::c:");
8452 assert_eq!(attrs["id"], "x");
8453 }
8454
8455 #[test]
8456 fn consecutive_emojis_remain_separate_nodes() {
8457 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8461 {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8462 {"type":"emoji","attrs":{"shortName":":water:","id":"1f4a7","text":"💧"}}
8463 ]}]}"#;
8464 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8465
8466 let md = adf_to_markdown(&doc).unwrap();
8467 let round_tripped = markdown_to_adf(&md).unwrap();
8468 let content = round_tripped.content[0].content.as_ref().unwrap();
8469
8470 assert_eq!(content.len(), 2, "should be two emoji nodes: {content:?}");
8471 assert_eq!(content[0].node_type, "emoji");
8472 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8473 assert_eq!(content[1].node_type, "emoji");
8474 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8475 }
8476
8477 #[test]
8478 fn adjacent_shortcodes_without_directive_parse_as_two_emojis() {
8479 let md = ":fire::water:";
8482 let doc = markdown_to_adf(md).unwrap();
8483 let content = doc.content[0].content.as_ref().unwrap();
8484
8485 assert_eq!(content.len(), 2, "should be two emojis: {content:?}");
8486 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8487 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8488 }
8489
8490 #[test]
8491 fn combined_emoji_shortname_preserves_local_id() {
8492 let md = r#":a::b:{shortName=":a::b:" id="x" text="y" localId="abc"}"#;
8495 let doc = markdown_to_adf(md).unwrap();
8496 let content = doc.content[0].content.as_ref().unwrap();
8497
8498 assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8499 let attrs = content[0].attrs.as_ref().unwrap();
8500 assert_eq!(attrs["shortName"], ":a::b:");
8501 assert_eq!(attrs["id"], "x");
8502 assert_eq!(attrs["text"], "y");
8503 assert_eq!(attrs["localId"], "abc");
8504 }
8505
8506 #[test]
8507 fn text_shortcode_with_marks_round_trips() {
8508 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8510 {"type":"text","text":"Alert :fire: triggered","marks":[{"type":"strong"}]}
8511 ]}]}"#;
8512 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8513
8514 let md = adf_to_markdown(&doc).unwrap();
8515 let round_tripped = markdown_to_adf(&md).unwrap();
8516 let content = round_tripped.content[0].content.as_ref().unwrap();
8517
8518 assert_eq!(
8519 content.len(),
8520 1,
8521 "should be single bold text node: {content:?}"
8522 );
8523 assert_eq!(content[0].node_type, "text");
8524 assert_eq!(
8525 content[0].text.as_deref().unwrap(),
8526 "Alert :fire: triggered"
8527 );
8528 assert!(content[0]
8529 .marks
8530 .as_ref()
8531 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong")));
8532 }
8533
8534 #[test]
8535 fn mixed_emoji_node_and_text_shortcode_round_trips() {
8536 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8538 {"type":"emoji","attrs":{"shortName":":wave:","id":"1f44b","text":"👋"}},
8539 {"type":"text","text":" says :hello: to you"}
8540 ]}]}"#;
8541 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8542
8543 let md = adf_to_markdown(&doc).unwrap();
8544 let round_tripped = markdown_to_adf(&md).unwrap();
8545 let content = round_tripped.content[0].content.as_ref().unwrap();
8546
8547 assert_eq!(content.len(), 2, "should have 2 nodes: {content:?}");
8549 assert_eq!(content[0].node_type, "emoji");
8550 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":wave:");
8551 assert_eq!(content[1].node_type, "text");
8552 assert_eq!(content[1].text.as_deref().unwrap(), " says :hello: to you");
8553 }
8554
8555 #[test]
8556 fn code_block_with_shortcode_pattern_round_trips() {
8557 let adf_json = r#"{"version":1,"type":"doc","content":[
8560 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8561 {"type":"text","text":"module Foo::Bar::Baz\n def hello\n puts 'world'\n end\nend"}
8562 ]}
8563 ]}"#;
8564 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8565
8566 let md = adf_to_markdown(&doc).unwrap();
8567 let round_tripped = markdown_to_adf(&md).unwrap();
8568
8569 assert_eq!(
8570 round_tripped.content.len(),
8571 1,
8572 "should be a single codeBlock"
8573 );
8574 let cb = &round_tripped.content[0];
8575 assert_eq!(cb.node_type, "codeBlock");
8576 let content = cb.content.as_ref().expect("codeBlock content");
8577 assert_eq!(
8578 content.len(),
8579 1,
8580 "should be a single text node: {content:?}"
8581 );
8582 assert_eq!(content[0].node_type, "text");
8583 assert_eq!(
8584 content[0].text.as_deref().unwrap(),
8585 "module Foo::Bar::Baz\n def hello\n puts 'world'\n end\nend"
8586 );
8587 assert!(
8588 content.iter().all(|n| n.node_type != "emoji"),
8589 "no emoji nodes should be present, got: {content:?}"
8590 );
8591 }
8592
8593 #[test]
8594 fn code_block_with_exact_namespaced_shortcode_pattern_round_trips() {
8595 let adf_json = r#"{"version":1,"type":"doc","content":[
8597 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8598 {"type":"text","text":"class ZBC::Acme::PlanType::Professional < Base"}
8599 ]}
8600 ]}"#;
8601 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8602
8603 let md = adf_to_markdown(&doc).unwrap();
8604 let round_tripped = markdown_to_adf(&md).unwrap();
8605
8606 let cb = &round_tripped.content[0];
8607 assert_eq!(cb.node_type, "codeBlock");
8608 let content = cb.content.as_ref().expect("codeBlock content");
8609 assert_eq!(content.len(), 1, "should be a single text node");
8610 assert_eq!(
8611 content[0].text.as_deref().unwrap(),
8612 "class ZBC::Acme::PlanType::Professional < Base"
8613 );
8614 }
8615
8616 #[test]
8617 fn code_block_with_literal_shortcode_round_trips() {
8618 let adf_json = r#"{"version":1,"type":"doc","content":[
8621 {"type":"codeBlock","attrs":{"language":"text"},"content":[
8622 {"type":"text","text":":fire: :wave: :thumbsup:"}
8623 ]}
8624 ]}"#;
8625 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8626
8627 let md = adf_to_markdown(&doc).unwrap();
8628 let round_tripped = markdown_to_adf(&md).unwrap();
8629
8630 let cb = &round_tripped.content[0];
8631 assert_eq!(cb.node_type, "codeBlock");
8632 let content = cb.content.as_ref().expect("codeBlock content");
8633 assert_eq!(
8634 content.len(),
8635 1,
8636 "should be a single text node: {content:?}"
8637 );
8638 assert_eq!(content[0].node_type, "text");
8639 assert_eq!(
8640 content[0].text.as_deref().unwrap(),
8641 ":fire: :wave: :thumbsup:"
8642 );
8643 }
8644
8645 #[test]
8646 fn inline_code_with_shortcode_pattern_round_trips() {
8647 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8650 {"type":"text","text":"See "},
8651 {"type":"text","text":"Foo::Bar::Baz","marks":[{"type":"code"}]},
8652 {"type":"text","text":" for details"}
8653 ]}]}"#;
8654 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8655
8656 let md = adf_to_markdown(&doc).unwrap();
8657 let round_tripped = markdown_to_adf(&md).unwrap();
8658 let content = round_tripped.content[0].content.as_ref().unwrap();
8659
8660 assert_eq!(content.len(), 3, "should have 3 text nodes: {content:?}");
8661 assert_eq!(content[0].text.as_deref(), Some("See "));
8662 assert_eq!(content[1].text.as_deref(), Some("Foo::Bar::Baz"));
8663 assert!(content[1]
8664 .marks
8665 .as_ref()
8666 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8667 assert_eq!(content[2].text.as_deref(), Some(" for details"));
8668 assert!(
8669 content.iter().all(|n| n.node_type != "emoji"),
8670 "no emoji nodes should be present"
8671 );
8672 }
8673
8674 #[test]
8675 fn inline_code_with_literal_shortcode_round_trips() {
8676 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8679 {"type":"text","text":":fire:","marks":[{"type":"code"}]}
8680 ]}]}"#;
8681 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8682
8683 let md = adf_to_markdown(&doc).unwrap();
8684 let round_tripped = markdown_to_adf(&md).unwrap();
8685 let content = round_tripped.content[0].content.as_ref().unwrap();
8686
8687 assert_eq!(
8688 content.len(),
8689 1,
8690 "should be a single code node: {content:?}"
8691 );
8692 assert_eq!(content[0].node_type, "text");
8693 assert_eq!(content[0].text.as_deref(), Some(":fire:"));
8694 assert!(content[0]
8695 .marks
8696 .as_ref()
8697 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8698 }
8699
8700 #[test]
8701 fn code_block_in_list_with_shortcode_pattern_round_trips() {
8702 let adf_json = r#"{"version":1,"type":"doc","content":[
8705 {"type":"bulletList","content":[
8706 {"type":"listItem","content":[
8707 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8708 {"type":"text","text":"Foo::Bar::Baz"}
8709 ]}
8710 ]}
8711 ]}
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
8718 let list = &round_tripped.content[0];
8719 assert_eq!(list.node_type, "bulletList");
8720 let item = &list.content.as_ref().unwrap()[0];
8721 assert_eq!(item.node_type, "listItem");
8722 let cb = &item.content.as_ref().unwrap()[0];
8723 assert_eq!(cb.node_type, "codeBlock");
8724 let cb_content = cb.content.as_ref().unwrap();
8725 assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8726 assert_eq!(cb_content[0].node_type, "text");
8727 }
8728
8729 #[test]
8730 fn code_block_with_unicode_shortcode_pattern_round_trips() {
8731 let adf_json = r#"{"version":1,"type":"doc","content":[
8735 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8736 {"type":"text","text":"class ZBC::配置::Production < Base"}
8737 ]}
8738 ]}"#;
8739 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8740
8741 let md = adf_to_markdown(&doc).unwrap();
8742 let round_tripped = markdown_to_adf(&md).unwrap();
8743
8744 let cb = &round_tripped.content[0];
8745 assert_eq!(cb.node_type, "codeBlock");
8746 let content = cb.content.as_ref().expect("codeBlock content");
8747 assert_eq!(content.len(), 1);
8748 assert_eq!(
8749 content[0].text.as_deref().unwrap(),
8750 "class ZBC::配置::Production < Base"
8751 );
8752 }
8753
8754 #[test]
8755 fn list_item_hardbreak_then_code_block_round_trips() {
8756 let adf_json = r#"{"version":1,"type":"doc","content":[
8762 {"type":"bulletList","content":[
8763 {"type":"listItem","content":[
8764 {"type":"paragraph","content":[
8765 {"type":"text","text":"Consider removing this check."},
8766 {"type":"hardBreak"}
8767 ]},
8768 {"type":"codeBlock","content":[
8769 {"type":"text","text":"x = Foo::Bar::Baz.new"}
8770 ]}
8771 ]}
8772 ]}
8773 ]}"#;
8774 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8775
8776 let md = adf_to_markdown(&doc).unwrap();
8777 let round_tripped = markdown_to_adf(&md).unwrap();
8778
8779 let list = &round_tripped.content[0];
8780 assert_eq!(list.node_type, "bulletList");
8781 let item = &list.content.as_ref().unwrap()[0];
8782 assert_eq!(item.node_type, "listItem");
8783 let item_content = item.content.as_ref().unwrap();
8784 assert_eq!(
8785 item_content.len(),
8786 2,
8787 "listItem should have paragraph + codeBlock, got: {item_content:?}"
8788 );
8789 assert_eq!(item_content[0].node_type, "paragraph");
8790 assert_eq!(item_content[1].node_type, "codeBlock");
8791
8792 let cb_content = item_content[1].content.as_ref().unwrap();
8794 assert_eq!(cb_content[0].node_type, "text");
8795 assert_eq!(
8796 cb_content[0].text.as_deref().unwrap(),
8797 "x = Foo::Bar::Baz.new"
8798 );
8799
8800 assert!(
8802 item_content
8803 .iter()
8804 .flat_map(|n| n.content.iter().flat_map(|c| c.iter()))
8805 .all(|n| n.node_type != "emoji"),
8806 "no emoji nodes should be present, got: {item_content:?}"
8807 );
8808 }
8809
8810 #[test]
8811 fn list_item_hardbreak_then_nested_list_still_works() {
8812 let md = "- first\\\n continuation text\\\n - nested item\n";
8816 let doc = markdown_to_adf(md).unwrap();
8817 let list = &doc.content[0];
8818 assert_eq!(list.node_type, "bulletList");
8819 let item = &list.content.as_ref().unwrap()[0];
8820 let item_content = item.content.as_ref().unwrap();
8822 let para = &item_content[0];
8823 assert_eq!(para.node_type, "paragraph");
8824 let para_nodes = para.content.as_ref().unwrap();
8825 assert!(
8826 para_nodes
8827 .iter()
8828 .any(|n| n.text.as_deref() == Some("continuation text")),
8829 "continuation text should survive: {para_nodes:?}"
8830 );
8831 }
8832
8833 #[test]
8834 fn list_item_hardbreak_then_image_still_works() {
8835 let md = "- first\\\n {type=file id=x}\n";
8840 let doc = markdown_to_adf(md).unwrap();
8841 let list = &doc.content[0];
8842 let item = &list.content.as_ref().unwrap()[0];
8843 let item_content = item.content.as_ref().unwrap();
8844 assert!(
8846 item_content.iter().any(|n| n.node_type == "mediaSingle"),
8847 "mediaSingle should still be a block-level sibling, got: {item_content:?}"
8848 );
8849 }
8850
8851 #[test]
8852 fn list_item_hardbreak_then_container_directive_round_trips() {
8853 let adf_json = r#"{"version":1,"type":"doc","content":[
8857 {"type":"bulletList","content":[
8858 {"type":"listItem","content":[
8859 {"type":"paragraph","content":[
8860 {"type":"text","text":"intro"},
8861 {"type":"hardBreak"}
8862 ]},
8863 {"type":"panel","attrs":{"panelType":"info"},"content":[
8864 {"type":"paragraph","content":[
8865 {"type":"text","text":"panel body"}
8866 ]}
8867 ]}
8868 ]}
8869 ]}
8870 ]}"#;
8871 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8872 let md = adf_to_markdown(&doc).unwrap();
8873 let round_tripped = markdown_to_adf(&md).unwrap();
8874
8875 let item = &round_tripped.content[0].content.as_ref().unwrap()[0];
8876 let item_content = item.content.as_ref().unwrap();
8877 assert!(
8878 item_content.iter().any(|n| n.node_type == "panel"),
8879 "panel should survive as block-level sibling, got: {item_content:?}"
8880 );
8881 }
8882
8883 #[test]
8884 fn inline_code_with_unicode_shortcode_pattern_round_trips() {
8885 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8888 {"type":"text","text":"See "},
8889 {"type":"text","text":"ZBC::配置::Production","marks":[{"type":"code"}]},
8890 {"type":"text","text":" for prod"}
8891 ]}]}"#;
8892 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8893
8894 let md = adf_to_markdown(&doc).unwrap();
8895 let round_tripped = markdown_to_adf(&md).unwrap();
8896 let content = round_tripped.content[0].content.as_ref().unwrap();
8897
8898 assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8899 assert_eq!(content[1].text.as_deref(), Some("ZBC::配置::Production"));
8900 assert!(content[1]
8901 .marks
8902 .as_ref()
8903 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8904 }
8905
8906 #[test]
8907 fn code_block_followed_by_shortcode_text_round_trips() {
8908 let adf_json = r#"{"version":1,"type":"doc","content":[
8911 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8912 {"type":"text","text":"Foo::Bar::Baz"}
8913 ]},
8914 {"type":"paragraph","content":[
8915 {"type":"text","text":"Status::Active::Running"}
8916 ]}
8917 ]}"#;
8918 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8919
8920 let md = adf_to_markdown(&doc).unwrap();
8921 let round_tripped = markdown_to_adf(&md).unwrap();
8922
8923 assert_eq!(round_tripped.content.len(), 2);
8924 let cb = &round_tripped.content[0];
8925 assert_eq!(cb.node_type, "codeBlock");
8926 let cb_content = cb.content.as_ref().unwrap();
8927 assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8928
8929 let para = &round_tripped.content[1];
8930 assert_eq!(para.node_type, "paragraph");
8931 let para_content = para.content.as_ref().unwrap();
8932 assert_eq!(para_content.len(), 1);
8933 assert_eq!(para_content[0].node_type, "text");
8934 assert_eq!(
8935 para_content[0].text.as_deref(),
8936 Some("Status::Active::Running")
8937 );
8938 }
8939
8940 #[test]
8941 fn adf_inline_card_to_markdown() {
8942 let doc = AdfDocument {
8943 version: 1,
8944 doc_type: "doc".to_string(),
8945 content: vec![AdfNode::paragraph(vec![AdfNode {
8946 node_type: "inlineCard".to_string(),
8947 attrs: Some(
8948 serde_json::json!({"url": "https://org.atlassian.net/browse/ACCS-4382"}),
8949 ),
8950 content: None,
8951 text: None,
8952 marks: None,
8953 local_id: None,
8954 parameters: None,
8955 }])],
8956 };
8957 let md = adf_to_markdown(&doc).unwrap();
8958 assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
8959 assert!(!md.contains("<!-- unsupported inline"));
8960 }
8961
8962 #[test]
8963 fn inline_card_directive_round_trips() {
8964 let original = AdfDocument {
8966 version: 1,
8967 doc_type: "doc".to_string(),
8968 content: vec![AdfNode::paragraph(vec![AdfNode::inline_card(
8969 "https://org.atlassian.net/browse/ACCS-4382",
8970 )])],
8971 };
8972 let md = adf_to_markdown(&original).unwrap();
8973 assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
8974 let restored = markdown_to_adf(&md).unwrap();
8975 let node = &restored.content[0].content.as_ref().unwrap()[0];
8976 assert_eq!(node.node_type, "inlineCard");
8977 assert_eq!(
8978 node.attrs.as_ref().unwrap()["url"],
8979 "https://org.atlassian.net/browse/ACCS-4382"
8980 );
8981 }
8982
8983 #[test]
8984 fn inline_card_directive_parsed_from_jfm() {
8985 let doc = markdown_to_adf("See :card[https://example.com/issue/123] for details.").unwrap();
8987 let nodes = doc.content[0].content.as_ref().unwrap();
8988 assert_eq!(nodes[0].node_type, "text");
8989 assert_eq!(nodes[0].text.as_deref(), Some("See "));
8990 assert_eq!(nodes[1].node_type, "inlineCard");
8991 assert_eq!(
8992 nodes[1].attrs.as_ref().unwrap()["url"],
8993 "https://example.com/issue/123"
8994 );
8995 assert_eq!(nodes[2].node_type, "text");
8996 assert_eq!(nodes[2].text.as_deref(), Some(" for details."));
8997 }
8998
8999 #[test]
9000 fn self_link_becomes_link_mark_not_inline_card() {
9001 let doc = markdown_to_adf("[https://example.com](https://example.com)").unwrap();
9004 let node = &doc.content[0].content.as_ref().unwrap()[0];
9005 assert_eq!(node.node_type, "text");
9006 assert_eq!(node.text.as_deref(), Some("https://example.com"));
9007 let mark = &node.marks.as_ref().unwrap()[0];
9008 assert_eq!(mark.mark_type, "link");
9009 assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
9010 }
9011
9012 #[test]
9013 fn url_link_text_with_trailing_slash_mismatch_becomes_link_mark() {
9014 let doc =
9017 markdown_to_adf("[https://octopz.example.com](https://octopz.example.com/)").unwrap();
9018 let node = &doc.content[0].content.as_ref().unwrap()[0];
9019 assert_eq!(node.node_type, "text");
9020 assert_eq!(node.text.as_deref(), Some("https://octopz.example.com"));
9021 let mark = &node.marks.as_ref().unwrap()[0];
9022 assert_eq!(mark.mark_type, "link");
9023 assert_eq!(
9024 mark.attrs.as_ref().unwrap()["href"],
9025 "https://octopz.example.com/"
9026 );
9027 }
9028
9029 #[test]
9030 fn named_link_does_not_become_inline_card() {
9031 let doc = markdown_to_adf("[#4668](https://github.com/org/repo/pull/4668)").unwrap();
9033 let node = &doc.content[0].content.as_ref().unwrap()[0];
9034 assert_eq!(node.node_type, "text");
9035 assert_eq!(node.text.as_deref(), Some("#4668"));
9036 let mark = &node.marks.as_ref().unwrap()[0];
9037 assert_eq!(mark.mark_type, "link");
9038 }
9039
9040 #[test]
9041 fn adf_inline_card_no_url_to_markdown() {
9042 let doc = AdfDocument {
9043 version: 1,
9044 doc_type: "doc".to_string(),
9045 content: vec![AdfNode::paragraph(vec![AdfNode {
9046 node_type: "inlineCard".to_string(),
9047 attrs: Some(serde_json::json!({})),
9048 content: None,
9049 text: None,
9050 marks: None,
9051 local_id: None,
9052 parameters: None,
9053 }])],
9054 };
9055 let md = adf_to_markdown(&doc).unwrap();
9056 assert!(!md.contains("<!-- unsupported inline"));
9058 }
9059
9060 #[test]
9061 fn adf_code_block_no_language_to_markdown() {
9062 let doc = AdfDocument {
9063 version: 1,
9064 doc_type: "doc".to_string(),
9065 content: vec![AdfNode::code_block(None, "plain code")],
9066 };
9067 let md = adf_to_markdown(&doc).unwrap();
9068 assert!(md.contains("```\n"));
9069 assert!(md.contains("plain code"));
9070 }
9071
9072 #[test]
9073 fn adf_code_block_empty_language_to_markdown() {
9074 let doc = AdfDocument {
9075 version: 1,
9076 doc_type: "doc".to_string(),
9077 content: vec![AdfNode::code_block(Some(""), "plain code")],
9078 };
9079 let md = adf_to_markdown(&doc).unwrap();
9080 assert!(md.contains("```\"\"\n"));
9081 assert!(md.contains("plain code"));
9082 }
9083
9084 #[test]
9087 fn round_trip_table() {
9088 let md = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
9089 let adf = markdown_to_adf(md).unwrap();
9090 let restored = adf_to_markdown(&adf).unwrap();
9091 assert!(restored.contains("| A | B |"));
9092 assert!(restored.contains("| 1 | 2 |"));
9093 }
9094
9095 #[test]
9096 fn round_trip_blockquote() {
9097 let md = "> This is quoted\n";
9098 let adf = markdown_to_adf(md).unwrap();
9099 let restored = adf_to_markdown(&adf).unwrap();
9100 assert!(restored.contains("> This is quoted"));
9101 }
9102
9103 #[test]
9104 fn round_trip_image() {
9105 let md = "\n";
9106 let adf = markdown_to_adf(md).unwrap();
9107 let restored = adf_to_markdown(&adf).unwrap();
9108 assert!(restored.contains(""));
9109 }
9110
9111 #[test]
9112 fn round_trip_ordered_list() {
9113 let md = "1. A\n2. B\n3. C\n";
9114 let adf = markdown_to_adf(md).unwrap();
9115 let restored = adf_to_markdown(&adf).unwrap();
9116 assert!(restored.contains("1. A"));
9117 assert!(restored.contains("2. B"));
9118 assert!(restored.contains("3. C"));
9119 }
9120
9121 #[test]
9122 fn round_trip_inline_marks() {
9123 let md = "Text with `code` and ~~strike~~ and [link](https://x.com).\n";
9124 let adf = markdown_to_adf(md).unwrap();
9125 let restored = adf_to_markdown(&adf).unwrap();
9126 assert!(restored.contains("`code`"));
9127 assert!(restored.contains("~~strike~~"));
9128 assert!(restored.contains("[link](https://x.com)"));
9129 }
9130
9131 #[test]
9134 fn panel_info() {
9135 let md = ":::panel{type=info}\nThis is informational.\n:::";
9136 let doc = markdown_to_adf(md).unwrap();
9137 assert_eq!(doc.content[0].node_type, "panel");
9138 assert_eq!(doc.content[0].attrs.as_ref().unwrap()["panelType"], "info");
9139 let inner = doc.content[0].content.as_ref().unwrap();
9140 assert_eq!(inner[0].node_type, "paragraph");
9141 }
9142
9143 #[test]
9144 fn adf_panel_to_markdown() {
9145 let doc = AdfDocument {
9146 version: 1,
9147 doc_type: "doc".to_string(),
9148 content: vec![AdfNode::panel(
9149 "warning",
9150 vec![AdfNode::paragraph(vec![AdfNode::text("Be careful.")])],
9151 )],
9152 };
9153 let md = adf_to_markdown(&doc).unwrap();
9154 assert!(md.contains(":::panel{type=warning}"));
9155 assert!(md.contains("Be careful."));
9156 assert!(md.contains(":::"));
9157 }
9158
9159 #[test]
9160 fn round_trip_panel() {
9161 let md = ":::panel{type=info}\nThis is informational.\n:::\n";
9162 let doc = markdown_to_adf(md).unwrap();
9163 let result = adf_to_markdown(&doc).unwrap();
9164 assert!(result.contains(":::panel{type=info}"));
9165 assert!(result.contains("This is informational."));
9166 }
9167
9168 #[test]
9169 fn expand_with_title() {
9170 let md = ":::expand{title=\"Click me\"}\nHidden content.\n:::";
9171 let doc = markdown_to_adf(md).unwrap();
9172 assert_eq!(doc.content[0].node_type, "expand");
9173 assert_eq!(doc.content[0].attrs.as_ref().unwrap()["title"], "Click me");
9174 }
9175
9176 #[test]
9177 fn adf_expand_to_markdown() {
9178 let doc = AdfDocument {
9179 version: 1,
9180 doc_type: "doc".to_string(),
9181 content: vec![AdfNode::expand(
9182 Some("Details"),
9183 vec![AdfNode::paragraph(vec![AdfNode::text("Inner.")])],
9184 )],
9185 };
9186 let md = adf_to_markdown(&doc).unwrap();
9187 assert!(md.contains(":::expand{title=\"Details\"}"));
9188 assert!(md.contains("Inner."));
9189 }
9190
9191 #[test]
9192 fn round_trip_expand() {
9193 let md = ":::expand{title=\"Details\"}\nInner content.\n:::\n";
9194 let doc = markdown_to_adf(md).unwrap();
9195 let result = adf_to_markdown(&doc).unwrap();
9196 assert!(result.contains(":::expand{title=\"Details\"}"));
9197 assert!(result.contains("Inner content."));
9198 }
9199
9200 #[test]
9201 fn layout_two_columns() {
9202 let md =
9203 "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
9204 let doc = markdown_to_adf(md).unwrap();
9205 assert_eq!(doc.content[0].node_type, "layoutSection");
9206 let columns = doc.content[0].content.as_ref().unwrap();
9207 assert_eq!(columns.len(), 2);
9208 assert_eq!(columns[0].node_type, "layoutColumn");
9209 assert_eq!(columns[1].node_type, "layoutColumn");
9210 }
9211
9212 #[test]
9213 fn adf_layout_to_markdown() {
9214 let doc = AdfDocument {
9215 version: 1,
9216 doc_type: "doc".to_string(),
9217 content: vec![AdfNode::layout_section(vec![
9218 AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Left.")])]),
9219 AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Right.")])]),
9220 ])],
9221 };
9222 let md = adf_to_markdown(&doc).unwrap();
9223 assert!(md.contains("::::layout"));
9224 assert!(md.contains(":::column{width=50}"));
9225 assert!(md.contains("Left."));
9226 assert!(md.contains("Right."));
9227 }
9228
9229 #[test]
9230 fn layout_column_localid_roundtrip() {
9231 let adf_json = r#"{
9232 "version": 1,
9233 "type": "doc",
9234 "content": [{
9235 "type": "layoutSection",
9236 "content": [
9237 {
9238 "type": "layoutColumn",
9239 "attrs": {"width": 50.0, "localId": "aabb112233cc"},
9240 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Left"}]}]
9241 },
9242 {
9243 "type": "layoutColumn",
9244 "attrs": {"width": 50.0, "localId": "ddeeff445566"},
9245 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Right"}]}]
9246 }
9247 ]
9248 }]
9249 }"#;
9250 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9251 let md = adf_to_markdown(&doc).unwrap();
9252 assert!(
9253 md.contains("localId=aabb112233cc"),
9254 "first column localId should appear in markdown: {md}"
9255 );
9256 assert!(
9257 md.contains("localId=ddeeff445566"),
9258 "second column localId should appear in markdown: {md}"
9259 );
9260 let rt = markdown_to_adf(&md).unwrap();
9261 let cols = rt.content[0].content.as_ref().unwrap();
9262 assert_eq!(
9263 cols[0].attrs.as_ref().unwrap()["localId"],
9264 "aabb112233cc",
9265 "first column localId should round-trip"
9266 );
9267 assert_eq!(
9268 cols[1].attrs.as_ref().unwrap()["localId"],
9269 "ddeeff445566",
9270 "second column localId should round-trip"
9271 );
9272 }
9273
9274 #[test]
9275 fn layout_column_without_localid() {
9276 let md =
9277 "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
9278 let doc = markdown_to_adf(md).unwrap();
9279 let cols = doc.content[0].content.as_ref().unwrap();
9280 assert!(
9281 cols[0].attrs.as_ref().unwrap().get("localId").is_none(),
9282 "column without localId should not gain one"
9283 );
9284 let md2 = adf_to_markdown(&doc).unwrap();
9285 assert!(
9286 !md2.contains("localId"),
9287 "no localId should appear in output: {md2}"
9288 );
9289 }
9290
9291 #[test]
9292 fn layout_column_localid_stripped_when_option_set() {
9293 let adf_json = r#"{
9294 "version": 1,
9295 "type": "doc",
9296 "content": [{
9297 "type": "layoutSection",
9298 "content": [{
9299 "type": "layoutColumn",
9300 "attrs": {"width": 50.0, "localId": "aabb112233cc"},
9301 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Col"}]}]
9302 }]
9303 }]
9304 }"#;
9305 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9306 let opts = RenderOptions {
9307 strip_local_ids: true,
9308 ..Default::default()
9309 };
9310 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
9311 assert!(!md.contains("localId"), "localId should be stripped: {md}");
9312 }
9313
9314 #[test]
9315 fn layout_column_localid_flush_previous() {
9316 let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nLeft.\n:::column{width=50 localId=ddeeff445566}\nRight.\n:::\n::::";
9318 let doc = markdown_to_adf(md).unwrap();
9319 let cols = doc.content[0].content.as_ref().unwrap();
9320 assert_eq!(
9321 cols[0].attrs.as_ref().unwrap()["localId"],
9322 "aabb112233cc",
9323 "flush-previous column should preserve localId"
9324 );
9325 assert_eq!(
9326 cols[1].attrs.as_ref().unwrap()["localId"],
9327 "ddeeff445566",
9328 "second column localId should be preserved"
9329 );
9330 }
9331
9332 #[test]
9333 fn layout_column_localid_flush_last() {
9334 let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nOnly column.";
9336 let doc = markdown_to_adf(md).unwrap();
9337 let cols = doc.content[0].content.as_ref().unwrap();
9338 assert_eq!(
9339 cols[0].attrs.as_ref().unwrap()["localId"],
9340 "aabb112233cc",
9341 "flush-last column should preserve localId"
9342 );
9343 }
9344
9345 #[test]
9347 fn issue_555_layout_column_fractional_width_roundtrip() {
9348 let adf_json = r#"{
9349 "version": 1,
9350 "type": "doc",
9351 "content": [{
9352 "type": "layoutSection",
9353 "content": [
9354 {
9355 "type": "layoutColumn",
9356 "attrs": {"width": 66.66},
9357 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9358 },
9359 {
9360 "type": "layoutColumn",
9361 "attrs": {"width": 33.34},
9362 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9363 }
9364 ]
9365 }]
9366 }"#;
9367 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9368 let md = adf_to_markdown(&doc).unwrap();
9369 assert!(md.contains("width=66.66"), "fractional width in md: {md}");
9370 assert!(md.contains("width=33.34"), "fractional width in md: {md}");
9371 let rt = markdown_to_adf(&md).unwrap();
9372 let cols = rt.content[0].content.as_ref().unwrap();
9373 assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 66.66);
9374 assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 33.34);
9375 }
9376
9377 #[test]
9379 fn issue_555_layout_column_five_sixths_width_roundtrip() {
9380 let adf_json = r#"{
9381 "version": 1,
9382 "type": "doc",
9383 "content": [{
9384 "type": "layoutSection",
9385 "content": [
9386 {
9387 "type": "layoutColumn",
9388 "attrs": {"width": 83.33},
9389 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9390 },
9391 {
9392 "type": "layoutColumn",
9393 "attrs": {"width": 16.67},
9394 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9395 }
9396 ]
9397 }]
9398 }"#;
9399 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9400 let md = adf_to_markdown(&doc).unwrap();
9401 let rt = markdown_to_adf(&md).unwrap();
9402 let cols = rt.content[0].content.as_ref().unwrap();
9403 assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 83.33);
9404 assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 16.67);
9405 }
9406
9407 #[test]
9409 fn issue_555_layout_column_integer_width_preserved() {
9410 let adf_json = r#"{
9411 "version": 1,
9412 "type": "doc",
9413 "content": [{
9414 "type": "layoutSection",
9415 "content": [
9416 {
9417 "type": "layoutColumn",
9418 "attrs": {"width": 50},
9419 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "A"}]}]
9420 },
9421 {
9422 "type": "layoutColumn",
9423 "attrs": {"width": 50},
9424 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "B"}]}]
9425 }
9426 ]
9427 }]
9428 }"#;
9429 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9430 let md = adf_to_markdown(&doc).unwrap();
9431 assert!(
9432 md.contains("width=50") && !md.contains("width=50."),
9433 "integer width should render without decimal: {md}"
9434 );
9435 let rt = markdown_to_adf(&md).unwrap();
9436 let cols = rt.content[0].content.as_ref().unwrap();
9437 let w0 = &cols[0].attrs.as_ref().unwrap()["width"];
9438 assert!(
9439 w0.is_i64() || w0.is_u64(),
9440 "width should remain a JSON integer, got: {w0}"
9441 );
9442 assert_eq!(w0.as_i64(), Some(50));
9443 }
9444
9445 #[test]
9447 fn issue_555_media_single_integer_width_preserved() {
9448 let adf_json = r#"{
9449 "version": 1,
9450 "type": "doc",
9451 "content": [{
9452 "type": "mediaSingle",
9453 "attrs": {"layout": "center", "width": 800},
9454 "content": [
9455 {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9456 ]
9457 }]
9458 }"#;
9459 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9460 let md = adf_to_markdown(&doc).unwrap();
9461 assert!(
9462 md.contains("width=800") && !md.contains("width=800."),
9463 "integer width should render without decimal: {md}"
9464 );
9465 let rt = markdown_to_adf(&md).unwrap();
9466 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9467 let w = &ms_attrs["width"];
9468 assert!(
9469 w.is_i64() || w.is_u64(),
9470 "mediaSingle width should remain JSON integer, got: {w}"
9471 );
9472 assert_eq!(w.as_i64(), Some(800));
9473 }
9474
9475 #[test]
9479 fn issue_555_media_single_fractional_width_preserved() {
9480 let adf_json = r#"{
9481 "version": 1,
9482 "type": "doc",
9483 "content": [{
9484 "type": "mediaSingle",
9485 "attrs": {"layout": "center", "width": 66.5},
9486 "content": [
9487 {"type": "media", "attrs": {"type": "external", "url": "https://example.com/diagram.png"}}
9488 ]
9489 }]
9490 }"#;
9491 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9492 let md = adf_to_markdown(&doc).unwrap();
9493 assert!(
9494 md.contains("width=66.5"),
9495 "fractional width must appear in JFM: {md}"
9496 );
9497 let rt = markdown_to_adf(&md).unwrap();
9498 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9499 assert_eq!(ms_attrs["width"], 66.5);
9500 }
9501
9502 #[test]
9504 fn issue_555_media_single_float_width_preserved() {
9505 let adf_json = r#"{
9506 "version": 1,
9507 "type": "doc",
9508 "content": [{
9509 "type": "mediaSingle",
9510 "attrs": {"layout": "center", "width": 800.0},
9511 "content": [
9512 {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9513 ]
9514 }]
9515 }"#;
9516 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9517 let md = adf_to_markdown(&doc).unwrap();
9518 assert!(
9519 md.contains("width=800.0"),
9520 "float width should render with decimal: {md}"
9521 );
9522 let rt = markdown_to_adf(&md).unwrap();
9523 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9524 let w = &ms_attrs["width"];
9525 assert!(
9526 w.is_f64(),
9527 "mediaSingle float width should stay a JSON float, got: {w}"
9528 );
9529 assert_eq!(w.as_f64(), Some(800.0));
9530 }
9531
9532 #[test]
9534 fn issue_555_media_single_wide_layout_integer_width_roundtrip() {
9535 let adf_json = r#"{
9536 "version": 1,
9537 "type": "doc",
9538 "content": [{
9539 "type": "mediaSingle",
9540 "attrs": {"layout": "wide", "width": 1420},
9541 "content": [
9542 {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9543 ]
9544 }]
9545 }"#;
9546 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9547 let md = adf_to_markdown(&doc).unwrap();
9548 let rt = markdown_to_adf(&md).unwrap();
9549 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9550 assert_eq!(ms_attrs["layout"], "wide");
9551 let w = &ms_attrs["width"];
9552 assert!(
9553 w.is_i64() || w.is_u64(),
9554 "mediaSingle width should remain JSON integer, got: {w}"
9555 );
9556 assert_eq!(w.as_i64(), Some(1420));
9557 }
9558
9559 #[test]
9562 fn issue_555_file_media_single_integer_width_preserved() {
9563 let adf_json = r#"{
9564 "version": 1,
9565 "type": "doc",
9566 "content": [{
9567 "type": "mediaSingle",
9568 "attrs": {"layout": "wide", "width": 1420},
9569 "content": [
9570 {"type": "media", "attrs": {"id": "abc-123", "type": "file", "collection": "col-1", "width": 1200, "height": 800}}
9571 ]
9572 }]
9573 }"#;
9574 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9575 let md = adf_to_markdown(&doc).unwrap();
9576 let rt = markdown_to_adf(&md).unwrap();
9577 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9578 let ms_w = &ms_attrs["width"];
9579 assert!(ms_w.is_i64() || ms_w.is_u64(), "ms width: {ms_w}");
9580 assert_eq!(ms_w.as_i64(), Some(1420));
9581 let media = &rt.content[0].content.as_ref().unwrap()[0];
9582 let media_attrs = media.attrs.as_ref().unwrap();
9583 let mw = &media_attrs["width"];
9584 assert!(mw.is_i64() || mw.is_u64(), "media width: {mw}");
9585 assert_eq!(mw.as_i64(), Some(1200));
9586 let mh = &media_attrs["height"];
9587 assert!(mh.is_i64() || mh.is_u64(), "media height: {mh}");
9588 assert_eq!(mh.as_i64(), Some(800));
9589 }
9590
9591 #[test]
9593 fn issue_555_fmt_numeric_attr_preserves_type() {
9594 assert_eq!(
9595 fmt_numeric_attr(&serde_json::json!(50)).as_deref(),
9596 Some("50")
9597 );
9598 assert_eq!(
9599 fmt_numeric_attr(&serde_json::json!(50.0)).as_deref(),
9600 Some("50.0")
9601 );
9602 assert_eq!(
9603 fmt_numeric_attr(&serde_json::json!(66.66)).as_deref(),
9604 Some("66.66")
9605 );
9606 assert_eq!(
9607 fmt_numeric_attr(&serde_json::json!(-5)).as_deref(),
9608 Some("-5")
9609 );
9610 assert_eq!(fmt_numeric_attr(&serde_json::json!("not a number")), None);
9611 let big = serde_json::Value::Number(serde_json::Number::from(u64::MAX));
9613 assert_eq!(
9614 fmt_numeric_attr(&big).as_deref(),
9615 Some("18446744073709551615")
9616 );
9617 assert_eq!(fmt_numeric_attr(&serde_json::Value::Null), None);
9619 }
9620
9621 #[test]
9623 fn issue_555_parse_numeric_attr_detects_type() {
9624 let v = parse_numeric_attr("800").unwrap();
9625 assert!(v.is_i64() || v.is_u64(), "'800' should parse as integer");
9626 assert_eq!(v.as_i64(), Some(800));
9627
9628 let v = parse_numeric_attr("800.0").unwrap();
9629 assert!(v.is_f64(), "'800.0' should parse as float");
9630 assert_eq!(v.as_f64(), Some(800.0));
9631
9632 let v = parse_numeric_attr("66.66").unwrap();
9633 assert!(v.is_f64());
9634 assert_eq!(v.as_f64(), Some(66.66));
9635
9636 let v = parse_numeric_attr("1e2").unwrap();
9638 assert!(v.is_f64());
9639 assert_eq!(v.as_f64(), Some(100.0));
9640 let v = parse_numeric_attr("1E2").unwrap();
9641 assert!(v.is_f64());
9642 assert_eq!(v.as_f64(), Some(100.0));
9643
9644 let v = parse_numeric_attr("-42").unwrap();
9646 assert!(v.is_i64());
9647 assert_eq!(v.as_i64(), Some(-42));
9648
9649 assert!(parse_numeric_attr("not-a-number").is_none());
9650 assert!(parse_numeric_attr("").is_none());
9651 assert!(parse_numeric_attr("1.2.3").is_none());
9652 }
9653
9654 #[test]
9657 fn issue_555_media_single_wide_layout_fractional_width_roundtrip() {
9658 let adf_json = r#"{
9659 "version": 1,
9660 "type": "doc",
9661 "content": [{
9662 "type": "mediaSingle",
9663 "attrs": {"layout": "wide", "width": 83.33},
9664 "content": [
9665 {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9666 ]
9667 }]
9668 }"#;
9669 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9670 let md = adf_to_markdown(&doc).unwrap();
9671 assert!(md.contains("layout=wide"), "layout must appear in md: {md}");
9672 assert!(md.contains("width=83.33"), "width must appear in md: {md}");
9673 let rt = markdown_to_adf(&md).unwrap();
9674 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9675 assert_eq!(ms_attrs["layout"], "wide");
9676 assert_eq!(ms_attrs["width"], 83.33);
9677 }
9678
9679 #[test]
9683 fn issue_555_file_media_single_fractional_media_width_preserved() {
9684 let adf_json = r#"{
9685 "version": 1,
9686 "type": "doc",
9687 "content": [{
9688 "type": "mediaSingle",
9689 "attrs": {"layout": "wide", "width": 66.5},
9690 "content": [
9691 {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c"}}
9692 ]
9693 }]
9694 }"#;
9695 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9696 let md = adf_to_markdown(&doc).unwrap();
9697 assert!(md.contains("mediaWidth=66.5"), "mediaWidth in md: {md}");
9698 let rt = markdown_to_adf(&md).unwrap();
9699 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9700 assert_eq!(ms_attrs["width"], 66.5);
9701 }
9702
9703 #[test]
9707 fn issue_555_file_media_fractional_inner_dimensions_preserved() {
9708 let adf_json = r#"{
9709 "version": 1,
9710 "type": "doc",
9711 "content": [{
9712 "type": "mediaSingle",
9713 "attrs": {"layout": "center"},
9714 "content": [
9715 {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c", "width": 1200.5, "height": 800.25}}
9716 ]
9717 }]
9718 }"#;
9719 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9720 let md = adf_to_markdown(&doc).unwrap();
9721 assert!(md.contains("width=1200.5"), "width in md: {md}");
9722 assert!(md.contains("height=800.25"), "height in md: {md}");
9723 let rt = markdown_to_adf(&md).unwrap();
9724 let media = &rt.content[0].content.as_ref().unwrap()[0];
9725 let attrs = media.attrs.as_ref().unwrap();
9726 assert_eq!(attrs["width"], 1200.5);
9727 assert_eq!(attrs["height"], 800.25);
9728 }
9729
9730 #[test]
9731 fn decisions_list() {
9732 let md = ":::decisions\n- <> Use PostgreSQL\n- <> REST API\n:::";
9733 let doc = markdown_to_adf(md).unwrap();
9734 assert_eq!(doc.content[0].node_type, "decisionList");
9735 let items = doc.content[0].content.as_ref().unwrap();
9736 assert_eq!(items.len(), 2);
9737 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DECIDED");
9738 }
9739
9740 #[test]
9743 fn decision_item_content_is_inline_not_paragraph() {
9744 let md = ":::decisions\n- <> Use Rust\n:::";
9745 let doc = markdown_to_adf(md).unwrap();
9746 let items = doc.content[0].content.as_ref().unwrap();
9747 let first_child = &items[0].content.as_ref().unwrap()[0];
9748 assert_eq!(
9749 first_child.node_type, "text",
9750 "decisionItem must contain inline nodes directly, not a paragraph wrapper"
9751 );
9752 assert_eq!(first_child.text.as_deref(), Some("Use Rust"));
9753 }
9754
9755 #[test]
9756 fn adf_decisions_to_markdown() {
9757 let doc = AdfDocument {
9758 version: 1,
9759 doc_type: "doc".to_string(),
9760 content: vec![AdfNode::decision_list(vec![AdfNode::decision_item(
9761 "DECIDED",
9762 vec![AdfNode::paragraph(vec![AdfNode::text("Use PostgreSQL")])],
9763 )])],
9764 };
9765 let md = adf_to_markdown(&doc).unwrap();
9766 assert!(md.contains(":::decisions"));
9767 assert!(md.contains("- <> Use PostgreSQL"));
9768 }
9769
9770 #[test]
9771 fn bodied_extension_container() {
9772 let md = ":::extension{type=com.forge key=my-macro}\nContent.\n:::";
9773 let doc = markdown_to_adf(md).unwrap();
9774 assert_eq!(doc.content[0].node_type, "bodiedExtension");
9775 assert_eq!(
9776 doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9777 "com.forge"
9778 );
9779 }
9780
9781 #[test]
9782 fn adf_bodied_extension_to_markdown() {
9783 let doc = AdfDocument {
9784 version: 1,
9785 doc_type: "doc".to_string(),
9786 content: vec![AdfNode::bodied_extension(
9787 "com.forge",
9788 "my-macro",
9789 vec![AdfNode::paragraph(vec![AdfNode::text("Content.")])],
9790 )],
9791 };
9792 let md = adf_to_markdown(&doc).unwrap();
9793 assert!(md.contains(":::extension{type=com.forge key=my-macro}"));
9794 assert!(md.contains("Content."));
9795 }
9796
9797 #[test]
9800 fn leaf_block_card() {
9801 let doc = markdown_to_adf("::card[https://example.com/browse/PROJ-123]").unwrap();
9802 assert_eq!(doc.content[0].node_type, "blockCard");
9803 assert_eq!(
9804 doc.content[0].attrs.as_ref().unwrap()["url"],
9805 "https://example.com/browse/PROJ-123"
9806 );
9807 }
9808
9809 #[test]
9810 fn adf_block_card_to_markdown() {
9811 let doc = AdfDocument {
9812 version: 1,
9813 doc_type: "doc".to_string(),
9814 content: vec![AdfNode::block_card("https://example.com/browse/PROJ-123")],
9815 };
9816 let md = adf_to_markdown(&doc).unwrap();
9817 assert!(md.contains("::card[https://example.com/browse/PROJ-123]"));
9818 }
9819
9820 #[test]
9821 fn round_trip_block_card() {
9822 let md = "::card[https://example.com/browse/PROJ-123]\n";
9823 let doc = markdown_to_adf(md).unwrap();
9824 let result = adf_to_markdown(&doc).unwrap();
9825 assert!(result.contains("::card[https://example.com/browse/PROJ-123]"));
9826 }
9827
9828 #[test]
9829 fn leaf_embed_card() {
9830 let doc =
9831 markdown_to_adf("::embed[https://figma.com/file/abc]{layout=wide width=80}").unwrap();
9832 assert_eq!(doc.content[0].node_type, "embedCard");
9833 let attrs = doc.content[0].attrs.as_ref().unwrap();
9834 assert_eq!(attrs["url"], "https://figma.com/file/abc");
9835 assert_eq!(attrs["layout"], "wide");
9836 assert_eq!(attrs["width"], 80.0);
9837 }
9838
9839 #[test]
9840 fn leaf_embed_card_with_original_height() {
9841 let doc = markdown_to_adf(
9842 "::embed[https://example.com]{layout=center originalHeight=732 width=100}",
9843 )
9844 .unwrap();
9845 assert_eq!(doc.content[0].node_type, "embedCard");
9846 let attrs = doc.content[0].attrs.as_ref().unwrap();
9847 assert_eq!(attrs["url"], "https://example.com");
9848 assert_eq!(attrs["layout"], "center");
9849 assert_eq!(attrs["originalHeight"], 732.0);
9850 assert_eq!(attrs["width"], 100.0);
9851 }
9852
9853 #[test]
9854 fn adf_embed_card_to_markdown() {
9855 let doc = AdfDocument {
9856 version: 1,
9857 doc_type: "doc".to_string(),
9858 content: vec![AdfNode::embed_card(
9859 "https://figma.com/file/abc",
9860 Some("wide"),
9861 None,
9862 Some(80.0),
9863 )],
9864 };
9865 let md = adf_to_markdown(&doc).unwrap();
9866 assert!(md.contains("::embed[https://figma.com/file/abc]{layout=wide width=80}"));
9867 }
9868
9869 #[test]
9870 fn adf_embed_card_original_height_to_markdown() {
9871 let doc = AdfDocument {
9872 version: 1,
9873 doc_type: "doc".to_string(),
9874 content: vec![AdfNode::embed_card(
9875 "https://example.com",
9876 Some("center"),
9877 Some(732.0),
9878 Some(100.0),
9879 )],
9880 };
9881 let md = adf_to_markdown(&doc).unwrap();
9882 assert!(
9883 md.contains("::embed[https://example.com]{layout=center originalHeight=732 width=100}"),
9884 "expected originalHeight and width in md: {md}"
9885 );
9886 }
9887
9888 #[test]
9889 fn embed_card_roundtrip_with_all_attrs() {
9890 let adf_json = r#"{"version":1,"type":"doc","content":[{
9891 "type":"embedCard",
9892 "attrs":{"layout":"center","originalHeight":732.0,"url":"https://example.com","width":100.0}
9893 }]}"#;
9894 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9895 let md = adf_to_markdown(&doc).unwrap();
9896 assert!(
9897 md.contains("originalHeight=732"),
9898 "originalHeight missing from md: {md}"
9899 );
9900 assert!(md.contains("width=100"), "width missing from md: {md}");
9901 let rt = markdown_to_adf(&md).unwrap();
9902 let attrs = rt.content[0].attrs.as_ref().unwrap();
9903 assert_eq!(attrs["originalHeight"], 732.0);
9904 assert_eq!(attrs["width"], 100.0);
9905 assert_eq!(attrs["layout"], "center");
9906 assert_eq!(attrs["url"], "https://example.com");
9907 }
9908
9909 #[test]
9910 fn embed_card_fractional_dimensions() {
9911 let doc = AdfDocument {
9912 version: 1,
9913 doc_type: "doc".to_string(),
9914 content: vec![AdfNode::embed_card(
9915 "https://example.com",
9916 Some("center"),
9917 Some(732.5),
9918 Some(99.9),
9919 )],
9920 };
9921 let md = adf_to_markdown(&doc).unwrap();
9922 assert!(
9923 md.contains("originalHeight=732.5"),
9924 "fractional originalHeight missing: {md}"
9925 );
9926 assert!(md.contains("width=99.9"), "fractional width missing: {md}");
9927 let rt = markdown_to_adf(&md).unwrap();
9928 let attrs = rt.content[0].attrs.as_ref().unwrap();
9929 assert_eq!(attrs["originalHeight"], 732.5);
9930 assert_eq!(attrs["width"], 99.9);
9931 }
9932
9933 #[test]
9934 fn embed_card_integer_width_in_json() {
9935 let adf_json = r#"{"version":1,"type":"doc","content":[{
9937 "type":"embedCard",
9938 "attrs":{"url":"https://example.com","width":100}
9939 }]}"#;
9940 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9941 let md = adf_to_markdown(&doc).unwrap();
9942 assert!(
9943 md.contains("width=100"),
9944 "integer width missing from md: {md}"
9945 );
9946 let rt = markdown_to_adf(&md).unwrap();
9947 assert_eq!(rt.content[0].attrs.as_ref().unwrap()["width"], 100.0);
9948 }
9949
9950 #[test]
9951 fn embed_card_only_original_height() {
9952 let adf_json = r#"{"version":1,"type":"doc","content":[{
9954 "type":"embedCard",
9955 "attrs":{"url":"https://example.com","originalHeight":500.0}
9956 }]}"#;
9957 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9958 let md = adf_to_markdown(&doc).unwrap();
9959 assert!(
9960 md.contains("originalHeight=500"),
9961 "originalHeight missing: {md}"
9962 );
9963 assert!(!md.contains("width="), "width should not appear: {md}");
9964 let rt = markdown_to_adf(&md).unwrap();
9965 let attrs = rt.content[0].attrs.as_ref().unwrap();
9966 assert_eq!(attrs["originalHeight"], 500.0);
9967 assert!(attrs.get("width").is_none());
9968 }
9969
9970 #[test]
9971 fn leaf_void_extension() {
9972 let doc = markdown_to_adf("::extension{type=com.atlassian.macro key=jira-chart}").unwrap();
9973 assert_eq!(doc.content[0].node_type, "extension");
9974 assert_eq!(
9975 doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9976 "com.atlassian.macro"
9977 );
9978 assert_eq!(
9979 doc.content[0].attrs.as_ref().unwrap()["extensionKey"],
9980 "jira-chart"
9981 );
9982 }
9983
9984 #[test]
9985 fn adf_void_extension_to_markdown() {
9986 let doc = AdfDocument {
9987 version: 1,
9988 doc_type: "doc".to_string(),
9989 content: vec![AdfNode::extension(
9990 "com.atlassian.macro",
9991 "jira-chart",
9992 None,
9993 )],
9994 };
9995 let md = adf_to_markdown(&doc).unwrap();
9996 assert!(md.contains("::extension{type=com.atlassian.macro key=jira-chart}"));
9997 }
9998
9999 #[test]
10002 fn bare_url_autolink() {
10003 let doc = markdown_to_adf("Visit https://example.com today").unwrap();
10004 let content = doc.content[0].content.as_ref().unwrap();
10005 assert_eq!(content[0].text.as_deref(), Some("Visit "));
10006 assert_eq!(content[1].node_type, "inlineCard");
10007 assert_eq!(
10008 content[1].attrs.as_ref().unwrap()["url"],
10009 "https://example.com"
10010 );
10011 assert_eq!(content[2].text.as_deref(), Some(" today"));
10012 }
10013
10014 #[test]
10015 fn bare_url_strips_trailing_punctuation() {
10016 let doc = markdown_to_adf("See https://example.com.").unwrap();
10017 let content = doc.content[0].content.as_ref().unwrap();
10018 assert_eq!(
10019 content[1].attrs.as_ref().unwrap()["url"],
10020 "https://example.com"
10021 );
10022 }
10023
10024 #[test]
10025 fn bare_url_round_trip() {
10026 let doc = markdown_to_adf("Visit https://example.com/path today").unwrap();
10027 let md = adf_to_markdown(&doc).unwrap();
10028 assert!(md.contains(":card[https://example.com/path]"));
10029 }
10030
10031 #[test]
10034 fn plain_text_url_round_trips_as_text() {
10035 let adf_json = r#"{
10038 "version": 1,
10039 "type": "doc",
10040 "content": [{
10041 "type": "paragraph",
10042 "content": [
10043 {"type": "text", "text": "https://example.com/some/path/to/resource"}
10044 ]
10045 }]
10046 }"#;
10047 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10048 let jfm = adf_to_markdown(&adf).unwrap();
10049 let roundtripped = markdown_to_adf(&jfm).unwrap();
10050 let content = roundtripped.content[0].content.as_ref().unwrap();
10051 assert_eq!(content.len(), 1, "should be a single node");
10052 assert_eq!(content[0].node_type, "text");
10053 assert_eq!(
10054 content[0].text.as_deref(),
10055 Some("https://example.com/some/path/to/resource")
10056 );
10057 }
10058
10059 #[test]
10060 fn url_text_with_link_mark_round_trips_as_text_node() {
10061 let adf_json = r#"{
10065 "version": 1,
10066 "type": "doc",
10067 "content": [{
10068 "type": "paragraph",
10069 "content": [{
10070 "type": "text",
10071 "text": "https://octopz.example.com",
10072 "marks": [{"type": "link", "attrs": {"href": "https://octopz.example.com/"}}]
10073 }]
10074 }]
10075 }"#;
10076 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10077 let jfm = adf_to_markdown(&adf).unwrap();
10078 let roundtripped = markdown_to_adf(&jfm).unwrap();
10079 let content = roundtripped.content[0].content.as_ref().unwrap();
10080 assert_eq!(content.len(), 1, "should be a single node");
10081 assert_eq!(content[0].node_type, "text", "must be text, not inlineCard");
10082 assert_eq!(
10083 content[0].text.as_deref(),
10084 Some("https://octopz.example.com")
10085 );
10086 let mark = &content[0].marks.as_ref().unwrap()[0];
10087 assert_eq!(mark.mark_type, "link");
10088 assert_eq!(
10089 mark.attrs.as_ref().unwrap()["href"],
10090 "https://octopz.example.com/"
10091 );
10092 }
10093
10094 #[test]
10095 fn url_text_with_exact_link_mark_round_trips() {
10096 let adf_json = r#"{
10098 "version": 1,
10099 "type": "doc",
10100 "content": [{
10101 "type": "paragraph",
10102 "content": [{
10103 "type": "text",
10104 "text": "https://example.com/path",
10105 "marks": [{"type": "link", "attrs": {"href": "https://example.com/path"}}]
10106 }]
10107 }]
10108 }"#;
10109 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10110 let jfm = adf_to_markdown(&adf).unwrap();
10111 let roundtripped = markdown_to_adf(&jfm).unwrap();
10112 let content = roundtripped.content[0].content.as_ref().unwrap();
10113 assert_eq!(content.len(), 1, "should be a single node");
10114 assert_eq!(content[0].node_type, "text");
10115 assert_eq!(content[0].text.as_deref(), Some("https://example.com/path"));
10116 let mark = &content[0].marks.as_ref().unwrap()[0];
10117 assert_eq!(mark.mark_type, "link");
10118 }
10119
10120 #[test]
10121 fn plain_text_url_amid_text_round_trips() {
10122 let adf_json = r#"{
10124 "version": 1,
10125 "type": "doc",
10126 "content": [{
10127 "type": "paragraph",
10128 "content": [
10129 {"type": "text", "text": "see https://example.com for info"}
10130 ]
10131 }]
10132 }"#;
10133 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10134 let jfm = adf_to_markdown(&adf).unwrap();
10135 let roundtripped = markdown_to_adf(&jfm).unwrap();
10136 let content = roundtripped.content[0].content.as_ref().unwrap();
10137 assert_eq!(content.len(), 1);
10138 assert_eq!(content[0].node_type, "text");
10139 assert_eq!(
10140 content[0].text.as_deref(),
10141 Some("see https://example.com for info")
10142 );
10143 }
10144
10145 #[test]
10146 fn plain_text_multiple_urls_round_trips() {
10147 let adf_json = r#"{
10148 "version": 1,
10149 "type": "doc",
10150 "content": [{
10151 "type": "paragraph",
10152 "content": [
10153 {"type": "text", "text": "http://a.com and https://b.com"}
10154 ]
10155 }]
10156 }"#;
10157 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10158 let jfm = adf_to_markdown(&adf).unwrap();
10159 let roundtripped = markdown_to_adf(&jfm).unwrap();
10160 let content = roundtripped.content[0].content.as_ref().unwrap();
10161 assert_eq!(content.len(), 1);
10162 assert_eq!(content[0].node_type, "text");
10163 assert_eq!(
10164 content[0].text.as_deref(),
10165 Some("http://a.com and https://b.com")
10166 );
10167 }
10168
10169 #[test]
10170 fn plain_text_http_prefix_no_url_unchanged() {
10171 let adf_json = r#"{
10173 "version": 1,
10174 "type": "doc",
10175 "content": [{
10176 "type": "paragraph",
10177 "content": [
10178 {"type": "text", "text": "the http header is important"}
10179 ]
10180 }]
10181 }"#;
10182 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10183 let jfm = adf_to_markdown(&adf).unwrap();
10184 let roundtripped = markdown_to_adf(&jfm).unwrap();
10185 let content = roundtripped.content[0].content.as_ref().unwrap();
10186 assert_eq!(
10187 content[0].text.as_deref(),
10188 Some("the http header is important")
10189 );
10190 }
10191
10192 #[test]
10193 fn linked_url_text_round_trips() {
10194 let adf_json = r#"{
10198 "version": 1,
10199 "type": "doc",
10200 "content": [{
10201 "type": "paragraph",
10202 "content": [{
10203 "type": "text",
10204 "text": "https://example.com",
10205 "marks": [{"type": "link", "attrs": {"href": "https://example.com"}}]
10206 }]
10207 }]
10208 }"#;
10209 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10210 let jfm = adf_to_markdown(&adf).unwrap();
10211 let roundtripped = markdown_to_adf(&jfm).unwrap();
10212 let content = roundtripped.content[0].content.as_ref().unwrap();
10213 assert_eq!(content.len(), 1);
10214 assert_eq!(content[0].node_type, "text");
10215 assert_eq!(content[0].text.as_deref(), Some("https://example.com"));
10216 let mark = &content[0].marks.as_ref().unwrap()[0];
10217 assert_eq!(mark.mark_type, "link");
10218 assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
10219 }
10220
10221 #[test]
10224 fn escape_link_brackets_unit() {
10225 assert_eq!(escape_link_brackets("hello"), "hello");
10226 assert_eq!(escape_link_brackets("["), "\\[");
10227 assert_eq!(escape_link_brackets("]"), "\\]");
10228 assert_eq!(escape_link_brackets("[PROJ-456]"), "\\[PROJ-456\\]");
10229 assert_eq!(escape_link_brackets("a[b]c"), "a\\[b\\]c");
10230 }
10231
10232 #[test]
10233 fn bracket_text_with_link_mark_escapes_brackets() {
10234 let doc = AdfDocument {
10237 version: 1,
10238 doc_type: "doc".to_string(),
10239 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10240 "[",
10241 vec![AdfMark::link("https://example.com")],
10242 )])],
10243 };
10244 let md = adf_to_markdown(&doc).unwrap();
10245 assert_eq!(md.trim(), "[\\[](https://example.com)");
10246 }
10247
10248 #[test]
10249 fn bracket_text_with_link_mark_round_trips() {
10250 let adf_json = r#"{
10253 "type": "doc",
10254 "version": 1,
10255 "content": [{
10256 "type": "paragraph",
10257 "content": [
10258 {
10259 "type": "text",
10260 "text": "[",
10261 "marks": [{"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}]
10262 },
10263 {
10264 "type": "text",
10265 "text": "PROJ-456] Fix the auth bug",
10266 "marks": [
10267 {"type": "underline"},
10268 {"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}
10269 ]
10270 }
10271 ]
10272 }]
10273 }"#;
10274 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10275 let jfm = adf_to_markdown(&adf).unwrap();
10276
10277 assert!(jfm.contains("\\["), "opening bracket should be escaped");
10279
10280 let rt = markdown_to_adf(&jfm).unwrap();
10282 let content = rt.content[0].content.as_ref().unwrap();
10283
10284 let link_nodes: Vec<_> = content
10286 .iter()
10287 .filter(|n| {
10288 n.marks
10289 .as_ref()
10290 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
10291 })
10292 .collect();
10293 assert!(
10294 !link_nodes.is_empty(),
10295 "link mark must be preserved on round-trip"
10296 );
10297
10298 let all_text: String = content.iter().filter_map(|n| n.text.as_deref()).collect();
10300 assert!(
10301 all_text.contains('['),
10302 "literal '[' must survive round-trip"
10303 );
10304 assert!(
10305 all_text.contains("PROJ-456]"),
10306 "continuation text must survive round-trip"
10307 );
10308 }
10309
10310 #[test]
10311 fn closing_bracket_in_link_text_round_trips() {
10312 let doc = AdfDocument {
10315 version: 1,
10316 doc_type: "doc".to_string(),
10317 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10318 "item]",
10319 vec![AdfMark::link("https://example.com")],
10320 )])],
10321 };
10322 let md = adf_to_markdown(&doc).unwrap();
10323 assert_eq!(md.trim(), "[item\\]](https://example.com)");
10324
10325 let rt = markdown_to_adf(&md).unwrap();
10326 let content = rt.content[0].content.as_ref().unwrap();
10327 assert_eq!(content[0].text.as_deref(), Some("item]"));
10328 assert!(content[0]
10329 .marks
10330 .as_ref()
10331 .unwrap()
10332 .iter()
10333 .any(|m| m.mark_type == "link"));
10334 }
10335
10336 #[test]
10337 fn brackets_in_link_text_round_trip() {
10338 let doc = AdfDocument {
10340 version: 1,
10341 doc_type: "doc".to_string(),
10342 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10343 "[PROJ-123]",
10344 vec![AdfMark::link("https://example.com")],
10345 )])],
10346 };
10347 let md = adf_to_markdown(&doc).unwrap();
10348 assert_eq!(md.trim(), "[\\[PROJ-123\\]](https://example.com)");
10349
10350 let rt = markdown_to_adf(&md).unwrap();
10351 let content = rt.content[0].content.as_ref().unwrap();
10352 assert_eq!(content[0].text.as_deref(), Some("[PROJ-123]"));
10353 assert!(content[0]
10354 .marks
10355 .as_ref()
10356 .unwrap()
10357 .iter()
10358 .any(|m| m.mark_type == "link"));
10359 }
10360
10361 #[test]
10362 fn plain_text_brackets_not_escaped() {
10363 let doc = AdfDocument {
10365 version: 1,
10366 doc_type: "doc".to_string(),
10367 content: vec![AdfNode::paragraph(vec![AdfNode::text(
10368 "see [PROJ-123] for details",
10369 )])],
10370 };
10371 let md = adf_to_markdown(&doc).unwrap();
10372 assert_eq!(md.trim(), "see [PROJ-123] for details");
10373 }
10374
10375 #[test]
10376 fn link_with_no_brackets_unchanged() {
10377 let doc = AdfDocument {
10379 version: 1,
10380 doc_type: "doc".to_string(),
10381 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10382 "click here",
10383 vec![AdfMark::link("https://example.com")],
10384 )])],
10385 };
10386 let md = adf_to_markdown(&doc).unwrap();
10387 assert_eq!(md.trim(), "[click here](https://example.com)");
10388 }
10389
10390 #[test]
10393 fn url_with_brackets_as_link_text_round_trips() {
10394 let href = "https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10399 let doc = AdfDocument {
10400 version: 1,
10401 doc_type: "doc".to_string(),
10402 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10403 href,
10404 vec![AdfMark::link(href)],
10405 )])],
10406 };
10407 let md = adf_to_markdown(&doc).unwrap();
10408 let rt = markdown_to_adf(&md).unwrap();
10409 let content = rt.content[0].content.as_ref().unwrap();
10410 assert_eq!(content.len(), 1);
10411 assert_eq!(content[0].node_type, "text");
10412 assert_eq!(content[0].text.as_deref(), Some(href));
10413 let mark = &content[0].marks.as_ref().unwrap()[0];
10414 assert_eq!(mark.mark_type, "link");
10415 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10416 }
10417
10418 #[test]
10419 fn url_with_brackets_embedded_in_link_text_round_trips() {
10420 let href = "https://example.com/logs?query=service%20environment%20data&from=100&to=200";
10427 let text =
10428 "See the logs: https://example.com/logs?query=service[\u{2026}]data&from=100&to=200";
10429 let doc = AdfDocument {
10430 version: 1,
10431 doc_type: "doc".to_string(),
10432 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10433 text,
10434 vec![AdfMark::link(href)],
10435 )])],
10436 };
10437 let md = adf_to_markdown(&doc).unwrap();
10438 let rt = markdown_to_adf(&md).unwrap();
10439 let content = rt.content[0].content.as_ref().unwrap();
10440 assert_eq!(content.len(), 1, "content split unexpectedly: {content:?}");
10441 assert_eq!(content[0].node_type, "text");
10442 assert_eq!(content[0].text.as_deref(), Some(text));
10443 let mark = &content[0].marks.as_ref().unwrap()[0];
10444 assert_eq!(mark.mark_type, "link");
10445 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10446 }
10447
10448 #[test]
10449 fn url_with_brackets_plain_text_round_trips() {
10450 let text =
10453 "See the dashboard: https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10454 let doc = AdfDocument {
10455 version: 1,
10456 doc_type: "doc".to_string(),
10457 content: vec![AdfNode::paragraph(vec![AdfNode::text(text)])],
10458 };
10459 let md = adf_to_markdown(&doc).unwrap();
10460 let rt = markdown_to_adf(&md).unwrap();
10461 let content = rt.content[0].content.as_ref().unwrap();
10462 assert_eq!(content.len(), 1);
10463 assert_eq!(content[0].node_type, "text");
10464 assert_eq!(content[0].text.as_deref(), Some(text));
10465 assert!(content[0].marks.is_none());
10466 }
10467
10468 #[test]
10469 fn url_with_link_mark_embedded_no_brackets_round_trips() {
10470 let href = "https://example.com/";
10473 let text = "See https://example.com/ for more";
10474 let doc = AdfDocument {
10475 version: 1,
10476 doc_type: "doc".to_string(),
10477 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10478 text,
10479 vec![AdfMark::link(href)],
10480 )])],
10481 };
10482 let md = adf_to_markdown(&doc).unwrap();
10483 let rt = markdown_to_adf(&md).unwrap();
10484 let content = rt.content[0].content.as_ref().unwrap();
10485 assert_eq!(content.len(), 1);
10486 assert_eq!(content[0].node_type, "text");
10487 assert_eq!(content[0].text.as_deref(), Some(text));
10488 let mark = &content[0].marks.as_ref().unwrap()[0];
10489 assert_eq!(mark.mark_type, "link");
10490 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10491 }
10492
10493 #[test]
10494 fn nested_brackets_in_link_text_round_trip() {
10495 let href = "https://x.com";
10498 let text = "foo [a[b]c] bar";
10499 let doc = AdfDocument {
10500 version: 1,
10501 doc_type: "doc".to_string(),
10502 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10503 text,
10504 vec![AdfMark::link(href)],
10505 )])],
10506 };
10507 let md = adf_to_markdown(&doc).unwrap();
10508 let rt = markdown_to_adf(&md).unwrap();
10509 let content = rt.content[0].content.as_ref().unwrap();
10510 assert_eq!(content.len(), 1);
10511 assert_eq!(content[0].node_type, "text");
10512 assert_eq!(content[0].text.as_deref(), Some(text));
10513 }
10514
10515 #[test]
10516 fn bracket_url_bracket_in_link_text_round_trips() {
10517 let href = "https://y.com";
10523 let text = "[see https://x.com/a[0]=1 here]";
10524 let doc = AdfDocument {
10525 version: 1,
10526 doc_type: "doc".to_string(),
10527 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10528 text,
10529 vec![AdfMark::link(href)],
10530 )])],
10531 };
10532 let md = adf_to_markdown(&doc).unwrap();
10533 let rt = markdown_to_adf(&md).unwrap();
10534 let content = rt.content[0].content.as_ref().unwrap();
10535 assert_eq!(content.len(), 1);
10536 assert_eq!(content[0].node_type, "text");
10537 assert_eq!(content[0].text.as_deref(), Some(text));
10538 let mark = &content[0].marks.as_ref().unwrap()[0];
10539 assert_eq!(mark.mark_type, "link");
10540 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10541 }
10542
10543 #[test]
10544 fn escape_bare_urls_applied_inside_link_text() {
10545 let doc = AdfDocument {
10551 version: 1,
10552 doc_type: "doc".to_string(),
10553 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10554 "See https://example.com/",
10555 vec![AdfMark::link("https://target.example.com/")],
10556 )])],
10557 };
10558 let md = adf_to_markdown(&doc).unwrap();
10559 assert!(
10560 md.contains(r"\https://example.com/"),
10561 "bare URL inside link text must be escaped, got: {md}"
10562 );
10563 }
10564
10565 #[test]
10566 fn inline_card_still_round_trips() {
10567 let adf_json = r#"{
10570 "version": 1,
10571 "type": "doc",
10572 "content": [{
10573 "type": "paragraph",
10574 "content": [
10575 {"type": "inlineCard", "attrs": {"url": "https://example.com/page"}}
10576 ]
10577 }]
10578 }"#;
10579 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10580 let jfm = adf_to_markdown(&adf).unwrap();
10581 assert!(jfm.contains(":card[https://example.com/page]"));
10582 let roundtripped = markdown_to_adf(&jfm).unwrap();
10583 let content = roundtripped.content[0].content.as_ref().unwrap();
10584 assert_eq!(content[0].node_type, "inlineCard");
10585 assert_eq!(
10586 content[0].attrs.as_ref().unwrap()["url"],
10587 "https://example.com/page"
10588 );
10589 }
10590
10591 #[test]
10594 fn url_safe_in_bracket_content_balanced() {
10595 assert!(url_safe_in_bracket_content("https://example.com"));
10597 assert!(url_safe_in_bracket_content("https://example.com/[id]"));
10598 assert!(url_safe_in_bracket_content("a[b[c]d]e"));
10599 assert!(url_safe_in_bracket_content(""));
10600 }
10601
10602 #[test]
10603 fn url_safe_in_bracket_content_unbalanced() {
10604 assert!(!url_safe_in_bracket_content("a]b"));
10606 assert!(!url_safe_in_bracket_content("https://example.com/path]end"));
10607 assert!(!url_safe_in_bracket_content("a\nb"));
10609 }
10610
10611 #[test]
10612 fn inline_card_url_with_closing_bracket_round_trip() {
10613 let adf_json = r#"{
10618 "version": 1,
10619 "type": "doc",
10620 "content": [{
10621 "type": "paragraph",
10622 "content": [
10623 {"type": "text", "text": "See: "},
10624 {"type": "inlineCard", "attrs": {"url": "https://example.com/path]end/?q=1"}}
10625 ]
10626 }]
10627 }"#;
10628 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10629 let jfm = adf_to_markdown(&adf).unwrap();
10630 assert!(
10631 jfm.contains(r#":card[]{url="https://example.com/path]end/?q=1"}"#),
10632 "expected attr-form for URL with `]`, got: {jfm}"
10633 );
10634 let rt = markdown_to_adf(&jfm).unwrap();
10635 let content = rt.content[0].content.as_ref().unwrap();
10636 assert_eq!(content.len(), 2, "expected 2 inline nodes, got {content:?}");
10637 assert_eq!(content[0].node_type, "text");
10638 assert_eq!(content[0].text.as_deref(), Some("See: "));
10639 assert_eq!(content[1].node_type, "inlineCard");
10640 assert_eq!(
10641 content[1].attrs.as_ref().unwrap()["url"],
10642 "https://example.com/path]end/?q=1"
10643 );
10644 }
10645
10646 #[test]
10647 fn inline_card_url_with_closing_bracket_preserves_local_id() {
10648 let adf_json = r#"{
10650 "version": 1,
10651 "type": "doc",
10652 "content": [{
10653 "type": "paragraph",
10654 "content": [
10655 {"type": "inlineCard", "attrs": {
10656 "url": "https://example.com/a]b",
10657 "localId": "c-77"
10658 }}
10659 ]
10660 }]
10661 }"#;
10662 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10663 let jfm = adf_to_markdown(&adf).unwrap();
10664 assert!(
10665 jfm.contains(r#"url="https://example.com/a]b""#),
10666 "jfm: {jfm}"
10667 );
10668 assert!(jfm.contains("localId=c-77"), "jfm: {jfm}");
10669 let rt = markdown_to_adf(&jfm).unwrap();
10670 let card = &rt.content[0].content.as_ref().unwrap()[0];
10671 assert_eq!(card.node_type, "inlineCard");
10672 assert_eq!(
10673 card.attrs.as_ref().unwrap()["url"],
10674 "https://example.com/a]b"
10675 );
10676 assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-77");
10677 }
10678
10679 #[test]
10680 fn block_card_url_with_closing_bracket_round_trip() {
10681 let adf_json = r#"{
10683 "version": 1,
10684 "type": "doc",
10685 "content": [
10686 {"type": "blockCard", "attrs": {"url": "https://example.com/path]end"}}
10687 ]
10688 }"#;
10689 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10690 let jfm = adf_to_markdown(&adf).unwrap();
10691 assert!(
10692 jfm.contains(r#"::card[]{url="https://example.com/path]end"}"#),
10693 "expected attr-form for blockCard with `]`, got: {jfm}"
10694 );
10695 let rt = markdown_to_adf(&jfm).unwrap();
10696 assert_eq!(rt.content[0].node_type, "blockCard");
10697 assert_eq!(
10698 rt.content[0].attrs.as_ref().unwrap()["url"],
10699 "https://example.com/path]end"
10700 );
10701 }
10702
10703 #[test]
10704 fn block_card_attr_form_parses_without_renderer() {
10705 let doc = markdown_to_adf(r#"::card[]{url="https://example.com/a"}"#).unwrap();
10709 assert_eq!(doc.content[0].node_type, "blockCard");
10710 assert_eq!(
10711 doc.content[0].attrs.as_ref().unwrap()["url"],
10712 "https://example.com/a"
10713 );
10714 }
10715
10716 #[test]
10717 fn block_card_attr_form_url_overrides_content() {
10718 let doc =
10722 markdown_to_adf(r#"::card[https://old.example.com]{url="https://new.example.com"}"#)
10723 .unwrap();
10724 assert_eq!(doc.content[0].node_type, "blockCard");
10725 assert_eq!(
10726 doc.content[0].attrs.as_ref().unwrap()["url"],
10727 "https://new.example.com"
10728 );
10729 }
10730
10731 #[test]
10732 fn block_card_attr_form_with_layout_and_width() {
10733 let doc =
10736 markdown_to_adf(r#"::card[]{url="https://example.com/a]b" layout=wide width=80}"#)
10737 .unwrap();
10738 let attrs = doc.content[0].attrs.as_ref().unwrap();
10739 assert_eq!(attrs["url"], "https://example.com/a]b");
10740 assert_eq!(attrs["layout"], "wide");
10741 assert_eq!(attrs["width"], 80);
10742 }
10743
10744 #[test]
10745 fn inline_card_issue_553_reproducer() {
10746 let adf_json = r#"{
10750 "version": 1,
10751 "type": "doc",
10752 "content": [{
10753 "type": "paragraph",
10754 "content": [
10755 {"type": "text", "text": "See the related page: "},
10756 {"type": "inlineCard", "attrs": {
10757 "url": "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10758 }}
10759 ]
10760 }]
10761 }"#;
10762 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10763 let jfm = adf_to_markdown(&adf).unwrap();
10764 let rt = markdown_to_adf(&jfm).unwrap();
10765 let content = rt.content[0].content.as_ref().unwrap();
10766 assert_eq!(content.len(), 2);
10767 assert_eq!(content[0].node_type, "text");
10768 assert_eq!(content[1].node_type, "inlineCard");
10769 assert_eq!(
10770 content[1].attrs.as_ref().unwrap()["url"],
10771 "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10772 );
10773 }
10774
10775 #[test]
10776 fn inline_card_attr_form_parses_even_without_renderer() {
10777 let doc = markdown_to_adf(r#":card[]{url="https://example.com/a"}"#).unwrap();
10779 let node = &doc.content[0].content.as_ref().unwrap()[0];
10780 assert_eq!(node.node_type, "inlineCard");
10781 assert_eq!(node.attrs.as_ref().unwrap()["url"], "https://example.com/a");
10782 }
10783
10784 #[test]
10785 fn inline_card_attr_form_url_overrides_content() {
10786 let doc =
10790 markdown_to_adf(r#":card[https://old.example.com]{url="https://new.example.com"}"#)
10791 .unwrap();
10792 let node = &doc.content[0].content.as_ref().unwrap()[0];
10793 assert_eq!(node.node_type, "inlineCard");
10794 assert_eq!(
10795 node.attrs.as_ref().unwrap()["url"],
10796 "https://new.example.com"
10797 );
10798 }
10799
10800 #[test]
10803 fn url_with_link_and_underline_marks_round_trip() {
10804 let adf_json = r#"{
10808 "version": 1,
10809 "type": "doc",
10810 "content": [{
10811 "type": "paragraph",
10812 "content": [
10813 {"type": "text", "text": "See results at: "},
10814 {"type": "text",
10815 "text": "https://example.com/projects/abc123/analytics",
10816 "marks": [
10817 {"type": "link", "attrs": {"href": "https://example.com/projects/abc123/analytics"}},
10818 {"type": "underline"}
10819 ]},
10820 {"type": "text", "text": " for details."}
10821 ]
10822 }]
10823 }"#;
10824 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10825 let jfm = adf_to_markdown(&adf).unwrap();
10826 let rt = markdown_to_adf(&jfm).unwrap();
10827 let content = rt.content[0].content.as_ref().unwrap();
10828 assert_eq!(
10829 content.len(),
10830 3,
10831 "expected 3 inline nodes, got: {content:?}"
10832 );
10833 assert_eq!(content[0].node_type, "text");
10834 assert_eq!(
10835 content[1].node_type, "text",
10836 "must stay text, not inlineCard"
10837 );
10838 assert_eq!(
10839 content[1].text.as_deref(),
10840 Some("https://example.com/projects/abc123/analytics")
10841 );
10842 let mark_types: Vec<&str> = content[1]
10843 .marks
10844 .as_deref()
10845 .unwrap_or(&[])
10846 .iter()
10847 .map(|m| m.mark_type.as_str())
10848 .collect();
10849 assert_eq!(mark_types, vec!["link", "underline"], "marks lost");
10850 assert_eq!(content[2].node_type, "text");
10851 }
10852
10853 #[test]
10854 fn url_inside_bracketed_span_stays_text() {
10855 let doc = markdown_to_adf("[https://example.com]{underline}").unwrap();
10859 let node = &doc.content[0].content.as_ref().unwrap()[0];
10860 assert_eq!(node.node_type, "text");
10861 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10862 let mark_types: Vec<&str> = node
10863 .marks
10864 .as_deref()
10865 .unwrap_or(&[])
10866 .iter()
10867 .map(|m| m.mark_type.as_str())
10868 .collect();
10869 assert_eq!(mark_types, vec!["underline"]);
10870 }
10871
10872 #[test]
10873 fn url_inside_emphasis_stays_text() {
10874 for (md, mark) in [
10877 ("**https://example.com**", "strong"),
10878 ("*https://example.com*", "em"),
10879 ("~~https://example.com~~", "strike"),
10880 ] {
10881 let doc = markdown_to_adf(md).unwrap();
10882 let node = &doc.content[0].content.as_ref().unwrap()[0];
10883 assert_eq!(node.node_type, "text", "md={md}: must be text");
10884 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10885 let mark_types: Vec<&str> = node
10886 .marks
10887 .as_deref()
10888 .unwrap_or(&[])
10889 .iter()
10890 .map(|m| m.mark_type.as_str())
10891 .collect();
10892 assert_eq!(mark_types, vec![mark], "md={md}: wrong marks");
10893 }
10894 }
10895
10896 #[test]
10897 fn url_inside_span_directive_stays_text() {
10898 let doc = markdown_to_adf(":span[https://example.com]{color=red}").unwrap();
10900 let node = &doc.content[0].content.as_ref().unwrap()[0];
10901 assert_eq!(node.node_type, "text");
10902 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10903 let mark = &node.marks.as_ref().unwrap()[0];
10904 assert_eq!(mark.mark_type, "textColor");
10905 }
10906
10907 #[test]
10908 fn url_as_link_text_with_underline_after_link_mark_order() {
10909 let adf_json = r#"{
10913 "version": 1,
10914 "type": "doc",
10915 "content": [{
10916 "type": "paragraph",
10917 "content": [
10918 {"type": "text",
10919 "text": "https://example.com",
10920 "marks": [
10921 {"type": "underline"},
10922 {"type": "link", "attrs": {"href": "https://example.com"}}
10923 ]}
10924 ]
10925 }]
10926 }"#;
10927 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10928 let jfm = adf_to_markdown(&adf).unwrap();
10929 let rt = markdown_to_adf(&jfm).unwrap();
10930 let node = &rt.content[0].content.as_ref().unwrap()[0];
10931 assert_eq!(node.node_type, "text", "must stay text, got: {node:?}");
10932 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10933 let mark_types: Vec<&str> = node
10934 .marks
10935 .as_deref()
10936 .unwrap_or(&[])
10937 .iter()
10938 .map(|m| m.mark_type.as_str())
10939 .collect();
10940 assert_eq!(mark_types, vec!["underline", "link"]);
10941 }
10942
10943 #[test]
10944 fn bare_url_at_top_level_still_becomes_inline_card() {
10945 let doc = markdown_to_adf("Visit https://example.com today").unwrap();
10949 let content = doc.content[0].content.as_ref().unwrap();
10950 assert_eq!(content.len(), 3);
10951 assert_eq!(content[0].node_type, "text");
10952 assert_eq!(content[1].node_type, "inlineCard");
10953 assert_eq!(
10954 content[1].attrs.as_ref().unwrap()["url"],
10955 "https://example.com"
10956 );
10957 assert_eq!(content[2].node_type, "text");
10958 }
10959
10960 #[test]
10963 fn paragraph_align_center() {
10964 let md = "Centered text.\n{align=center}";
10965 let doc = markdown_to_adf(md).unwrap();
10966 let marks = doc.content[0].marks.as_ref().unwrap();
10967 assert_eq!(marks[0].mark_type, "alignment");
10968 assert_eq!(marks[0].attrs.as_ref().unwrap()["align"], "center");
10969 }
10970
10971 #[test]
10972 fn adf_alignment_to_markdown() {
10973 let mut node = AdfNode::paragraph(vec![AdfNode::text("Centered.")]);
10974 node.marks = Some(vec![AdfMark::alignment("center")]);
10975 let doc = AdfDocument {
10976 version: 1,
10977 doc_type: "doc".to_string(),
10978 content: vec![node],
10979 };
10980 let md = adf_to_markdown(&doc).unwrap();
10981 assert!(md.contains("Centered."));
10982 assert!(md.contains("{align=center}"));
10983 }
10984
10985 #[test]
10986 fn round_trip_alignment() {
10987 let md = "Centered.\n{align=center}\n";
10988 let doc = markdown_to_adf(md).unwrap();
10989 let result = adf_to_markdown(&doc).unwrap();
10990 assert!(result.contains("{align=center}"));
10991 }
10992
10993 #[test]
10994 fn paragraph_indent() {
10995 let md = "Indented.\n{indent=2}";
10996 let doc = markdown_to_adf(md).unwrap();
10997 let marks = doc.content[0].marks.as_ref().unwrap();
10998 assert_eq!(marks[0].mark_type, "indentation");
10999 assert_eq!(marks[0].attrs.as_ref().unwrap()["level"], 2);
11000 }
11001
11002 #[test]
11003 fn code_block_breakout() {
11004 let md = "```python\ndef f(): pass\n```\n{breakout=wide}";
11005 let doc = markdown_to_adf(md).unwrap();
11006 let marks = doc.content[0].marks.as_ref().unwrap();
11007 assert_eq!(marks[0].mark_type, "breakout");
11008 assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
11009 assert!(marks[0].attrs.as_ref().unwrap().get("width").is_none());
11010 }
11011
11012 #[test]
11013 fn code_block_breakout_with_width() {
11014 let md = "```python\ndef f(): pass\n```\n{breakout=wide breakoutWidth=1200}";
11015 let doc = markdown_to_adf(md).unwrap();
11016 let marks = doc.content[0].marks.as_ref().unwrap();
11017 assert_eq!(marks[0].mark_type, "breakout");
11018 assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
11019 assert_eq!(marks[0].attrs.as_ref().unwrap()["width"], 1200);
11020 }
11021
11022 #[test]
11023 fn adf_breakout_to_markdown() {
11024 let mut node = AdfNode::code_block(Some("python"), "pass");
11025 node.marks = Some(vec![AdfMark::breakout("wide", None)]);
11026 let doc = AdfDocument {
11027 version: 1,
11028 doc_type: "doc".to_string(),
11029 content: vec![node],
11030 };
11031 let md = adf_to_markdown(&doc).unwrap();
11032 assert!(md.contains("{breakout=wide}"));
11033 assert!(!md.contains("breakoutWidth"));
11034 }
11035
11036 #[test]
11037 fn adf_breakout_with_width_to_markdown() {
11038 let mut node = AdfNode::code_block(Some("python"), "pass");
11039 node.marks = Some(vec![AdfMark::breakout("wide", Some(1200))]);
11040 let doc = AdfDocument {
11041 version: 1,
11042 doc_type: "doc".to_string(),
11043 content: vec![node],
11044 };
11045 let md = adf_to_markdown(&doc).unwrap();
11046 assert!(md.contains("breakout=wide"));
11047 assert!(md.contains("breakoutWidth=1200"));
11048 }
11049
11050 #[test]
11051 fn breakout_width_round_trip() {
11052 let adf_json = r#"{"version":1,"type":"doc","content":[{
11053 "type":"codeBlock",
11054 "attrs":{"language":"text"},
11055 "marks":[{"type":"breakout","attrs":{"mode":"wide","width":1200}}],
11056 "content":[{"type":"text","text":"some code"}]
11057 }]}"#;
11058 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11059 let md = adf_to_markdown(&doc).unwrap();
11060 assert!(md.contains("breakout=wide"));
11061 assert!(md.contains("breakoutWidth=1200"));
11062 let round_tripped = markdown_to_adf(&md).unwrap();
11063 let marks = round_tripped.content[0].marks.as_ref().unwrap();
11064 let breakout = marks.iter().find(|m| m.mark_type == "breakout").unwrap();
11065 assert_eq!(breakout.attrs.as_ref().unwrap()["mode"], "wide");
11066 assert_eq!(breakout.attrs.as_ref().unwrap()["width"], 1200);
11067 }
11068
11069 #[test]
11072 fn image_with_layout_attrs() {
11073 let doc = markdown_to_adf("{layout=wide width=80}").unwrap();
11074 let node = &doc.content[0];
11075 assert_eq!(node.node_type, "mediaSingle");
11076 let attrs = node.attrs.as_ref().unwrap();
11077 assert_eq!(attrs["layout"], "wide");
11078 assert_eq!(attrs["width"], 80);
11079 }
11080
11081 #[test]
11082 fn adf_image_with_layout_to_markdown() {
11083 let mut node = AdfNode::media_single("url", Some("alt"));
11084 node.attrs.as_mut().unwrap()["layout"] = serde_json::json!("wide");
11085 node.attrs.as_mut().unwrap()["width"] = serde_json::json!(80);
11086 let doc = AdfDocument {
11087 version: 1,
11088 doc_type: "doc".to_string(),
11089 content: vec![node],
11090 };
11091 let md = adf_to_markdown(&doc).unwrap();
11092 assert!(md.contains("{layout=wide width=80}"));
11093 }
11094
11095 #[test]
11096 fn table_with_layout_attrs() {
11097 let md = "| H |\n| --- |\n| C |\n{layout=wide numbered}";
11098 let doc = markdown_to_adf(md).unwrap();
11099 let table = &doc.content[0];
11100 assert_eq!(table.node_type, "table");
11101 let attrs = table.attrs.as_ref().unwrap();
11102 assert_eq!(attrs["layout"], "wide");
11103 assert_eq!(attrs["isNumberColumnEnabled"], true);
11104 }
11105
11106 #[test]
11107 fn adf_table_with_attrs_to_markdown() {
11108 let mut table = AdfNode::table(vec![
11109 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
11110 AdfNode::text("H"),
11111 ])])]),
11112 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
11113 AdfNode::text("C"),
11114 ])])]),
11115 ]);
11116 table.attrs = Some(serde_json::json!({"layout": "wide", "isNumberColumnEnabled": true}));
11117 let doc = AdfDocument {
11118 version: 1,
11119 doc_type: "doc".to_string(),
11120 content: vec![table],
11121 };
11122 let md = adf_to_markdown(&doc).unwrap();
11123 assert!(md.contains("{layout=wide numbered}"));
11124 }
11125
11126 #[test]
11129 fn underline_bracketed_span() {
11130 let doc = markdown_to_adf("This is [underlined text]{underline} here.").unwrap();
11131 let content = doc.content[0].content.as_ref().unwrap();
11132 assert_eq!(content[1].text.as_deref(), Some("underlined text"));
11133 let marks = content[1].marks.as_ref().unwrap();
11134 assert_eq!(marks[0].mark_type, "underline");
11135 }
11136
11137 #[test]
11138 fn adf_underline_to_markdown() {
11139 let doc = AdfDocument {
11140 version: 1,
11141 doc_type: "doc".to_string(),
11142 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11143 "underlined",
11144 vec![AdfMark::underline()],
11145 )])],
11146 };
11147 let md = adf_to_markdown(&doc).unwrap();
11148 assert!(md.contains("[underlined]{underline}"));
11149 }
11150
11151 #[test]
11152 fn round_trip_underline() {
11153 let md = "This is [underlined text]{underline} here.\n";
11154 let doc = markdown_to_adf(md).unwrap();
11155 let result = adf_to_markdown(&doc).unwrap();
11156 assert!(result.contains("[underlined text]{underline}"));
11157 }
11158
11159 #[test]
11160 fn mark_ordering_underline_strong_preserved() {
11161 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11163 {"type":"text","text":"bold and underlined","marks":[{"type":"underline"},{"type":"strong"}]}
11164 ]}]}"#;
11165 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11166 let md = adf_to_markdown(&doc).unwrap();
11167 let round_tripped = markdown_to_adf(&md).unwrap();
11168 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11169 let mark_types: Vec<&str> = node
11170 .marks
11171 .as_ref()
11172 .unwrap()
11173 .iter()
11174 .map(|m| m.mark_type.as_str())
11175 .collect();
11176 assert_eq!(
11177 mark_types,
11178 vec!["underline", "strong"],
11179 "mark order should be preserved, got: {mark_types:?}"
11180 );
11181 }
11182
11183 #[test]
11184 fn mark_ordering_link_strong_preserved() {
11185 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11187 {"type":"text","text":"bold link","marks":[
11188 {"type":"link","attrs":{"href":"https://example.com"}},
11189 {"type":"strong"}
11190 ]}
11191 ]}]}"#;
11192 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11193 let md = adf_to_markdown(&doc).unwrap();
11194 let round_tripped = markdown_to_adf(&md).unwrap();
11195 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11196 let mark_types: Vec<&str> = node
11197 .marks
11198 .as_ref()
11199 .unwrap()
11200 .iter()
11201 .map(|m| m.mark_type.as_str())
11202 .collect();
11203 assert_eq!(
11204 mark_types,
11205 vec!["link", "strong"],
11206 "mark order should be preserved, got: {mark_types:?}"
11207 );
11208 }
11209
11210 #[test]
11211 fn mark_ordering_link_textcolor_preserved() {
11212 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11214 {"type":"text","text":"red link","marks":[
11215 {"type":"link","attrs":{"href":"https://example.com"}},
11216 {"type":"textColor","attrs":{"color":"#ff0000"}}
11217 ]}
11218 ]}]}"##;
11219 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11220 let md = adf_to_markdown(&doc).unwrap();
11221 let round_tripped = markdown_to_adf(&md).unwrap();
11222 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11223 let mark_types: Vec<&str> = node
11224 .marks
11225 .as_ref()
11226 .unwrap()
11227 .iter()
11228 .map(|m| m.mark_type.as_str())
11229 .collect();
11230 assert_eq!(
11231 mark_types,
11232 vec!["link", "textColor"],
11233 "mark order should be preserved, got: {mark_types:?}"
11234 );
11235 }
11236
11237 #[test]
11238 fn mark_ordering_link_em_preserved() {
11239 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11241 {"type":"text","text":"italic link","marks":[
11242 {"type":"link","attrs":{"href":"https://example.com"}},
11243 {"type":"em"}
11244 ]}
11245 ]}]}"#;
11246 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11247 let md = adf_to_markdown(&doc).unwrap();
11248 let round_tripped = markdown_to_adf(&md).unwrap();
11249 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11250 let mark_types: Vec<&str> = node
11251 .marks
11252 .as_ref()
11253 .unwrap()
11254 .iter()
11255 .map(|m| m.mark_type.as_str())
11256 .collect();
11257 assert_eq!(
11258 mark_types,
11259 vec!["link", "em"],
11260 "mark order should be preserved, got: {mark_types:?}"
11261 );
11262 }
11263
11264 #[test]
11265 fn mark_ordering_link_strike_preserved() {
11266 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11268 {"type":"text","text":"struck link","marks":[
11269 {"type":"link","attrs":{"href":"https://example.com"}},
11270 {"type":"strike"}
11271 ]}
11272 ]}]}"#;
11273 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11274 let md = adf_to_markdown(&doc).unwrap();
11275 let round_tripped = markdown_to_adf(&md).unwrap();
11276 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11277 let mark_types: Vec<&str> = node
11278 .marks
11279 .as_ref()
11280 .unwrap()
11281 .iter()
11282 .map(|m| m.mark_type.as_str())
11283 .collect();
11284 assert_eq!(
11285 mark_types,
11286 vec!["link", "strike"],
11287 "mark order should be preserved, got: {mark_types:?}"
11288 );
11289 }
11290
11291 #[test]
11292 fn mark_ordering_strong_link_preserved() {
11293 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11295 {"type":"text","text":"bold link","marks":[
11296 {"type":"strong"},
11297 {"type":"link","attrs":{"href":"https://example.com"}}
11298 ]}
11299 ]}]}"#;
11300 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11301 let md = adf_to_markdown(&doc).unwrap();
11302 let round_tripped = markdown_to_adf(&md).unwrap();
11303 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11304 let mark_types: Vec<&str> = node
11305 .marks
11306 .as_ref()
11307 .unwrap()
11308 .iter()
11309 .map(|m| m.mark_type.as_str())
11310 .collect();
11311 assert_eq!(
11312 mark_types,
11313 vec!["strong", "link"],
11314 "mark order should be preserved, got: {mark_types:?}"
11315 );
11316 }
11317
11318 #[test]
11319 fn mark_ordering_em_link_preserved() {
11320 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11322 {"type":"text","text":"italic link","marks":[
11323 {"type":"em"},
11324 {"type":"link","attrs":{"href":"https://example.com"}}
11325 ]}
11326 ]}]}"#;
11327 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11328 let md = adf_to_markdown(&doc).unwrap();
11329 let round_tripped = markdown_to_adf(&md).unwrap();
11330 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11331 let mark_types: Vec<&str> = node
11332 .marks
11333 .as_ref()
11334 .unwrap()
11335 .iter()
11336 .map(|m| m.mark_type.as_str())
11337 .collect();
11338 assert_eq!(
11339 mark_types,
11340 vec!["em", "link"],
11341 "mark order should be preserved, got: {mark_types:?}"
11342 );
11343 }
11344
11345 #[test]
11346 fn mark_ordering_strike_link_preserved() {
11347 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11349 {"type":"text","text":"struck link","marks":[
11350 {"type":"strike"},
11351 {"type":"link","attrs":{"href":"https://example.com"}}
11352 ]}
11353 ]}]}"#;
11354 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11355 let md = adf_to_markdown(&doc).unwrap();
11356 let round_tripped = markdown_to_adf(&md).unwrap();
11357 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11358 let mark_types: Vec<&str> = node
11359 .marks
11360 .as_ref()
11361 .unwrap()
11362 .iter()
11363 .map(|m| m.mark_type.as_str())
11364 .collect();
11365 assert_eq!(
11366 mark_types,
11367 vec!["strike", "link"],
11368 "mark order should be preserved, got: {mark_types:?}"
11369 );
11370 }
11371
11372 #[test]
11373 fn mark_ordering_underline_link_preserved() {
11374 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11376 {"type":"text","text":"click here","marks":[
11377 {"type":"underline"},
11378 {"type":"link","attrs":{"href":"https://example.com"}}
11379 ]}
11380 ]}]}"#;
11381 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11382 let md = adf_to_markdown(&doc).unwrap();
11383 let round_tripped = markdown_to_adf(&md).unwrap();
11384 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11385 let mark_types: Vec<&str> = node
11386 .marks
11387 .as_ref()
11388 .unwrap()
11389 .iter()
11390 .map(|m| m.mark_type.as_str())
11391 .collect();
11392 assert_eq!(
11393 mark_types,
11394 vec!["underline", "link"],
11395 "mark order should be preserved, got: {mark_types:?}"
11396 );
11397 }
11398
11399 #[test]
11400 fn mark_ordering_textcolor_link_preserved() {
11401 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11403 {"type":"text","text":"red link","marks":[
11404 {"type":"textColor","attrs":{"color":"#ff0000"}},
11405 {"type":"link","attrs":{"href":"https://example.com"}}
11406 ]}
11407 ]}]}"##;
11408 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11409 let md = adf_to_markdown(&doc).unwrap();
11410 let round_tripped = markdown_to_adf(&md).unwrap();
11411 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11412 let mark_types: Vec<&str> = node
11413 .marks
11414 .as_ref()
11415 .unwrap()
11416 .iter()
11417 .map(|m| m.mark_type.as_str())
11418 .collect();
11419 assert_eq!(
11420 mark_types,
11421 vec!["textColor", "link"],
11422 "mark order should be preserved, got: {mark_types:?}"
11423 );
11424 }
11425
11426 #[test]
11427 fn mark_ordering_link_underline_preserved() {
11428 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11430 {"type":"text","text":"click here","marks":[
11431 {"type":"link","attrs":{"href":"https://example.com"}},
11432 {"type":"underline"}
11433 ]}
11434 ]}]}"#;
11435 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11436 let md = adf_to_markdown(&doc).unwrap();
11437 assert!(
11439 md.contains("](https://example.com)"),
11440 "should have link: {md}"
11441 );
11442 assert!(md.contains("underline"), "should have underline: {md}");
11443 let round_tripped = markdown_to_adf(&md).unwrap();
11444 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11445 let mark_types: Vec<&str> = node
11446 .marks
11447 .as_ref()
11448 .unwrap()
11449 .iter()
11450 .map(|m| m.mark_type.as_str())
11451 .collect();
11452 assert_eq!(
11453 mark_types,
11454 vec!["link", "underline"],
11455 "mark order should be preserved, got: {mark_types:?}"
11456 );
11457 }
11458
11459 #[test]
11460 fn mark_ordering_underline_strong_link_preserved() {
11461 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11463 {"type":"text","text":"bold underlined link","marks":[
11464 {"type":"underline"},
11465 {"type":"strong"},
11466 {"type":"link","attrs":{"href":"https://example.com/page"}}
11467 ]}
11468 ]}]}"#;
11469 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11470 let md = adf_to_markdown(&doc).unwrap();
11471 let round_tripped = markdown_to_adf(&md).unwrap();
11472 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11473 let mark_types: Vec<&str> = node
11474 .marks
11475 .as_ref()
11476 .unwrap()
11477 .iter()
11478 .map(|m| m.mark_type.as_str())
11479 .collect();
11480 assert_eq!(
11481 mark_types,
11482 vec!["underline", "strong", "link"],
11483 "mark order should be preserved, got: {mark_types:?}"
11484 );
11485 }
11486
11487 #[test]
11488 fn mark_ordering_strong_underline_link_preserved() {
11489 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11491 {"type":"text","text":"bold underlined link","marks":[
11492 {"type":"strong"},
11493 {"type":"underline"},
11494 {"type":"link","attrs":{"href":"https://example.com/page"}}
11495 ]}
11496 ]}]}"#;
11497 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11498 let md = adf_to_markdown(&doc).unwrap();
11499 let round_tripped = markdown_to_adf(&md).unwrap();
11500 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11501 let mark_types: Vec<&str> = node
11502 .marks
11503 .as_ref()
11504 .unwrap()
11505 .iter()
11506 .map(|m| m.mark_type.as_str())
11507 .collect();
11508 assert_eq!(
11509 mark_types,
11510 vec!["strong", "underline", "link"],
11511 "mark order should be preserved, got: {mark_types:?}"
11512 );
11513 }
11514
11515 #[test]
11516 fn mark_ordering_underline_em_link_preserved() {
11517 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11519 {"type":"text","text":"italic underlined link","marks":[
11520 {"type":"underline"},
11521 {"type":"em"},
11522 {"type":"link","attrs":{"href":"https://example.com/page"}}
11523 ]}
11524 ]}]}"#;
11525 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11526 let md = adf_to_markdown(&doc).unwrap();
11527 let round_tripped = markdown_to_adf(&md).unwrap();
11528 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11529 let mark_types: Vec<&str> = node
11530 .marks
11531 .as_ref()
11532 .unwrap()
11533 .iter()
11534 .map(|m| m.mark_type.as_str())
11535 .collect();
11536 assert_eq!(
11537 mark_types,
11538 vec!["underline", "em", "link"],
11539 "mark order should be preserved, got: {mark_types:?}"
11540 );
11541 }
11542
11543 #[test]
11544 fn mark_ordering_underline_strike_link_preserved() {
11545 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11547 {"type":"text","text":"struck underlined link","marks":[
11548 {"type":"underline"},
11549 {"type":"strike"},
11550 {"type":"link","attrs":{"href":"https://example.com/page"}}
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", "strike", "link"],
11567 "mark order should be preserved, got: {mark_types:?}"
11568 );
11569 }
11570
11571 #[test]
11572 fn mark_ordering_underline_strong_em_link_preserved() {
11573 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11575 {"type":"text","text":"all the marks","marks":[
11576 {"type":"underline"},
11577 {"type":"strong"},
11578 {"type":"em"},
11579 {"type":"link","attrs":{"href":"https://example.com/page"}}
11580 ]}
11581 ]}]}"#;
11582 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11583 let md = adf_to_markdown(&doc).unwrap();
11584 let round_tripped = markdown_to_adf(&md).unwrap();
11585 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11586 let mark_types: Vec<&str> = node
11587 .marks
11588 .as_ref()
11589 .unwrap()
11590 .iter()
11591 .map(|m| m.mark_type.as_str())
11592 .collect();
11593 assert_eq!(
11594 mark_types,
11595 vec!["underline", "strong", "em", "link"],
11596 "mark order should be preserved, got: {mark_types:?}"
11597 );
11598 }
11599
11600 #[test]
11601 fn em_strong_round_trip() {
11602 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11604 {"type":"text","text":"bold and italic","marks":[{"type":"strong"},{"type":"em"}]}
11605 ]}]}"#;
11606 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11607 let md = adf_to_markdown(&doc).unwrap();
11608 assert_eq!(md.trim(), "***bold and italic***");
11609 let round_tripped = markdown_to_adf(&md).unwrap();
11610 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11611 assert_eq!(node.text.as_deref(), Some("bold and italic"));
11612 let mark_types: Vec<&str> = node
11613 .marks
11614 .as_ref()
11615 .unwrap()
11616 .iter()
11617 .map(|m| m.mark_type.as_str())
11618 .collect();
11619 assert_eq!(
11620 mark_types,
11621 vec!["strong", "em"],
11622 "both strong and em marks should be preserved, got: {mark_types:?}"
11623 );
11624 }
11625
11626 #[test]
11627 fn em_strong_round_trip_em_first() {
11628 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11630 {"type":"text","text":"italic and bold","marks":[{"type":"em"},{"type":"strong"}]}
11631 ]}]}"#;
11632 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11633 let md = adf_to_markdown(&doc).unwrap();
11634 let round_tripped = markdown_to_adf(&md).unwrap();
11635 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11636 assert_eq!(node.text.as_deref(), Some("italic and bold"));
11637 let mark_types: Vec<&str> = node
11638 .marks
11639 .as_ref()
11640 .unwrap()
11641 .iter()
11642 .map(|m| m.mark_type.as_str())
11643 .collect();
11644 assert_eq!(
11645 mark_types,
11646 vec!["em", "strong"],
11647 "mark order [em, strong] should be preserved, got: {mark_types:?}"
11648 );
11649 }
11650
11651 fn assert_mark_order_round_trip(marks: Vec<AdfMark>, expected: &[&str]) {
11654 let doc = AdfDocument {
11655 version: 1,
11656 doc_type: "doc".to_string(),
11657 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11658 "text", marks,
11659 )])],
11660 };
11661 let md = adf_to_markdown(&doc).unwrap();
11662 let round_tripped = markdown_to_adf(&md).unwrap();
11663 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11664 let mark_types: Vec<&str> = node
11665 .marks
11666 .as_ref()
11667 .expect("round-tripped node should have marks")
11668 .iter()
11669 .map(|m| m.mark_type.as_str())
11670 .collect();
11671 assert_eq!(
11672 mark_types, expected,
11673 "marks should round-trip in order via {md:?}"
11674 );
11675 }
11676
11677 #[test]
11678 fn round_trip_em_strong_mark_order() {
11679 assert_mark_order_round_trip(vec![AdfMark::em(), AdfMark::strong()], &["em", "strong"]);
11681 assert_mark_order_round_trip(vec![AdfMark::strong(), AdfMark::em()], &["strong", "em"]);
11682 }
11683
11684 #[test]
11685 fn round_trip_strong_underline_mark_order() {
11686 assert_mark_order_round_trip(
11688 vec![AdfMark::strong(), AdfMark::underline()],
11689 &["strong", "underline"],
11690 );
11691 assert_mark_order_round_trip(
11692 vec![AdfMark::underline(), AdfMark::strong()],
11693 &["underline", "strong"],
11694 );
11695 }
11696
11697 #[test]
11698 fn round_trip_em_underline_mark_order() {
11699 assert_mark_order_round_trip(
11700 vec![AdfMark::em(), AdfMark::underline()],
11701 &["em", "underline"],
11702 );
11703 assert_mark_order_round_trip(
11704 vec![AdfMark::underline(), AdfMark::em()],
11705 &["underline", "em"],
11706 );
11707 }
11708
11709 #[test]
11710 fn round_trip_strike_strong_em_permutations() {
11711 assert_mark_order_round_trip(
11715 vec![AdfMark::strike(), AdfMark::strong(), AdfMark::em()],
11716 &["strike", "strong", "em"],
11717 );
11718 assert_mark_order_round_trip(
11719 vec![AdfMark::strike(), AdfMark::em(), AdfMark::strong()],
11720 &["strike", "em", "strong"],
11721 );
11722 assert_mark_order_round_trip(
11723 vec![AdfMark::strong(), AdfMark::strike(), AdfMark::em()],
11724 &["strong", "strike", "em"],
11725 );
11726 assert_mark_order_round_trip(
11727 vec![AdfMark::strong(), AdfMark::em(), AdfMark::strike()],
11728 &["strong", "em", "strike"],
11729 );
11730 assert_mark_order_round_trip(
11731 vec![AdfMark::em(), AdfMark::strike(), AdfMark::strong()],
11732 &["em", "strike", "strong"],
11733 );
11734 assert_mark_order_round_trip(
11735 vec![AdfMark::em(), AdfMark::strong(), AdfMark::strike()],
11736 &["em", "strong", "strike"],
11737 );
11738 }
11739
11740 #[test]
11741 fn round_trip_underline_nested_with_strong_em() {
11742 assert_mark_order_round_trip(
11745 vec![AdfMark::underline(), AdfMark::strong(), AdfMark::em()],
11746 &["underline", "strong", "em"],
11747 );
11748 assert_mark_order_round_trip(
11749 vec![AdfMark::strong(), AdfMark::underline(), AdfMark::em()],
11750 &["strong", "underline", "em"],
11751 );
11752 assert_mark_order_round_trip(
11753 vec![AdfMark::strong(), AdfMark::em(), AdfMark::underline()],
11754 &["strong", "em", "underline"],
11755 );
11756 }
11757
11758 #[test]
11759 fn round_trip_span_attr_order_preserved() {
11760 assert_mark_order_round_trip(
11764 vec![
11765 AdfMark::background_color("#ffff00"),
11766 AdfMark::text_color("#ff0000"),
11767 ],
11768 &["backgroundColor", "textColor"],
11769 );
11770 assert_mark_order_round_trip(
11771 vec![AdfMark::subsup("sub"), AdfMark::text_color("#ff0000")],
11772 &["subsup", "textColor"],
11773 );
11774 assert_mark_order_round_trip(
11775 vec![
11776 AdfMark::text_color("#ff0000"),
11777 AdfMark::background_color("#ffff00"),
11778 ],
11779 &["textColor", "backgroundColor"],
11780 );
11781 }
11782
11783 #[test]
11784 fn round_trip_annotation_before_underline() {
11785 assert_mark_order_round_trip(
11789 vec![
11790 AdfMark::annotation("ann-1", "inlineComment"),
11791 AdfMark::underline(),
11792 ],
11793 &["annotation", "underline"],
11794 );
11795 assert_mark_order_round_trip(
11796 vec![
11797 AdfMark::annotation("ann-1", "inlineComment"),
11798 AdfMark::underline(),
11799 AdfMark::annotation("ann-2", "inlineComment"),
11800 ],
11801 &["annotation", "underline", "annotation"],
11802 );
11803 }
11804
11805 #[test]
11806 fn round_trip_em_content_with_underscores() {
11807 let doc = AdfDocument {
11812 version: 1,
11813 doc_type: "doc".to_string(),
11814 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11815 "foo _bar_ baz",
11816 vec![AdfMark::em(), AdfMark::strong()],
11817 )])],
11818 };
11819 let md = adf_to_markdown(&doc).unwrap();
11820 let round_tripped = markdown_to_adf(&md).unwrap();
11821 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11822 assert_eq!(node.text.as_deref(), Some("foo _bar_ baz"));
11823 let mark_types: Vec<&str> = node
11824 .marks
11825 .as_ref()
11826 .unwrap()
11827 .iter()
11828 .map(|m| m.mark_type.as_str())
11829 .collect();
11830 assert_eq!(mark_types, vec!["em", "strong"]);
11831 }
11832
11833 #[test]
11834 fn round_trip_link_nested_with_formatting_marks() {
11835 assert_mark_order_round_trip(
11838 vec![
11839 AdfMark::link("https://example.com"),
11840 AdfMark::strong(),
11841 AdfMark::em(),
11842 ],
11843 &["link", "strong", "em"],
11844 );
11845 assert_mark_order_round_trip(
11846 vec![
11847 AdfMark::em(),
11848 AdfMark::strong(),
11849 AdfMark::link("https://example.com"),
11850 ],
11851 &["em", "strong", "link"],
11852 );
11853 assert_mark_order_round_trip(
11854 vec![
11855 AdfMark::underline(),
11856 AdfMark::link("https://example.com"),
11857 AdfMark::strong(),
11858 ],
11859 &["underline", "link", "strong"],
11860 );
11861 }
11862
11863 fn bare_mark(mark_type: &str) -> AdfMark {
11867 AdfMark {
11868 mark_type: mark_type.to_string(),
11869 attrs: None,
11870 }
11871 }
11872
11873 #[test]
11874 fn collect_span_attr_handles_missing_attrs() {
11875 let mut attrs = Vec::new();
11880 collect_span_attr(&bare_mark("textColor"), &mut attrs);
11881 collect_span_attr(&bare_mark("backgroundColor"), &mut attrs);
11882 collect_span_attr(&bare_mark("subsup"), &mut attrs);
11883 collect_span_attr(&bare_mark("link"), &mut attrs);
11884 assert!(attrs.is_empty(), "got: {attrs:?}");
11885 }
11886
11887 #[test]
11888 fn collect_bracketed_attr_handles_missing_attrs() {
11889 let mut attrs = Vec::new();
11892 collect_bracketed_attr(&bare_mark("annotation"), &mut attrs);
11893 collect_bracketed_attr(&bare_mark("strong"), &mut attrs);
11894 assert!(attrs.is_empty(), "got: {attrs:?}");
11895 }
11896
11897 #[test]
11898 fn collect_bracketed_attr_handles_annotation_without_id() {
11899 let mark = AdfMark {
11903 mark_type: "annotation".to_string(),
11904 attrs: Some(serde_json::json!({})),
11905 };
11906 let mut attrs = Vec::new();
11907 collect_bracketed_attr(&mark, &mut attrs);
11908 assert!(attrs.is_empty(), "got: {attrs:?}");
11909 }
11910
11911 #[test]
11912 fn span_attr_order_rejects_unknown_types() {
11913 assert_eq!(span_attr_order("textColor"), 0);
11917 assert_eq!(span_attr_order("backgroundColor"), 1);
11918 assert_eq!(span_attr_order("subsup"), 2);
11919 assert_eq!(span_attr_order("strong"), u8::MAX);
11920 assert!(!span_run_is_canonical(&[bare_mark("strong")]));
11921 }
11922
11923 #[test]
11924 fn bracketed_run_rejects_unknown_types() {
11925 assert!(bracketed_run_is_canonical(&[
11929 AdfMark::underline(),
11930 AdfMark::annotation("x", "inlineComment")
11931 ]));
11932 assert!(!bracketed_run_is_canonical(&[
11933 AdfMark::annotation("x", "inlineComment"),
11934 AdfMark::underline()
11935 ]));
11936 assert!(!bracketed_run_is_canonical(&[bare_mark("strong")]));
11937 }
11938
11939 #[test]
11940 fn render_marked_text_ignores_unknown_mark_types() {
11941 let doc = AdfDocument {
11945 version: 1,
11946 doc_type: "doc".to_string(),
11947 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11948 "hello",
11949 vec![bare_mark("futureMark"), AdfMark::strong()],
11950 )])],
11951 };
11952 let md = adf_to_markdown(&doc).unwrap();
11953 assert_eq!(md.trim(), "**hello**");
11954 let rt = markdown_to_adf(&md).unwrap();
11955 let node = &rt.content[0].content.as_ref().unwrap()[0];
11956 assert_eq!(node.text.as_deref(), Some("hello"));
11957 let mark_types: Vec<&str> = node
11958 .marks
11959 .as_ref()
11960 .unwrap()
11961 .iter()
11962 .map(|m| m.mark_type.as_str())
11963 .collect();
11964 assert_eq!(mark_types, vec!["strong"]);
11965 }
11966
11967 #[test]
11968 fn triple_asterisk_parse_to_adf() {
11969 let md = "***bold and italic***\n";
11971 let doc = markdown_to_adf(md).unwrap();
11972 let node = &doc.content[0].content.as_ref().unwrap()[0];
11973 assert_eq!(node.text.as_deref(), Some("bold and italic"));
11974 let mark_types: Vec<&str> = node
11975 .marks
11976 .as_ref()
11977 .unwrap()
11978 .iter()
11979 .map(|m| m.mark_type.as_str())
11980 .collect();
11981 assert!(
11982 mark_types.contains(&"strong") && mark_types.contains(&"em"),
11983 "***text*** should produce both strong and em marks, got: {mark_types:?}"
11984 );
11985 }
11986
11987 #[test]
11988 fn triple_asterisk_with_surrounding_text() {
11989 let md = "before ***bold italic*** after\n";
11991 let doc = markdown_to_adf(md).unwrap();
11992 let nodes = doc.content[0].content.as_ref().unwrap();
11993 assert!(
11995 nodes.len() >= 3,
11996 "expected at least 3 nodes, got {}",
11997 nodes.len()
11998 );
11999 assert_eq!(nodes[0].text.as_deref(), Some("before "));
12000 assert_eq!(nodes[1].text.as_deref(), Some("bold italic"));
12001 let mark_types: Vec<&str> = nodes[1]
12002 .marks
12003 .as_ref()
12004 .unwrap()
12005 .iter()
12006 .map(|m| m.mark_type.as_str())
12007 .collect();
12008 assert!(
12009 mark_types.contains(&"strong") && mark_types.contains(&"em"),
12010 "middle node should have strong+em, got: {mark_types:?}"
12011 );
12012 assert_eq!(nodes[2].text.as_deref(), Some(" after"));
12013 }
12014
12015 #[test]
12016 fn annotation_mark_round_trip() {
12017 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12019 {"type":"text","text":"highlighted text","marks":[
12020 {"type":"annotation","attrs":{"id":"abc123","annotationType":"inlineComment"}}
12021 ]}
12022 ]}]}"#;
12023 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12024
12025 let md = adf_to_markdown(&doc).unwrap();
12026 assert!(
12027 md.contains("annotation-id="),
12028 "JFM should contain annotation-id, got: {md}"
12029 );
12030
12031 let round_tripped = markdown_to_adf(&md).unwrap();
12032 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12033 assert_eq!(text_node.text.as_deref(), Some("highlighted text"));
12034 let marks = text_node.marks.as_ref().expect("should have marks");
12035 let ann = marks
12036 .iter()
12037 .find(|m| m.mark_type == "annotation")
12038 .expect("should have annotation mark");
12039 let attrs = ann.attrs.as_ref().unwrap();
12040 assert_eq!(attrs["id"], "abc123");
12041 assert_eq!(attrs["annotationType"], "inlineComment");
12042 }
12043
12044 #[test]
12045 fn annotation_mark_with_bold() {
12046 let doc = AdfDocument {
12048 version: 1,
12049 doc_type: "doc".to_string(),
12050 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12051 "bold comment",
12052 vec![
12053 AdfMark::strong(),
12054 AdfMark::annotation("def456", "inlineComment"),
12055 ],
12056 )])],
12057 };
12058 let md = adf_to_markdown(&doc).unwrap();
12059 let round_tripped = markdown_to_adf(&md).unwrap();
12060 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12061 let marks = text_node.marks.as_ref().expect("should have marks");
12062 assert!(
12063 marks.iter().any(|m| m.mark_type == "strong"),
12064 "should have strong mark"
12065 );
12066 assert!(
12067 marks.iter().any(|m| m.mark_type == "annotation"),
12068 "should have annotation mark"
12069 );
12070 }
12071
12072 #[test]
12073 fn annotation_and_link_marks_both_preserved() {
12074 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12076 {"type":"text","text":"HANGUL-8","marks":[
12077 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"5ca7425e-34cd-48d3-b4eb-9873ac8b20e0"}},
12078 {"type":"link","attrs":{"href":"https://zd.atlassian.net/browse/HANG-8"}}
12079 ]}
12080 ]}]}"#;
12081 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12082 let md = adf_to_markdown(&doc).unwrap();
12083 assert!(
12085 md.contains("annotation-id="),
12086 "JFM should contain annotation-id, got: {md}"
12087 );
12088 assert!(
12089 md.contains("](https://"),
12090 "JFM should contain link href, got: {md}"
12091 );
12092 let round_tripped = markdown_to_adf(&md).unwrap();
12093 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12094 let marks = text_node.marks.as_ref().expect("should have marks");
12095 assert!(
12096 marks.iter().any(|m| m.mark_type == "annotation"),
12097 "should have annotation mark, got: {:?}",
12098 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12099 );
12100 assert!(
12101 marks.iter().any(|m| m.mark_type == "link"),
12102 "should have link mark, got: {:?}",
12103 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12104 );
12105 }
12106
12107 #[test]
12108 fn annotation_and_code_marks_both_preserved() {
12109 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12111 {"type":"text","text":"some text with "},
12112 {"type":"text","text":"annotated code","marks":[
12113 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"aabbccdd-1234-5678-abcd-000000000001"}},
12114 {"type":"code"}
12115 ]},
12116 {"type":"text","text":" remaining text"}
12117 ]}]}"#;
12118 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12119 let md = adf_to_markdown(&doc).unwrap();
12120 assert!(
12121 md.contains("annotation-id="),
12122 "JFM should contain annotation-id, got: {md}"
12123 );
12124 assert!(
12125 md.contains('`'),
12126 "JFM should contain backticks for code, got: {md}"
12127 );
12128
12129 let round_tripped = markdown_to_adf(&md).unwrap();
12130 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12131 let code_node = nodes
12133 .iter()
12134 .find(|n| n.text.as_deref() == Some("annotated code"))
12135 .expect("should have 'annotated code' text node");
12136 let marks = code_node.marks.as_ref().expect("should have marks");
12137 assert!(
12138 marks.iter().any(|m| m.mark_type == "annotation"),
12139 "should have annotation mark, got: {:?}",
12140 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12141 );
12142 assert!(
12143 marks.iter().any(|m| m.mark_type == "code"),
12144 "should have code mark, got: {:?}",
12145 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12146 );
12147 let ann = marks.iter().find(|m| m.mark_type == "annotation").unwrap();
12148 let attrs = ann.attrs.as_ref().unwrap();
12149 assert_eq!(attrs["id"], "aabbccdd-1234-5678-abcd-000000000001");
12150 assert_eq!(attrs["annotationType"], "inlineComment");
12151 }
12152
12153 #[test]
12154 fn annotation_and_code_and_link_marks_all_preserved() {
12155 let doc = AdfDocument {
12157 version: 1,
12158 doc_type: "doc".to_string(),
12159 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12160 "linked code",
12161 vec![
12162 AdfMark::annotation("ann-001", "inlineComment"),
12163 AdfMark::code(),
12164 AdfMark::link("https://example.com"),
12165 ],
12166 )])],
12167 };
12168 let md = adf_to_markdown(&doc).unwrap();
12169 assert!(
12170 md.contains("annotation-id="),
12171 "JFM should contain annotation-id, got: {md}"
12172 );
12173 assert!(md.contains('`'), "JFM should contain backticks, got: {md}");
12174 assert!(
12175 md.contains("](https://example.com)"),
12176 "JFM should contain link, got: {md}"
12177 );
12178
12179 let round_tripped = markdown_to_adf(&md).unwrap();
12180 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12181 let marks = text_node.marks.as_ref().expect("should have marks");
12182 assert!(
12183 marks.iter().any(|m| m.mark_type == "annotation"),
12184 "should have annotation mark, got: {:?}",
12185 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12186 );
12187 assert!(
12188 marks.iter().any(|m| m.mark_type == "code"),
12189 "should have code mark, got: {:?}",
12190 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12191 );
12192 assert!(
12193 marks.iter().any(|m| m.mark_type == "link"),
12194 "should have link mark, got: {:?}",
12195 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12196 );
12197 }
12198
12199 #[test]
12200 fn multiple_annotations_and_code_mark_preserved() {
12201 let doc = AdfDocument {
12203 version: 1,
12204 doc_type: "doc".to_string(),
12205 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12206 "doubly annotated",
12207 vec![
12208 AdfMark::annotation("ann-aaa", "inlineComment"),
12209 AdfMark::annotation("ann-bbb", "inlineComment"),
12210 AdfMark::code(),
12211 ],
12212 )])],
12213 };
12214 let md = adf_to_markdown(&doc).unwrap();
12215 assert!(
12216 md.contains("ann-aaa"),
12217 "JFM should contain first annotation id, got: {md}"
12218 );
12219 assert!(
12220 md.contains("ann-bbb"),
12221 "JFM should contain second annotation id, got: {md}"
12222 );
12223
12224 let round_tripped = markdown_to_adf(&md).unwrap();
12225 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12226 let marks = text_node.marks.as_ref().expect("should have marks");
12227 let ann_marks: Vec<_> = marks
12228 .iter()
12229 .filter(|m| m.mark_type == "annotation")
12230 .collect();
12231 assert_eq!(
12232 ann_marks.len(),
12233 2,
12234 "should have 2 annotation marks, got: {}",
12235 ann_marks.len()
12236 );
12237 assert!(
12238 marks.iter().any(|m| m.mark_type == "code"),
12239 "should have code mark"
12240 );
12241 }
12242
12243 #[test]
12244 fn underline_and_link_marks_both_preserved() {
12245 let doc = AdfDocument {
12247 version: 1,
12248 doc_type: "doc".to_string(),
12249 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12250 "click here",
12251 vec![AdfMark::underline(), AdfMark::link("https://example.com")],
12252 )])],
12253 };
12254 let md = adf_to_markdown(&doc).unwrap();
12255 assert!(md.contains("underline"), "should have underline attr: {md}");
12256 assert!(
12257 md.contains("](https://example.com)"),
12258 "should have link: {md}"
12259 );
12260 let round_tripped = markdown_to_adf(&md).unwrap();
12261 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12262 let marks = text_node.marks.as_ref().expect("should have marks");
12263 assert!(marks.iter().any(|m| m.mark_type == "underline"));
12264 assert!(marks.iter().any(|m| m.mark_type == "link"));
12265 }
12266
12267 #[test]
12268 fn annotation_link_and_bold_all_preserved() {
12269 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12271 {"type":"text","text":"important","marks":[
12272 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"abc"}},
12273 {"type":"link","attrs":{"href":"https://example.com"}},
12274 {"type":"strong"}
12275 ]}
12276 ]}]}"#;
12277 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12278 let md = adf_to_markdown(&doc).unwrap();
12279 let round_tripped = markdown_to_adf(&md).unwrap();
12280 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12281 let marks = text_node.marks.as_ref().expect("should have marks");
12282 assert!(
12283 marks.iter().any(|m| m.mark_type == "annotation"),
12284 "should have annotation"
12285 );
12286 assert!(
12287 marks.iter().any(|m| m.mark_type == "link"),
12288 "should have link"
12289 );
12290 assert!(
12291 marks.iter().any(|m| m.mark_type == "strong"),
12292 "should have strong"
12293 );
12294 }
12295
12296 #[test]
12297 fn multiple_annotation_marks_round_trip() {
12298 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12300 {"type":"text","text":"some annotated text","marks":[
12301 {"type":"annotation","attrs":{"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","annotationType":"inlineComment"}},
12302 {"type":"annotation","attrs":{"id":"ffffffff-1111-2222-3333-444444444444","annotationType":"inlineComment"}}
12303 ]}
12304 ]}]}"#;
12305 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12306
12307 let md = adf_to_markdown(&doc).unwrap();
12308 assert!(
12309 md.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
12310 "JFM should contain first annotation id, got: {md}"
12311 );
12312 assert!(
12313 md.contains("ffffffff-1111-2222-3333-444444444444"),
12314 "JFM should contain second annotation id, got: {md}"
12315 );
12316
12317 let round_tripped = markdown_to_adf(&md).unwrap();
12318 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12319 assert_eq!(text_node.text.as_deref(), Some("some annotated text"));
12320 let marks = text_node.marks.as_ref().expect("should have marks");
12321 let annotations: Vec<_> = marks
12322 .iter()
12323 .filter(|m| m.mark_type == "annotation")
12324 .collect();
12325 assert_eq!(
12326 annotations.len(),
12327 2,
12328 "should have 2 annotation marks, got: {annotations:?}"
12329 );
12330 let ids: Vec<_> = annotations
12331 .iter()
12332 .map(|a| a.attrs.as_ref().unwrap()["id"].as_str().unwrap())
12333 .collect();
12334 assert!(ids.contains(&"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
12335 assert!(ids.contains(&"ffffffff-1111-2222-3333-444444444444"));
12336 }
12337
12338 #[test]
12339 fn three_annotation_marks_round_trip() {
12340 let doc = AdfDocument {
12342 version: 1,
12343 doc_type: "doc".to_string(),
12344 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12345 "triple annotated",
12346 vec![
12347 AdfMark::annotation("id-1", "inlineComment"),
12348 AdfMark::annotation("id-2", "inlineComment"),
12349 AdfMark::annotation("id-3", "inlineComment"),
12350 ],
12351 )])],
12352 };
12353 let md = adf_to_markdown(&doc).unwrap();
12354 let round_tripped = markdown_to_adf(&md).unwrap();
12355 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12356 let marks = text_node.marks.as_ref().expect("should have marks");
12357 let annotations: Vec<_> = marks
12358 .iter()
12359 .filter(|m| m.mark_type == "annotation")
12360 .collect();
12361 assert_eq!(
12362 annotations.len(),
12363 3,
12364 "should have 3 annotation marks, got: {annotations:?}"
12365 );
12366 }
12367
12368 #[test]
12369 fn multiple_annotations_with_bold_round_trip() {
12370 let doc = AdfDocument {
12372 version: 1,
12373 doc_type: "doc".to_string(),
12374 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12375 "bold double annotated",
12376 vec![
12377 AdfMark::strong(),
12378 AdfMark::annotation("ann-a", "inlineComment"),
12379 AdfMark::annotation("ann-b", "inlineComment"),
12380 ],
12381 )])],
12382 };
12383 let md = adf_to_markdown(&doc).unwrap();
12384 let round_tripped = markdown_to_adf(&md).unwrap();
12385 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12386 let marks = text_node.marks.as_ref().expect("should have marks");
12387 assert!(
12388 marks.iter().any(|m| m.mark_type == "strong"),
12389 "should have strong mark"
12390 );
12391 let annotations: Vec<_> = marks
12392 .iter()
12393 .filter(|m| m.mark_type == "annotation")
12394 .collect();
12395 assert_eq!(
12396 annotations.len(),
12397 2,
12398 "should have 2 annotation marks, got: {annotations:?}"
12399 );
12400 }
12401
12402 #[test]
12403 fn multiple_annotations_with_link_round_trip() {
12404 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12406 {"type":"text","text":"linked text","marks":[
12407 {"type":"annotation","attrs":{"id":"ann-x","annotationType":"inlineComment"}},
12408 {"type":"annotation","attrs":{"id":"ann-y","annotationType":"inlineComment"}},
12409 {"type":"link","attrs":{"href":"https://example.com"}}
12410 ]}
12411 ]}]}"#;
12412 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12413 let md = adf_to_markdown(&doc).unwrap();
12414 let round_tripped = markdown_to_adf(&md).unwrap();
12415 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12416 let marks = text_node.marks.as_ref().expect("should have marks");
12417 assert!(
12418 marks.iter().any(|m| m.mark_type == "link"),
12419 "should have link mark"
12420 );
12421 let annotations: Vec<_> = marks
12422 .iter()
12423 .filter(|m| m.mark_type == "annotation")
12424 .collect();
12425 assert_eq!(
12426 annotations.len(),
12427 2,
12428 "should have 2 annotation marks, got: {annotations:?}"
12429 );
12430 }
12431
12432 #[test]
12435 fn annotation_on_emoji_round_trip() {
12436 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12438 {"type":"emoji","attrs":{"id":"1f4dd","shortName":":memo:","text":"📝"},"marks":[
12439 {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12440 ]},
12441 {"type":"text","text":" annotated text","marks":[
12442 {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12443 ]}
12444 ]}]}"#;
12445 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12446 let md = adf_to_markdown(&doc).unwrap();
12447 assert!(
12448 md.contains("annotation-id="),
12449 "JFM should contain annotation-id for emoji, got: {md}"
12450 );
12451
12452 let round_tripped = markdown_to_adf(&md).unwrap();
12453 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12454
12455 let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12457 let emoji_marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12458 assert!(
12459 emoji_marks.iter().any(|m| m.mark_type == "annotation"),
12460 "emoji should have annotation mark, got: {emoji_marks:?}"
12461 );
12462 let ann = emoji_marks
12463 .iter()
12464 .find(|m| m.mark_type == "annotation")
12465 .unwrap();
12466 assert_eq!(
12467 ann.attrs.as_ref().unwrap()["id"],
12468 "ccddee11-2233-4455-aabb-ccddee112233"
12469 );
12470
12471 let text_node = nodes.iter().find(|n| n.node_type == "text").unwrap();
12473 let text_marks = text_node.marks.as_ref().expect("text should have marks");
12474 assert!(
12475 text_marks.iter().any(|m| m.mark_type == "annotation"),
12476 "text should have annotation mark"
12477 );
12478 }
12479
12480 #[test]
12481 fn annotation_on_status_round_trip() {
12482 let mut status = AdfNode::status("In Progress", "blue");
12483 status.marks = Some(vec![AdfMark::annotation("ann-status-1", "inlineComment")]);
12484
12485 let doc = AdfDocument {
12486 version: 1,
12487 doc_type: "doc".to_string(),
12488 content: vec![AdfNode::paragraph(vec![status])],
12489 };
12490 let md = adf_to_markdown(&doc).unwrap();
12491 assert!(
12492 md.contains("annotation-id="),
12493 "JFM should contain annotation-id for status, got: {md}"
12494 );
12495
12496 let round_tripped = markdown_to_adf(&md).unwrap();
12497 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12498 let status_node = nodes.iter().find(|n| n.node_type == "status").unwrap();
12499 let marks = status_node
12500 .marks
12501 .as_ref()
12502 .expect("status should have marks");
12503 assert!(
12504 marks.iter().any(|m| m.mark_type == "annotation"),
12505 "status should have annotation mark, got: {marks:?}"
12506 );
12507 }
12508
12509 #[test]
12510 fn annotation_on_date_round_trip() {
12511 let mut date = AdfNode::date("1704067200000");
12512 date.marks = Some(vec![AdfMark::annotation("ann-date-1", "inlineComment")]);
12513
12514 let doc = AdfDocument {
12515 version: 1,
12516 doc_type: "doc".to_string(),
12517 content: vec![AdfNode::paragraph(vec![date])],
12518 };
12519 let md = adf_to_markdown(&doc).unwrap();
12520 assert!(
12521 md.contains("annotation-id="),
12522 "JFM should contain annotation-id for date, got: {md}"
12523 );
12524
12525 let round_tripped = markdown_to_adf(&md).unwrap();
12526 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12527 let date_node = nodes.iter().find(|n| n.node_type == "date").unwrap();
12528 let marks = date_node.marks.as_ref().expect("date should have marks");
12529 assert!(
12530 marks.iter().any(|m| m.mark_type == "annotation"),
12531 "date should have annotation mark, got: {marks:?}"
12532 );
12533 }
12534
12535 #[test]
12536 fn annotation_on_mention_round_trip() {
12537 let mut mention = AdfNode::mention("user-123", "@Alice");
12538 mention.marks = Some(vec![AdfMark::annotation("ann-mention-1", "inlineComment")]);
12539
12540 let doc = AdfDocument {
12541 version: 1,
12542 doc_type: "doc".to_string(),
12543 content: vec![AdfNode::paragraph(vec![mention])],
12544 };
12545 let md = adf_to_markdown(&doc).unwrap();
12546 assert!(
12547 md.contains("annotation-id="),
12548 "JFM should contain annotation-id for mention, got: {md}"
12549 );
12550
12551 let round_tripped = markdown_to_adf(&md).unwrap();
12552 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12553 let mention_node = nodes.iter().find(|n| n.node_type == "mention").unwrap();
12554 let marks = mention_node
12555 .marks
12556 .as_ref()
12557 .expect("mention should have marks");
12558 assert!(
12559 marks.iter().any(|m| m.mark_type == "annotation"),
12560 "mention should have annotation mark, got: {marks:?}"
12561 );
12562 }
12563
12564 #[test]
12565 fn annotation_on_inline_card_round_trip() {
12566 let mut card = AdfNode::inline_card("https://example.com");
12567 card.marks = Some(vec![AdfMark::annotation("ann-card-1", "inlineComment")]);
12568
12569 let doc = AdfDocument {
12570 version: 1,
12571 doc_type: "doc".to_string(),
12572 content: vec![AdfNode::paragraph(vec![card])],
12573 };
12574 let md = adf_to_markdown(&doc).unwrap();
12575 assert!(
12576 md.contains("annotation-id="),
12577 "JFM should contain annotation-id for inlineCard, got: {md}"
12578 );
12579
12580 let round_tripped = markdown_to_adf(&md).unwrap();
12581 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12582 let card_node = nodes.iter().find(|n| n.node_type == "inlineCard").unwrap();
12583 let marks = card_node
12584 .marks
12585 .as_ref()
12586 .expect("inlineCard should have marks");
12587 assert!(
12588 marks.iter().any(|m| m.mark_type == "annotation"),
12589 "inlineCard should have annotation mark, got: {marks:?}"
12590 );
12591 }
12592
12593 #[test]
12594 fn annotation_on_placeholder_round_trip() {
12595 let mut placeholder = AdfNode::placeholder("Enter text here");
12596 placeholder.marks = Some(vec![AdfMark::annotation("ann-ph-1", "inlineComment")]);
12597
12598 let doc = AdfDocument {
12599 version: 1,
12600 doc_type: "doc".to_string(),
12601 content: vec![AdfNode::paragraph(vec![placeholder])],
12602 };
12603 let md = adf_to_markdown(&doc).unwrap();
12604 assert!(
12605 md.contains("annotation-id="),
12606 "JFM should contain annotation-id for placeholder, got: {md}"
12607 );
12608
12609 let round_tripped = markdown_to_adf(&md).unwrap();
12610 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12611 let ph_node = nodes.iter().find(|n| n.node_type == "placeholder").unwrap();
12612 let marks = ph_node
12613 .marks
12614 .as_ref()
12615 .expect("placeholder should have marks");
12616 assert!(
12617 marks.iter().any(|m| m.mark_type == "annotation"),
12618 "placeholder should have annotation mark, got: {marks:?}"
12619 );
12620 }
12621
12622 #[test]
12623 fn multiple_annotations_on_emoji_round_trip() {
12624 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12626 {"type":"emoji","attrs":{"shortName":":fire:","text":"🔥"},"marks":[
12627 {"type":"annotation","attrs":{"id":"ann-1","annotationType":"inlineComment"}},
12628 {"type":"annotation","attrs":{"id":"ann-2","annotationType":"inlineComment"}}
12629 ]}
12630 ]}]}"#;
12631 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12632 let md = adf_to_markdown(&doc).unwrap();
12633
12634 let round_tripped = markdown_to_adf(&md).unwrap();
12635 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12636 let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12637 let marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12638 let annotations: Vec<_> = marks
12639 .iter()
12640 .filter(|m| m.mark_type == "annotation")
12641 .collect();
12642 assert_eq!(
12643 annotations.len(),
12644 2,
12645 "emoji should have 2 annotation marks, got: {annotations:?}"
12646 );
12647 }
12648
12649 #[test]
12650 fn emoji_without_annotation_unchanged() {
12651 let doc = AdfDocument {
12653 version: 1,
12654 doc_type: "doc".to_string(),
12655 content: vec![AdfNode::paragraph(vec![AdfNode::emoji(":fire:")])],
12656 };
12657 let md = adf_to_markdown(&doc).unwrap();
12658 assert!(
12660 !md.contains('['),
12661 "emoji without annotation should not be wrapped in brackets, got: {md}"
12662 );
12663 assert!(md.contains(":fire:"));
12664 }
12665
12666 #[test]
12669 fn status_directive() {
12670 let doc = markdown_to_adf("The ticket is :status[In Progress]{color=blue}.").unwrap();
12671 let content = doc.content[0].content.as_ref().unwrap();
12672 assert_eq!(content[1].node_type, "status");
12673 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "In Progress");
12674 assert_eq!(content[1].attrs.as_ref().unwrap()["color"], "blue");
12675 }
12676
12677 #[test]
12678 fn adf_status_to_markdown() {
12679 let doc = AdfDocument {
12680 version: 1,
12681 doc_type: "doc".to_string(),
12682 content: vec![AdfNode::paragraph(vec![AdfNode::status("Done", "green")])],
12683 };
12684 let md = adf_to_markdown(&doc).unwrap();
12685 assert!(md.contains(":status[Done]{color=green}"));
12686 }
12687
12688 #[test]
12689 fn round_trip_status() {
12690 let md = "The ticket is :status[In Progress]{color=blue}.\n";
12691 let doc = markdown_to_adf(md).unwrap();
12692 let result = adf_to_markdown(&doc).unwrap();
12693 assert!(result.contains(":status[In Progress]{color=blue}"));
12694 }
12695
12696 #[test]
12697 fn status_with_style_and_localid_roundtrips() {
12698 let adf = AdfDocument {
12699 version: 1,
12700 doc_type: "doc".to_string(),
12701 content: vec![AdfNode::paragraph(vec![{
12702 let mut node = AdfNode::status("open", "green");
12703 node.attrs.as_mut().unwrap()["style"] =
12704 serde_json::Value::String("bold".to_string());
12705 node.attrs.as_mut().unwrap()["localId"] =
12706 serde_json::Value::String("d2205ca5-84b9-4950-a730-bfe550fc146b".to_string());
12707 node
12708 }])],
12709 };
12710
12711 let md = adf_to_markdown(&adf).unwrap();
12712 assert!(
12713 md.contains("style=bold"),
12714 "Markdown should contain style attr: {md}"
12715 );
12716 assert!(
12717 md.contains("localId=d2205ca5"),
12718 "Markdown should contain localId attr: {md}"
12719 );
12720
12721 let rt = markdown_to_adf(&md).unwrap();
12722 let status = &rt.content[0].content.as_ref().unwrap()[0];
12723 let attrs = status.attrs.as_ref().unwrap();
12724 assert_eq!(attrs["text"], "open");
12725 assert_eq!(attrs["color"], "green");
12726 assert_eq!(attrs["style"], "bold");
12727 assert_eq!(
12728 attrs["localId"], "d2205ca5-84b9-4950-a730-bfe550fc146b",
12729 "localId should be preserved, got: {}",
12730 attrs["localId"]
12731 );
12732 }
12733
12734 #[test]
12735 fn status_without_style_still_works() {
12736 let md = ":status[Done]{color=green}\n";
12737 let doc = markdown_to_adf(md).unwrap();
12738 let status = &doc.content[0].content.as_ref().unwrap()[0];
12739 let attrs = status.attrs.as_ref().unwrap();
12740 assert_eq!(attrs["text"], "Done");
12741 assert_eq!(attrs["color"], "green");
12742 assert!(
12744 attrs.get("style").is_none() || attrs["style"].is_null(),
12745 "style should not be set when not provided"
12746 );
12747 }
12748
12749 #[test]
12750 fn strip_local_ids_removes_localid_from_status() {
12751 let adf = AdfDocument {
12752 version: 1,
12753 doc_type: "doc".to_string(),
12754 content: vec![AdfNode::paragraph(vec![{
12755 let mut node = AdfNode::status("open", "green");
12756 node.attrs.as_mut().unwrap()["localId"] =
12757 serde_json::Value::String("real-uuid-here".to_string());
12758 node
12759 }])],
12760 };
12761 let opts = RenderOptions {
12762 strip_local_ids: true,
12763 };
12764 let md = adf_to_markdown_with_options(&adf, &opts).unwrap();
12765 assert!(
12766 !md.contains("localId"),
12767 "localId should be stripped, got: {md}"
12768 );
12769 assert!(md.contains("color=green"), "color should be preserved");
12770 }
12771
12772 #[test]
12773 fn strip_local_ids_removes_localid_from_table() {
12774 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"}]}]}]}]}]}"#;
12775 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12776 let opts = RenderOptions {
12777 strip_local_ids: true,
12778 };
12779 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12780 assert!(
12781 !md.contains("localId"),
12782 "localId should be stripped from table, got: {md}"
12783 );
12784 assert!(md.contains("layout=default"), "layout should be preserved");
12785 }
12786
12787 #[test]
12788 fn default_options_preserve_localid() {
12789 let adf = AdfDocument {
12790 version: 1,
12791 doc_type: "doc".to_string(),
12792 content: vec![AdfNode::paragraph(vec![{
12793 let mut node = AdfNode::status("open", "green");
12794 node.attrs.as_mut().unwrap()["localId"] =
12795 serde_json::Value::String("real-uuid-here".to_string());
12796 node
12797 }])],
12798 };
12799 let md = adf_to_markdown(&adf).unwrap();
12800 assert!(
12801 md.contains("localId=real-uuid-here"),
12802 "Default should preserve localId, got: {md}"
12803 );
12804 }
12805
12806 #[test]
12807 fn mention_localid_roundtrip() {
12808 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12809 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12810 let md = adf_to_markdown(&doc).unwrap();
12811 assert!(
12812 md.contains("localId=m-001"),
12813 "mention should have localId in md: {md}"
12814 );
12815 let rt = markdown_to_adf(&md).unwrap();
12816 let mention = &rt.content[0].content.as_ref().unwrap()[0];
12817 assert_eq!(mention.attrs.as_ref().unwrap()["localId"], "m-001");
12818 }
12819
12820 #[test]
12821 fn date_localid_roundtrip() {
12822 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
12823 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12824 let md = adf_to_markdown(&doc).unwrap();
12825 assert!(
12826 md.contains("localId=d-001"),
12827 "date should have localId in md: {md}"
12828 );
12829 let rt = markdown_to_adf(&md).unwrap();
12830 let date = &rt.content[0].content.as_ref().unwrap()[0];
12831 assert_eq!(date.attrs.as_ref().unwrap()["localId"], "d-001");
12832 }
12833
12834 #[test]
12835 fn emoji_localid_roundtrip() {
12836 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"emoji","attrs":{"shortName":":smile:","localId":"e-001"}}]}]}"#;
12837 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12838 let md = adf_to_markdown(&doc).unwrap();
12839 assert!(
12840 md.contains("localId=e-001"),
12841 "emoji should have localId in md: {md}"
12842 );
12843 let rt = markdown_to_adf(&md).unwrap();
12844 let emoji = &rt.content[0].content.as_ref().unwrap()[0];
12845 assert_eq!(emoji.attrs.as_ref().unwrap()["localId"], "e-001");
12846 }
12847
12848 #[test]
12849 fn inline_card_localid_roundtrip() {
12850 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"inlineCard","attrs":{"url":"https://example.com","localId":"c-001"}}]}]}"#;
12851 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12852 let md = adf_to_markdown(&doc).unwrap();
12853 assert!(
12854 md.contains("localId=c-001"),
12855 "inlineCard should have localId in md: {md}"
12856 );
12857 let rt = markdown_to_adf(&md).unwrap();
12858 let card = &rt.content[0].content.as_ref().unwrap()[0];
12859 assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-001");
12860 }
12861
12862 #[test]
12863 fn strip_local_ids_removes_from_mention() {
12864 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12865 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12866 let opts = RenderOptions {
12867 strip_local_ids: true,
12868 };
12869 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12870 assert!(
12871 !md.contains("localId"),
12872 "localId should be stripped from mention: {md}"
12873 );
12874 assert!(md.contains("id=user123"), "other attrs should be preserved");
12875 }
12876
12877 #[test]
12878 fn strip_local_ids_removes_from_date() {
12879 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
12880 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12881 let opts = RenderOptions {
12882 strip_local_ids: true,
12883 };
12884 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12885 assert!(
12886 !md.contains("localId"),
12887 "localId should be stripped from date: {md}"
12888 );
12889 }
12890
12891 #[test]
12892 fn strip_local_ids_removes_from_block_attrs() {
12893 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"hello"}]}]}"#;
12894 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12895 let opts = RenderOptions {
12896 strip_local_ids: true,
12897 };
12898 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12899 assert!(
12900 !md.contains("localId"),
12901 "localId should be stripped from block attrs: {md}"
12902 );
12903 }
12904
12905 #[test]
12906 fn table_cell_localid_roundtrip() {
12907 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"}]}]}]}]}]}"#;
12908 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12909 let md = adf_to_markdown(&doc).unwrap();
12910 assert!(
12911 md.contains("localId=tc-001"),
12912 "tableCell should have localId in md: {md}"
12913 );
12914 let rt = markdown_to_adf(&md).unwrap();
12915 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12916 .content
12917 .as_ref()
12918 .unwrap()[0];
12919 assert_eq!(
12920 cell.attrs.as_ref().unwrap()["localId"],
12921 "tc-001",
12922 "tableCell localId should round-trip"
12923 );
12924 }
12925
12926 #[test]
12927 fn table_cell_border_mark_roundtrip() {
12928 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"}]}]}]}]}]}"##;
12929 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12930 let md = adf_to_markdown(&doc).unwrap();
12931 assert!(
12932 md.contains("border-color=#ff000033"),
12933 "tableCell should have border-color in md: {md}"
12934 );
12935 assert!(
12936 md.contains("border-size=2"),
12937 "tableCell should have border-size in md: {md}"
12938 );
12939 let rt = markdown_to_adf(&md).unwrap();
12940 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12941 .content
12942 .as_ref()
12943 .unwrap()[0];
12944 let marks = cell.marks.as_ref().expect("tableCell should have marks");
12945 assert_eq!(marks.len(), 1);
12946 assert_eq!(marks[0].mark_type, "border");
12947 let attrs = marks[0].attrs.as_ref().unwrap();
12948 assert_eq!(attrs["color"], "#ff000033");
12949 assert_eq!(attrs["size"], 2);
12950 }
12951
12952 #[test]
12953 fn table_header_border_mark_roundtrip() {
12954 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"}]}]}]}]}]}"##;
12955 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12956 let md = adf_to_markdown(&doc).unwrap();
12957 assert!(md.contains("border-color=#0000ff"), "md: {md}");
12958 assert!(md.contains("border-size=3"), "md: {md}");
12959 let rt = markdown_to_adf(&md).unwrap();
12960 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12961 .content
12962 .as_ref()
12963 .unwrap()[0];
12964 assert_eq!(cell.node_type, "tableHeader");
12965 let marks = cell.marks.as_ref().expect("tableHeader should have marks");
12966 assert_eq!(marks[0].mark_type, "border");
12967 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#0000ff");
12968 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12969 }
12970
12971 #[test]
12972 fn table_cell_border_mark_with_attrs_roundtrip() {
12973 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"}]}]}]}]}]}"##;
12974 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12975 let md = adf_to_markdown(&doc).unwrap();
12976 assert!(md.contains("bg=#e6fcff"), "md: {md}");
12977 assert!(md.contains("colspan=2"), "md: {md}");
12978 assert!(md.contains("border-color=#ff000033"), "md: {md}");
12979 let rt = markdown_to_adf(&md).unwrap();
12980 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12981 .content
12982 .as_ref()
12983 .unwrap()[0];
12984 assert_eq!(cell.attrs.as_ref().unwrap()["background"], "#e6fcff");
12985 assert_eq!(cell.attrs.as_ref().unwrap()["colspan"], 2);
12986 let marks = cell.marks.as_ref().expect("should have marks");
12987 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff000033");
12988 }
12989
12990 #[test]
12991 fn table_cell_no_border_mark_unchanged() {
12992 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"}]}]}]}]}]}"#;
12993 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12994 let md = adf_to_markdown(&doc).unwrap();
12995 assert!(
12996 !md.contains("border-color"),
12997 "no border attrs expected: {md}"
12998 );
12999 let rt = markdown_to_adf(&md).unwrap();
13000 let cell = &rt.content[0].content.as_ref().unwrap()[0]
13001 .content
13002 .as_ref()
13003 .unwrap()[0];
13004 assert!(cell.marks.is_none(), "no marks expected on plain cell");
13005 }
13006
13007 #[test]
13008 fn table_cell_border_size_only_defaults_color() {
13009 let md = "::::table\n:::tr\n:::td{border-size=3}\ncell\n:::\n:::\n::::\n";
13012 let doc = markdown_to_adf(md).unwrap();
13013 let cell = &doc.content[0].content.as_ref().unwrap()[0]
13014 .content
13015 .as_ref()
13016 .unwrap()[0];
13017 let marks = cell.marks.as_ref().expect("should have border mark");
13018 assert_eq!(marks[0].mark_type, "border");
13019 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
13020 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
13021 }
13022
13023 #[test]
13024 fn table_cell_border_color_only_defaults_size() {
13025 let md = "::::table\n:::tr\n:::td{border-color=#ff0000}\ncell\n:::\n:::\n::::\n";
13027 let doc = markdown_to_adf(md).unwrap();
13028 let cell = &doc.content[0].content.as_ref().unwrap()[0]
13029 .content
13030 .as_ref()
13031 .unwrap()[0];
13032 let marks = cell.marks.as_ref().expect("should have border mark");
13033 assert_eq!(marks[0].mark_type, "border");
13034 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
13035 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
13036 }
13037
13038 #[test]
13039 fn media_file_border_mark_roundtrip() {
13040 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}}]}]}]}"##;
13041 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13042 let md = adf_to_markdown(&doc).unwrap();
13043 assert!(
13044 md.contains("border-color=#091e4224"),
13045 "media should have border-color in md: {md}"
13046 );
13047 assert!(
13048 md.contains("border-size=2"),
13049 "media should have border-size in md: {md}"
13050 );
13051 let rt = markdown_to_adf(&md).unwrap();
13052 let media_single = &rt.content[0];
13053 let media = &media_single.content.as_ref().unwrap()[0];
13054 assert_eq!(media.node_type, "media");
13055 let marks = media.marks.as_ref().expect("media should have marks");
13056 assert_eq!(marks.len(), 1);
13057 assert_eq!(marks[0].mark_type, "border");
13058 let attrs = marks[0].attrs.as_ref().unwrap();
13059 assert_eq!(attrs["color"], "#091e4224");
13060 assert_eq!(attrs["size"], 2);
13061 }
13062
13063 #[test]
13064 fn media_external_border_mark_roundtrip() {
13065 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}}]}]}]}"##;
13066 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13067 let md = adf_to_markdown(&doc).unwrap();
13068 assert!(
13069 md.contains("border-color=#ff0000"),
13070 "external media should have border-color in md: {md}"
13071 );
13072 assert!(
13073 md.contains("border-size=3"),
13074 "external media should have border-size in md: {md}"
13075 );
13076 let rt = markdown_to_adf(&md).unwrap();
13077 let media = &rt.content[0].content.as_ref().unwrap()[0];
13078 let marks = media.marks.as_ref().expect("media should have marks");
13079 assert_eq!(marks[0].mark_type, "border");
13080 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
13081 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
13082 }
13083
13084 #[test]
13085 fn media_file_no_border_mark_unchanged() {
13086 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}}]}]}"#;
13087 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13088 let md = adf_to_markdown(&doc).unwrap();
13089 assert!(
13090 !md.contains("border-color"),
13091 "no border attrs expected: {md}"
13092 );
13093 let rt = markdown_to_adf(&md).unwrap();
13094 let media = &rt.content[0].content.as_ref().unwrap()[0];
13095 assert!(media.marks.is_none(), "no marks expected on plain media");
13096 }
13097
13098 #[test]
13099 fn media_border_size_only_defaults_color() {
13100 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}}]}]}]}"#;
13101 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13102 let md = adf_to_markdown(&doc).unwrap();
13103 assert!(md.contains("border-size=4"), "md: {md}");
13104 let rt = markdown_to_adf(&md).unwrap();
13105 let media = &rt.content[0].content.as_ref().unwrap()[0];
13106 let marks = media.marks.as_ref().expect("should have border mark");
13107 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
13108 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 4);
13109 }
13110
13111 #[test]
13112 fn media_border_color_only_defaults_size() {
13113 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"}}]}]}]}"##;
13114 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13115 let md = adf_to_markdown(&doc).unwrap();
13116 assert!(md.contains("border-color=#00ff00"), "md: {md}");
13117 let rt = markdown_to_adf(&md).unwrap();
13118 let media = &rt.content[0].content.as_ref().unwrap()[0];
13119 let marks = media.marks.as_ref().expect("should have border mark");
13120 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#00ff00");
13121 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
13122 }
13123
13124 #[test]
13125 fn media_border_with_other_attrs_roundtrip() {
13126 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}}]}]}]}"##;
13127 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13128 let md = adf_to_markdown(&doc).unwrap();
13129 assert!(md.contains("layout=wide"), "md: {md}");
13130 assert!(md.contains("mediaWidth=600"), "md: {md}");
13131 assert!(md.contains("border-color=#091e4224"), "md: {md}");
13132 assert!(md.contains("border-size=2"), "md: {md}");
13133 let rt = markdown_to_adf(&md).unwrap();
13134 let ms = &rt.content[0];
13135 assert_eq!(ms.attrs.as_ref().unwrap()["layout"], "wide");
13136 let media = &ms.content.as_ref().unwrap()[0];
13137 let marks = media.marks.as_ref().expect("should have marks");
13138 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#091e4224");
13139 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 2);
13140 }
13141
13142 #[test]
13143 fn table_row_localid_roundtrip() {
13144 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"}]}]}]}]}]}"#;
13145 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13146 let md = adf_to_markdown(&doc).unwrap();
13147 assert!(
13148 md.contains("localId=tr-001"),
13149 "tableRow should have localId in md: {md}"
13150 );
13151 let rt = markdown_to_adf(&md).unwrap();
13152 let row = &rt.content[0].content.as_ref().unwrap()[0];
13153 assert_eq!(
13154 row.attrs.as_ref().unwrap()["localId"],
13155 "tr-001",
13156 "tableRow localId should round-trip"
13157 );
13158 }
13159
13160 #[test]
13161 fn list_item_localid_roundtrip() {
13162 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"}]}]}]}]}"#;
13164 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13165 let md = adf_to_markdown(&doc).unwrap();
13166 assert!(
13167 md.contains("localId=li-001"),
13168 "listItem should have localId in md: {md}"
13169 );
13170 let rt = markdown_to_adf(&md).unwrap();
13172 let list = &rt.content[0];
13173 assert!(
13174 list.attrs.is_none() || list.attrs.as_ref().unwrap().get("localId").is_none(),
13175 "bulletList should NOT have localId: {:?}",
13176 list.attrs
13177 );
13178 let item = &list.content.as_ref().unwrap()[0];
13179 assert_eq!(
13180 item.attrs.as_ref().unwrap()["localId"],
13181 "li-001",
13182 "listItem should have localId=li-001"
13183 );
13184 }
13185
13186 #[test]
13187 fn list_item_localid_not_promoted_to_parent() {
13188 let md = "- item {localId=li-002}\n";
13190 let doc = markdown_to_adf(md).unwrap();
13191 let list = &doc.content[0];
13192 assert!(
13193 list.attrs.is_none(),
13194 "bulletList should have no attrs: {:?}",
13195 list.attrs
13196 );
13197 let item = &list.content.as_ref().unwrap()[0];
13198 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "li-002");
13199 }
13200
13201 #[test]
13202 fn ordered_list_item_localid_roundtrip() {
13203 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"}]}]}]}]}"#;
13204 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13205 let md = adf_to_markdown(&doc).unwrap();
13206 assert!(md.contains("localId=oli-001"), "md: {md}");
13207 let rt = markdown_to_adf(&md).unwrap();
13208 let item = &rt.content[0].content.as_ref().unwrap()[0];
13209 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
13210 }
13211
13212 #[test]
13213 fn task_item_localid_roundtrip() {
13214 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"task"}]}]}]}]}"#;
13215 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13216 let md = adf_to_markdown(&doc).unwrap();
13217 assert!(md.contains("localId=ti-001"), "md: {md}");
13218 let rt = markdown_to_adf(&md).unwrap();
13219 let item = &rt.content[0].content.as_ref().unwrap()[0];
13220 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "ti-001");
13221 }
13222
13223 #[test]
13226 fn task_list_short_localid_roundtrip() {
13227 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"}]}]}]}"#;
13228 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13229 let md = adf_to_markdown(&doc).unwrap();
13230 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13232 assert!(md.contains("localId=99"), "localId=99 missing: {md}");
13233 assert!(
13235 !md.contains("localId=}"),
13236 "empty localId should not be emitted: {md}"
13237 );
13238 let rt = markdown_to_adf(&md).unwrap();
13239 let task_list = &rt.content[0];
13240 assert_eq!(task_list.node_type, "taskList");
13241 assert_eq!(rt.content.len(), 1, "should be exactly one top-level node");
13243 let items = task_list.content.as_ref().unwrap();
13244 assert_eq!(items.len(), 2);
13245 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13247 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
13248 assert!(
13249 items[0].content.is_none(),
13250 "empty taskItem should have no content: {:?}",
13251 items[0].content
13252 );
13253 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "99");
13255 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
13256 let content = items[1].content.as_ref().unwrap();
13257 assert_eq!(content.len(), 1);
13258 assert_eq!(content[0].text.as_deref(), Some("done task"));
13259 }
13260
13261 #[test]
13265 fn task_item_numeric_localid_with_hardbreak_roundtrip() {
13266 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!!)"}]}]}]}]}"#;
13267 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13268 let md = adf_to_markdown(&doc).unwrap();
13269 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13271 let rt = markdown_to_adf(&md).unwrap();
13273 assert_eq!(rt.content.len(), 1, "exactly one top-level node");
13274 let task_list = &rt.content[0];
13275 assert_eq!(task_list.node_type, "taskList");
13276 let items = task_list.content.as_ref().unwrap();
13277 assert_eq!(items.len(), 1);
13278 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13280 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
13281 let para = &items[0].content.as_ref().unwrap()[0];
13283 assert_eq!(para.node_type, "paragraph");
13284 let inlines = para.content.as_ref().unwrap();
13285 assert_eq!(inlines[0].node_type, "text");
13286 assert_eq!(
13287 inlines[0].text.as_deref(),
13288 Some("Engineering Onboarding Link")
13289 );
13290 assert_eq!(inlines[1].node_type, "hardBreak");
13291 assert_eq!(inlines[2].node_type, "text");
13292 assert_eq!(
13293 inlines[2].text.as_deref(),
13294 Some("(This has links to all the various useful tools!!)")
13295 );
13296 let rt_json = serde_json::to_string(&rt).unwrap();
13298 assert!(
13299 !rt_json.contains("{localId="),
13300 "localId attr syntax should not leak into ADF text: {rt_json}"
13301 );
13302 }
13303
13304 #[test]
13306 fn task_item_multiple_hardbreak_localids_roundtrip() {
13307 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"}]}]}]}]}"#;
13308 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13309 let md = adf_to_markdown(&doc).unwrap();
13310 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13311 assert!(md.contains("localId=67"), "localId=67 missing: {md}");
13312 let rt = markdown_to_adf(&md).unwrap();
13313 let items = rt.content[0].content.as_ref().unwrap();
13314 assert_eq!(items.len(), 2);
13315 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13316 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "67");
13317 for item in items {
13319 let para = &item.content.as_ref().unwrap()[0];
13320 assert_eq!(para.node_type, "paragraph");
13321 let inlines = para.content.as_ref().unwrap();
13322 assert_eq!(inlines[1].node_type, "hardBreak");
13323 }
13324 }
13325
13326 #[test]
13331 fn task_item_sibling_localid_hardbreak_unwrapped_roundtrip() {
13332 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"}]}]}]}"#;
13333 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13334 let md = adf_to_markdown(&doc).unwrap();
13335 assert!(
13337 md.contains(" (parenthetical"),
13338 "continuation line should be 2-space indented: {md}"
13339 );
13340 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13341 assert!(md.contains("localId=69"), "localId=69 missing: {md}");
13342 let rt = markdown_to_adf(&md).unwrap();
13343 assert_eq!(
13345 rt.content.len(),
13346 1,
13347 "should be one taskList: {:#?}",
13348 rt.content
13349 );
13350 assert_eq!(rt.content[0].node_type, "taskList");
13351 let items = rt.content[0].content.as_ref().unwrap();
13352 assert_eq!(items.len(), 2, "should have 2 taskItems");
13353 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13354 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13355 let first_content = items[0].content.as_ref().unwrap();
13357 assert!(
13358 first_content.iter().any(|n| n.node_type == "hardBreak"),
13359 "first item should contain hardBreak"
13360 );
13361 let second_content = items[1].content.as_ref().unwrap();
13363 assert_eq!(second_content[0].node_type, "text");
13364 assert_eq!(
13365 second_content[0].text.as_deref().unwrap(),
13366 "second task item"
13367 );
13368 }
13369
13370 #[test]
13373 fn task_item_sibling_localid_hardbreak_paragraph_roundtrip() {
13374 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"}]}]}]}]}"#;
13375 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13376 let md = adf_to_markdown(&doc).unwrap();
13377 let rt = markdown_to_adf(&md).unwrap();
13378 assert_eq!(
13379 rt.content.len(),
13380 1,
13381 "should be one taskList: {:#?}",
13382 rt.content
13383 );
13384 let items = rt.content[0].content.as_ref().unwrap();
13385 assert_eq!(items.len(), 2);
13386 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13387 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13388 }
13389
13390 #[test]
13393 fn task_item_three_siblings_middle_hardbreak_roundtrip() {
13394 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"}]}]}]}"#;
13395 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13396 let md = adf_to_markdown(&doc).unwrap();
13397 let rt = markdown_to_adf(&md).unwrap();
13398 assert_eq!(rt.content.len(), 1);
13399 let items = rt.content[0].content.as_ref().unwrap();
13400 assert_eq!(items.len(), 3);
13401 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "10");
13402 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "20");
13403 assert_eq!(items[2].attrs.as_ref().unwrap()["localId"], "30");
13404 let mid_content = items[1].content.as_ref().unwrap();
13406 assert!(mid_content.iter().any(|n| n.node_type == "hardBreak"));
13407 }
13408
13409 #[test]
13412 fn task_list_empty_localid_no_spurious_paragraph() {
13413 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"}]}]}]}"#;
13414 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13415 let md = adf_to_markdown(&doc).unwrap();
13416 assert!(
13417 !md.contains("{localId=}"),
13418 "empty localId should not be emitted: {md}"
13419 );
13420 let rt = markdown_to_adf(&md).unwrap();
13421 assert_eq!(
13422 rt.content.len(),
13423 1,
13424 "no spurious paragraph: {:#?}",
13425 rt.content
13426 );
13427 assert_eq!(rt.content[0].node_type, "taskList");
13428 }
13429
13430 #[test]
13432 fn task_list_localid_stripped() {
13433 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"}]}]}]}]}"#;
13434 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13435 let opts = RenderOptions {
13436 strip_local_ids: true,
13437 };
13438 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13439 assert!(!md.contains("localId"), "localId should be stripped: {md}");
13440 }
13441
13442 #[test]
13444 fn task_item_no_content_emits_localid() {
13445 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"}}]}]}"#;
13446 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13447 let md = adf_to_markdown(&doc).unwrap();
13448 assert!(
13449 md.contains("localId=abc"),
13450 "localId should be emitted even without content: {md}"
13451 );
13452 let rt = markdown_to_adf(&md).unwrap();
13453 let item = &rt.content[0].content.as_ref().unwrap()[0];
13454 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "abc");
13455 assert!(item.content.is_none(), "should have no content");
13456 }
13457
13458 #[test]
13460 fn task_list_localid_roundtrip() {
13461 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"}]}]}]}]}"#;
13462 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13463 let md = adf_to_markdown(&doc).unwrap();
13464 assert!(
13465 md.contains("localId=tl-xyz"),
13466 "taskList localId missing: {md}"
13467 );
13468 let rt = markdown_to_adf(&md).unwrap();
13469 assert_eq!(
13470 rt.content[0].attrs.as_ref().unwrap()["localId"],
13471 "tl-xyz",
13472 "taskList localId should survive round-trip"
13473 );
13474 }
13475
13476 #[test]
13478 fn task_item_paragraph_wrapper_roundtrip_no_localid() {
13479 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"}]}]}]}]}"#;
13480 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13481 let md = adf_to_markdown(&doc).unwrap();
13482 assert!(
13483 md.contains("paraLocalId=_"),
13484 "should emit paraLocalId=_ sentinel: {md}"
13485 );
13486 let rt = markdown_to_adf(&md).unwrap();
13487 let item = &rt.content[0].content.as_ref().unwrap()[0];
13488 let content = item.content.as_ref().unwrap();
13489 assert_eq!(content.len(), 1, "should have one child: {content:#?}");
13490 assert_eq!(
13491 content[0].node_type, "paragraph",
13492 "child should be a paragraph: {content:#?}"
13493 );
13494 let para_content = content[0].content.as_ref().unwrap();
13495 assert_eq!(
13496 para_content[0].text.as_deref(),
13497 Some("A task with paragraph wrapper")
13498 );
13499 assert!(
13501 content[0].attrs.is_none(),
13502 "paragraph should have no attrs: {:?}",
13503 content[0].attrs
13504 );
13505 }
13506
13507 #[test]
13509 fn task_item_paragraph_wrapper_roundtrip_with_localid() {
13510 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"}]}]}]}]}"#;
13511 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13512 let md = adf_to_markdown(&doc).unwrap();
13513 assert!(
13514 md.contains("paraLocalId=p-001"),
13515 "should emit paraLocalId=p-001: {md}"
13516 );
13517 let rt = markdown_to_adf(&md).unwrap();
13518 let item = &rt.content[0].content.as_ref().unwrap()[0];
13519 let content = item.content.as_ref().unwrap();
13520 assert_eq!(content[0].node_type, "paragraph");
13521 assert_eq!(
13522 content[0].attrs.as_ref().unwrap()["localId"],
13523 "p-001",
13524 "paragraph localId should be preserved"
13525 );
13526 }
13527
13528 #[test]
13530 fn task_item_unwrapped_inline_no_paragraph_on_roundtrip() {
13531 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"}]}]}]}"#;
13532 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13533 let md = adf_to_markdown(&doc).unwrap();
13534 assert!(
13535 !md.contains("paraLocalId"),
13536 "should NOT emit paraLocalId for unwrapped inline: {md}"
13537 );
13538 let rt = markdown_to_adf(&md).unwrap();
13539 let item = &rt.content[0].content.as_ref().unwrap()[0];
13540 let content = item.content.as_ref().unwrap();
13541 assert_eq!(
13542 content[0].node_type, "text",
13543 "should remain unwrapped: {content:#?}"
13544 );
13545 }
13546
13547 #[test]
13549 fn task_item_done_paragraph_wrapper_roundtrip() {
13550 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"}]}]}]}]}"#;
13551 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13552 let md = adf_to_markdown(&doc).unwrap();
13553 assert!(md.contains("- [x]"), "should render as done: {md}");
13554 let rt = markdown_to_adf(&md).unwrap();
13555 let item = &rt.content[0].content.as_ref().unwrap()[0];
13556 assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
13557 let content = item.content.as_ref().unwrap();
13558 assert_eq!(content[0].node_type, "paragraph");
13559 }
13560
13561 #[test]
13563 fn task_item_mixed_paragraph_and_unwrapped_roundtrip() {
13564 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"}]}]}]}"#;
13565 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13566 let md = adf_to_markdown(&doc).unwrap();
13567 let rt = markdown_to_adf(&md).unwrap();
13568 let items = rt.content[0].content.as_ref().unwrap();
13569 assert_eq!(items.len(), 2);
13570 let c1 = items[0].content.as_ref().unwrap();
13572 assert_eq!(
13573 c1[0].node_type, "paragraph",
13574 "first item should have paragraph wrapper"
13575 );
13576 let c2 = items[1].content.as_ref().unwrap();
13578 assert_eq!(
13579 c2[0].node_type, "text",
13580 "second item should remain unwrapped"
13581 );
13582 }
13583
13584 #[test]
13586 fn task_item_paragraph_wrapper_with_marks_roundtrip() {
13587 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"}]}]}]}]}]}"#;
13588 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13589 let md = adf_to_markdown(&doc).unwrap();
13590 let rt = markdown_to_adf(&md).unwrap();
13591 let item = &rt.content[0].content.as_ref().unwrap()[0];
13592 let content = item.content.as_ref().unwrap();
13593 assert_eq!(content[0].node_type, "paragraph");
13594 let para_children = content[0].content.as_ref().unwrap();
13595 assert!(
13596 para_children.len() >= 2,
13597 "paragraph should contain multiple inline nodes"
13598 );
13599 }
13600
13601 #[test]
13603 fn task_item_paragraph_wrapper_stripped_with_option() {
13604 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"}]}]}]}]}"#;
13605 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13606 let opts = RenderOptions {
13607 strip_local_ids: true,
13608 };
13609 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13610 assert!(
13611 !md.contains("paraLocalId"),
13612 "paraLocalId should be stripped: {md}"
13613 );
13614 assert!(
13615 !md.contains("localId"),
13616 "all localIds should be stripped: {md}"
13617 );
13618 }
13619
13620 #[test]
13621 fn trailing_space_preserved_with_hex_localid() {
13622 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 "}]}]}]}]}"#;
13625 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13626 let md = adf_to_markdown(&doc).unwrap();
13627 let rt = markdown_to_adf(&md).unwrap();
13628 let item = &rt.content[0].content.as_ref().unwrap()[0];
13629 assert_eq!(
13630 item.attrs.as_ref().unwrap()["localId"],
13631 "aabb112233cc",
13632 "localId should round-trip"
13633 );
13634 let para = &item.content.as_ref().unwrap()[0];
13635 let inlines = para.content.as_ref().unwrap();
13636 let last = inlines.last().unwrap();
13637 assert!(
13638 last.text.as_deref().unwrap_or("").ends_with(' '),
13639 "trailing space should be preserved, got nodes: {:?}",
13640 inlines
13641 .iter()
13642 .map(|n| (&n.node_type, &n.text))
13643 .collect::<Vec<_>>()
13644 );
13645 }
13646
13647 #[test]
13648 fn extract_trailing_local_id_preserves_trailing_space() {
13649 let (before, lid, _) = extract_trailing_local_id("trailing space {localId=aabb112233cc}");
13651 assert_eq!(before, "trailing space ");
13652 assert_eq!(lid.as_deref(), Some("aabb112233cc"));
13653 }
13654
13655 #[test]
13656 fn extract_trailing_local_id_no_trailing_space() {
13657 let (before, lid, _) = extract_trailing_local_id("text {localId=abc123}");
13658 assert_eq!(before, "text");
13659 assert_eq!(lid.as_deref(), Some("abc123"));
13660 }
13661
13662 #[test]
13663 fn extract_trailing_local_id_no_attrs() {
13664 let (before, lid, pid) = extract_trailing_local_id("plain text");
13665 assert_eq!(before, "plain text");
13666 assert!(lid.is_none());
13667 assert!(pid.is_none());
13668 }
13669
13670 #[test]
13671 fn list_item_localid_stripped() {
13672 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"}]}]}]}]}"#;
13673 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13674 let opts = RenderOptions {
13675 strip_local_ids: true,
13676 };
13677 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13678 assert!(!md.contains("localId"), "localId should be stripped: {md}");
13679 }
13680
13681 #[test]
13682 fn paragraph_localid_in_list_item_roundtrip() {
13683 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"}]}]}]}]}"#;
13685 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13686 let md = adf_to_markdown(&doc).unwrap();
13687 assert!(
13688 md.contains("paraLocalId=para-001"),
13689 "paragraph localId should be in md: {md}"
13690 );
13691 let rt = markdown_to_adf(&md).unwrap();
13692 let item = &rt.content[0].content.as_ref().unwrap()[0];
13693 assert_eq!(
13694 item.attrs.as_ref().unwrap()["localId"],
13695 "item-001",
13696 "listItem localId should survive"
13697 );
13698 let para = &item.content.as_ref().unwrap()[0];
13699 assert_eq!(
13700 para.attrs.as_ref().unwrap()["localId"],
13701 "para-001",
13702 "paragraph localId should survive round-trip"
13703 );
13704 }
13705
13706 #[test]
13707 fn paragraph_localid_in_ordered_list_item_roundtrip() {
13708 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"}]}]}]}]}"#;
13710 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13711 let md = adf_to_markdown(&doc).unwrap();
13712 assert!(md.contains("paraLocalId=op-001"), "md: {md}");
13713 let rt = markdown_to_adf(&md).unwrap();
13714 let item = &rt.content[0].content.as_ref().unwrap()[0];
13715 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
13716 let para = &item.content.as_ref().unwrap()[0];
13717 assert_eq!(para.attrs.as_ref().unwrap()["localId"], "op-001");
13718 }
13719
13720 #[test]
13721 fn paragraph_localid_only_in_list_item() {
13722 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"}]}]}]}]}"#;
13724 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13725 let md = adf_to_markdown(&doc).unwrap();
13726 assert!(
13727 md.contains("paraLocalId=para-only"),
13728 "paragraph localId should be emitted: {md}"
13729 );
13730 let rt = markdown_to_adf(&md).unwrap();
13731 let item = &rt.content[0].content.as_ref().unwrap()[0];
13732 assert!(item.attrs.is_none(), "listItem should have no attrs");
13733 let para = &item.content.as_ref().unwrap()[0];
13734 assert_eq!(para.attrs.as_ref().unwrap()["localId"], "para-only");
13735 }
13736
13737 #[test]
13738 fn paragraph_localid_in_table_header_roundtrip() {
13739 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"}]}]}]}]}]}"#;
13741 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13742 let md = adf_to_markdown(&doc).unwrap();
13743 assert!(
13745 md.contains("localId=aaaa-aaaa"),
13746 "paragraph localId should be in md: {md}"
13747 );
13748 let rt = markdown_to_adf(&md).unwrap();
13749 let cell = &rt.content[0].content.as_ref().unwrap()[0]
13750 .content
13751 .as_ref()
13752 .unwrap()[0];
13753 let para = &cell.content.as_ref().unwrap()[0];
13754 assert_eq!(
13755 para.attrs.as_ref().unwrap()["localId"],
13756 "aaaa-aaaa",
13757 "paragraph localId should survive round-trip in tableHeader"
13758 );
13759 }
13760
13761 #[test]
13762 fn paragraph_localid_in_table_cell_roundtrip() {
13763 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"}]}]}]}]}]}"#;
13765 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13766 let md = adf_to_markdown(&doc).unwrap();
13767 assert!(
13768 md.contains("localId=cell-para"),
13769 "paragraph localId should be in md: {md}"
13770 );
13771 let rt = markdown_to_adf(&md).unwrap();
13772 let cell = &rt.content[0].content.as_ref().unwrap()[1]
13774 .content
13775 .as_ref()
13776 .unwrap()[0];
13777 let para = &cell.content.as_ref().unwrap()[0];
13778 assert_eq!(
13779 para.attrs.as_ref().unwrap()["localId"],
13780 "cell-para",
13781 "paragraph localId should survive round-trip in tableCell"
13782 );
13783 }
13784
13785 #[test]
13786 fn nbsp_paragraph_with_localid_roundtrip() {
13787 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"nbsp-para"},"content":[{"type":"text","text":"\u00a0"}]}]}"#;
13789 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13790 let md = adf_to_markdown(&doc).unwrap();
13791 assert!(
13792 md.contains("::paragraph["),
13793 "nbsp should use directive form: {md}"
13794 );
13795 assert!(
13796 md.contains("localId=nbsp-para"),
13797 "localId should be in directive: {md}"
13798 );
13799 let rt = markdown_to_adf(&md).unwrap();
13800 let para = &rt.content[0];
13801 assert_eq!(
13802 para.attrs.as_ref().unwrap()["localId"],
13803 "nbsp-para",
13804 "localId should survive round-trip"
13805 );
13806 let text = para.content.as_ref().unwrap()[0].text.as_ref().unwrap();
13807 assert_eq!(text, "\u{00a0}", "nbsp should survive");
13808 }
13809
13810 #[test]
13811 fn empty_paragraph_with_localid_roundtrip() {
13812 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"empty-para"}}]}"#;
13814 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13815 let md = adf_to_markdown(&doc).unwrap();
13816 assert!(
13817 md.contains("::paragraph{localId=empty-para}"),
13818 "empty paragraph should include localId in directive: {md}"
13819 );
13820 let rt = markdown_to_adf(&md).unwrap();
13821 assert_eq!(
13822 rt.content[0].attrs.as_ref().unwrap()["localId"],
13823 "empty-para"
13824 );
13825 }
13826
13827 #[test]
13828 fn paragraph_localid_stripped_from_list_item() {
13829 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"}]}]}]}]}"#;
13831 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13832 let opts = RenderOptions {
13833 strip_local_ids: true,
13834 };
13835 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13836 assert!(!md.contains("localId"), "localId should be stripped: {md}");
13837 assert!(
13838 !md.contains("paraLocalId"),
13839 "paraLocalId should be stripped: {md}"
13840 );
13841 }
13842
13843 #[test]
13844 fn date_directive() {
13845 let doc = markdown_to_adf("Due by :date[2026-04-15].").unwrap();
13846 let content = doc.content[0].content.as_ref().unwrap();
13847 assert_eq!(content[1].node_type, "date");
13848 assert_eq!(
13850 content[1].attrs.as_ref().unwrap()["timestamp"],
13851 "1776211200000"
13852 );
13853 }
13854
13855 #[test]
13856 fn adf_date_to_markdown() {
13857 let doc = AdfDocument {
13859 version: 1,
13860 doc_type: "doc".to_string(),
13861 content: vec![AdfNode::paragraph(vec![AdfNode::date("1776211200000")])],
13862 };
13863 let md = adf_to_markdown(&doc).unwrap();
13864 assert!(md.contains(":date[2026-04-15]{timestamp=1776211200000}"));
13865 }
13866
13867 #[test]
13868 fn adf_date_iso_passthrough() {
13869 let doc = AdfDocument {
13871 version: 1,
13872 doc_type: "doc".to_string(),
13873 content: vec![AdfNode::paragraph(vec![AdfNode::date("2026-04-15")])],
13874 };
13875 let md = adf_to_markdown(&doc).unwrap();
13876 assert!(md.contains(":date[2026-04-15]{timestamp=2026-04-15}"));
13877 }
13878
13879 #[test]
13880 fn round_trip_date() {
13881 let md = "Due by :date[2026-04-15].\n";
13882 let doc = markdown_to_adf(md).unwrap();
13883 let result = adf_to_markdown(&doc).unwrap();
13884 assert!(result.contains(":date[2026-04-15]"));
13885 }
13886
13887 #[test]
13888 fn round_trip_date_non_midnight_timestamp() {
13889 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000"}}]}]}"#;
13891 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13892 let md = adf_to_markdown(&doc).unwrap();
13893 assert!(
13895 md.contains("timestamp=1700000000000"),
13896 "JFM should preserve original timestamp: {md}"
13897 );
13898 let doc2 = markdown_to_adf(&md).unwrap();
13900 let content = doc2.content[0].content.as_ref().unwrap();
13901 assert_eq!(
13902 content[0].attrs.as_ref().unwrap()["timestamp"],
13903 "1700000000000",
13904 "Round-trip must preserve original non-midnight timestamp"
13905 );
13906 }
13907
13908 #[test]
13909 fn date_epoch_ms_passthrough() {
13910 let doc = markdown_to_adf("Due by :date[1776211200000].").unwrap();
13912 let content = doc.content[0].content.as_ref().unwrap();
13913 assert_eq!(
13914 content[1].attrs.as_ref().unwrap()["timestamp"],
13915 "1776211200000"
13916 );
13917 }
13918
13919 #[test]
13920 fn date_timestamp_attr_preferred_over_content() {
13921 let md = ":date[2023-11-14]{timestamp=1700000000000}\n";
13923 let doc = markdown_to_adf(md).unwrap();
13924 let content = doc.content[0].content.as_ref().unwrap();
13925 assert_eq!(
13926 content[0].attrs.as_ref().unwrap()["timestamp"],
13927 "1700000000000",
13928 "timestamp attr should be used directly"
13929 );
13930 }
13931
13932 #[test]
13933 fn date_without_timestamp_attr_backward_compat() {
13934 let md = ":date[2026-04-15]\n";
13936 let doc = markdown_to_adf(md).unwrap();
13937 let content = doc.content[0].content.as_ref().unwrap();
13938 assert_eq!(
13939 content[0].attrs.as_ref().unwrap()["timestamp"],
13940 "1776211200000",
13941 "Should fall back to computing timestamp from date string"
13942 );
13943 }
13944
13945 #[test]
13946 fn date_with_local_id_and_timestamp() {
13947 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
13949 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13950 let md = adf_to_markdown(&doc).unwrap();
13951 assert!(
13952 md.contains("timestamp=1700000000000"),
13953 "Should contain timestamp: {md}"
13954 );
13955 assert!(md.contains("localId=d-001"), "Should contain localId: {md}");
13956 let doc2 = markdown_to_adf(&md).unwrap();
13958 let content = doc2.content[0].content.as_ref().unwrap();
13959 let attrs = content[0].attrs.as_ref().unwrap();
13960 assert_eq!(attrs["timestamp"], "1700000000000");
13961 assert_eq!(attrs["localId"], "d-001");
13962 }
13963
13964 #[test]
13965 fn mention_directive() {
13966 let doc = markdown_to_adf("Assigned to :mention[Alice]{id=abc123}.").unwrap();
13967 let content = doc.content[0].content.as_ref().unwrap();
13968 assert_eq!(content[1].node_type, "mention");
13969 assert_eq!(content[1].attrs.as_ref().unwrap()["id"], "abc123");
13970 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "Alice");
13971 }
13972
13973 #[test]
13974 fn adf_mention_to_markdown() {
13975 let doc = AdfDocument {
13976 version: 1,
13977 doc_type: "doc".to_string(),
13978 content: vec![AdfNode::paragraph(vec![AdfNode::mention(
13979 "abc123", "Alice",
13980 )])],
13981 };
13982 let md = adf_to_markdown(&doc).unwrap();
13983 assert!(md.contains(":mention[Alice]{id=abc123}"));
13984 }
13985
13986 #[test]
13987 fn round_trip_mention() {
13988 let md = "Assigned to :mention[Alice]{id=abc123}.\n";
13989 let doc = markdown_to_adf(md).unwrap();
13990 let result = adf_to_markdown(&doc).unwrap();
13991 assert!(result.contains(":mention[Alice]{id=abc123}"));
13992 }
13993
13994 #[test]
13995 fn mention_with_empty_access_level_round_trips() {
13996 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13998 {"type":"mention","attrs":{"id":"61921b41c15977006af2b1d1","text":"@Javier Inchausti","accessLevel":""}}
13999 ]}]}"#;
14000 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14001
14002 let md = adf_to_markdown(&doc).unwrap();
14003 let round_tripped = markdown_to_adf(&md).unwrap();
14004 let mention = &round_tripped.content[0].content.as_ref().unwrap()[0];
14005 assert_eq!(
14006 mention.node_type, "mention",
14007 "mention with empty accessLevel was not parsed as mention, got: {}",
14008 mention.node_type
14009 );
14010 }
14011
14012 #[test]
14013 fn span_with_color() {
14014 let doc = markdown_to_adf("This is :span[red text]{color=#ff5630}.").unwrap();
14015 let content = doc.content[0].content.as_ref().unwrap();
14016 assert_eq!(content[1].node_type, "text");
14017 assert_eq!(content[1].text.as_deref(), Some("red text"));
14018 let marks = content[1].marks.as_ref().unwrap();
14019 assert_eq!(marks[0].mark_type, "textColor");
14020 }
14021
14022 #[test]
14023 fn adf_text_color_to_markdown() {
14024 let doc = AdfDocument {
14025 version: 1,
14026 doc_type: "doc".to_string(),
14027 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
14028 "red text",
14029 vec![AdfMark::text_color("#ff5630")],
14030 )])],
14031 };
14032 let md = adf_to_markdown(&doc).unwrap();
14033 assert!(md.contains(":span[red text]{color=#ff5630}"));
14034 }
14035
14036 #[test]
14037 fn round_trip_span_color() {
14038 let md = "This is :span[red text]{color=#ff5630}.\n";
14039 let doc = markdown_to_adf(md).unwrap();
14040 let result = adf_to_markdown(&doc).unwrap();
14041 assert!(result.contains(":span[red text]{color=#ff5630}"));
14042 }
14043
14044 #[test]
14045 fn text_color_and_link_marks_both_preserved() {
14046 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14048 {"type":"text","text":"red link","marks":[
14049 {"type":"link","attrs":{"href":"https://example.com"}},
14050 {"type":"textColor","attrs":{"color":"#ff0000"}}
14051 ]}
14052 ]}]}"##;
14053 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14054 let md = adf_to_markdown(&doc).unwrap();
14055 assert!(
14056 md.contains(":span[red link]{color=#ff0000}"),
14057 "JFM should contain span with color, got: {md}"
14058 );
14059 assert!(
14060 md.contains("](https://example.com)"),
14061 "JFM should contain link href, got: {md}"
14062 );
14063 let rt = markdown_to_adf(&md).unwrap();
14065 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
14066 let marks = text_node.marks.as_ref().expect("should have marks");
14067 assert!(
14068 marks.iter().any(|m| m.mark_type == "textColor"),
14069 "should have textColor mark, got: {:?}",
14070 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
14071 );
14072 assert!(
14073 marks.iter().any(|m| m.mark_type == "link"),
14074 "should have link mark, got: {:?}",
14075 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
14076 );
14077 let link_mark = marks.iter().find(|m| m.mark_type == "link").unwrap();
14079 assert_eq!(
14080 link_mark.attrs.as_ref().unwrap()["href"],
14081 "https://example.com"
14082 );
14083 let color_mark = marks.iter().find(|m| m.mark_type == "textColor").unwrap();
14084 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#ff0000");
14085 }
14086
14087 #[test]
14088 fn bg_color_and_link_marks_both_preserved() {
14089 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14090 {"type":"text","text":"highlighted link","marks":[
14091 {"type":"link","attrs":{"href":"https://example.com"}},
14092 {"type":"backgroundColor","attrs":{"color":"#ffff00"}}
14093 ]}
14094 ]}]}"##;
14095 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14096 let md = adf_to_markdown(&doc).unwrap();
14097 assert!(md.contains("bg=#ffff00"), "should have bg color: {md}");
14098 assert!(
14099 md.contains("](https://example.com)"),
14100 "should have link: {md}"
14101 );
14102 let rt = markdown_to_adf(&md).unwrap();
14103 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
14104 let marks = text_node.marks.as_ref().expect("should have marks");
14105 assert!(marks.iter().any(|m| m.mark_type == "backgroundColor"));
14106 assert!(marks.iter().any(|m| m.mark_type == "link"));
14107 }
14108
14109 #[test]
14110 fn text_color_link_and_strong_rendering() {
14111 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14113 {"type":"text","text":"bold red link","marks":[
14114 {"type":"strong"},
14115 {"type":"link","attrs":{"href":"https://example.com"}},
14116 {"type":"textColor","attrs":{"color":"#ff0000"}}
14117 ]}
14118 ]}]}"##;
14119 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14120 let md = adf_to_markdown(&doc).unwrap();
14121 assert!(
14122 md.starts_with("**") && md.trim().ends_with("**"),
14123 "should have bold wrapping: {md}"
14124 );
14125 assert!(md.contains("color=#ff0000"), "should have color: {md}");
14126 assert!(
14127 md.contains("](https://example.com)"),
14128 "should have link: {md}"
14129 );
14130 }
14131
14132 #[test]
14133 fn subsup_and_link_marks_both_preserved() {
14134 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14135 {"type":"text","text":"note","marks":[
14136 {"type":"link","attrs":{"href":"https://example.com"}},
14137 {"type":"subsup","attrs":{"type":"sup"}}
14138 ]}
14139 ]}]}"#;
14140 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14141 let md = adf_to_markdown(&doc).unwrap();
14142 assert!(md.contains("sup"), "should have sup: {md}");
14143 assert!(
14144 md.contains("](https://example.com)"),
14145 "should have link: {md}"
14146 );
14147 let rt = markdown_to_adf(&md).unwrap();
14148 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
14149 let marks = text_node.marks.as_ref().expect("should have marks");
14150 assert!(marks.iter().any(|m| m.mark_type == "subsup"));
14151 assert!(marks.iter().any(|m| m.mark_type == "link"));
14152 }
14153
14154 #[test]
14155 fn text_color_without_link_unchanged() {
14156 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14158 {"type":"text","text":"just red","marks":[
14159 {"type":"textColor","attrs":{"color":"#ff0000"}}
14160 ]}
14161 ]}]}"##;
14162 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14163 let md = adf_to_markdown(&doc).unwrap();
14164 assert!(md.contains(":span[just red]{color=#ff0000}"), "md: {md}");
14165 assert!(!md.contains("](http"), "should NOT have link syntax: {md}");
14166 }
14167
14168 #[test]
14169 fn inline_extension_directive() {
14170 let doc =
14171 markdown_to_adf("See :extension[fallback]{type=com.app key=widget} here.").unwrap();
14172 let content = doc.content[0].content.as_ref().unwrap();
14173 assert_eq!(content[1].node_type, "inlineExtension");
14174 assert_eq!(
14175 content[1].attrs.as_ref().unwrap()["extensionType"],
14176 "com.app"
14177 );
14178 assert_eq!(content[1].attrs.as_ref().unwrap()["extensionKey"], "widget");
14179 }
14180
14181 #[test]
14182 fn adf_inline_extension_to_markdown() {
14183 let doc = AdfDocument {
14184 version: 1,
14185 doc_type: "doc".to_string(),
14186 content: vec![AdfNode::paragraph(vec![AdfNode::inline_extension(
14187 "com.app",
14188 "widget",
14189 Some("fallback"),
14190 )])],
14191 };
14192 let md = adf_to_markdown(&doc).unwrap();
14193 assert!(md.contains(":extension[fallback]{type=com.app key=widget}"));
14194 }
14195
14196 #[test]
14199 fn parse_ordered_list_marker_valid() {
14200 let result = parse_ordered_list_marker("1. Hello");
14201 assert_eq!(result, Some((1, "Hello")));
14202 }
14203
14204 #[test]
14205 fn parse_ordered_list_marker_high_number() {
14206 let result = parse_ordered_list_marker("42. Item");
14207 assert_eq!(result, Some((42, "Item")));
14208 }
14209
14210 #[test]
14211 fn parse_ordered_list_marker_not_a_list() {
14212 assert!(parse_ordered_list_marker("not a list").is_none());
14213 assert!(parse_ordered_list_marker("1.no space").is_none());
14214 }
14215
14216 #[test]
14217 fn is_list_start_various() {
14218 assert!(is_list_start("- item"));
14219 assert!(is_list_start("* item"));
14220 assert!(is_list_start("+ item"));
14221 assert!(is_list_start("1. item"));
14222 assert!(!is_list_start("not a list"));
14223 }
14224
14225 #[test]
14226 fn is_horizontal_rule_various() {
14227 assert!(is_horizontal_rule("---"));
14228 assert!(is_horizontal_rule("***"));
14229 assert!(is_horizontal_rule("___"));
14230 assert!(is_horizontal_rule("------"));
14231 assert!(!is_horizontal_rule("--"));
14232 assert!(!is_horizontal_rule("abc"));
14233 }
14234
14235 #[test]
14236 fn is_table_separator_valid() {
14237 assert!(is_table_separator("| --- | --- |"));
14238 assert!(is_table_separator("|:---:|:---|"));
14239 assert!(!is_table_separator("no pipes here"));
14240 }
14241
14242 #[test]
14243 fn parse_table_row_cells() {
14244 let cells = parse_table_row("| A | B | C |");
14245 assert_eq!(cells, vec!["A", "B", "C"]);
14246 }
14247
14248 #[test]
14249 fn parse_table_row_escaped_pipe_in_cell() {
14250 let cells = parse_table_row(r"| a\|b | c |");
14252 assert_eq!(cells, vec!["a|b", "c"]);
14253 }
14254
14255 #[test]
14256 fn parse_table_row_escaped_pipe_in_code_span() {
14257 let cells = parse_table_row(r"| `parser.decode[T\|json]` | other |");
14259 assert_eq!(cells, vec!["`parser.decode[T|json]`", "other"]);
14260 }
14261
14262 #[test]
14263 fn parse_table_row_preserves_other_backslashes() {
14264 let cells = parse_table_row(r"| a\\b | c\*d |");
14266 assert_eq!(cells, vec![r"a\\b", r"c\*d"]);
14267 }
14268
14269 #[test]
14270 fn parse_image_syntax_valid() {
14271 let result = parse_image_syntax("");
14272 assert_eq!(result, Some(("alt", "url")));
14273 }
14274
14275 #[test]
14276 fn parse_image_syntax_not_image() {
14277 assert!(parse_image_syntax("not an image").is_none());
14278 }
14279
14280 #[test]
14283 fn find_closing_paren_simple() {
14284 assert_eq!(find_closing_paren("(hello)", 0), Some(6));
14285 }
14286
14287 #[test]
14288 fn find_closing_paren_nested() {
14289 assert_eq!(find_closing_paren("(a(b)c)", 0), Some(6));
14290 }
14291
14292 #[test]
14293 fn find_closing_paren_unmatched() {
14294 assert_eq!(find_closing_paren("(no close", 0), None);
14295 }
14296
14297 #[test]
14298 fn find_closing_paren_offset() {
14299 assert_eq!(find_closing_paren("xx(inner)", 2), Some(8));
14301 }
14302
14303 #[test]
14306 fn try_parse_link_url_with_parens() {
14307 let input = "[here](https://example.com/faq#access-(permissions)-rest)";
14308 let result = try_parse_link(input, 0);
14309 assert_eq!(
14310 result,
14311 Some((
14312 input.len(),
14313 "here",
14314 "https://example.com/faq#access-(permissions)-rest"
14315 ))
14316 );
14317 }
14318
14319 #[test]
14320 fn try_parse_link_url_no_parens() {
14321 let input = "[text](https://example.com)";
14322 let result = try_parse_link(input, 0);
14323 assert_eq!(result, Some((input.len(), "text", "https://example.com")));
14324 }
14325
14326 #[test]
14327 fn try_parse_link_url_with_multiple_nested_parens() {
14328 let input = "[x](http://en.wikipedia.org/wiki/Foo_(bar_(baz)))";
14329 let result = try_parse_link(input, 0);
14330 assert_eq!(
14331 result,
14332 Some((
14333 input.len(),
14334 "x",
14335 "http://en.wikipedia.org/wiki/Foo_(bar_(baz))"
14336 ))
14337 );
14338 }
14339
14340 #[test]
14341 fn parse_image_syntax_url_with_parens() {
14342 let result = parse_image_syntax(")");
14343 assert_eq!(result, Some(("alt", "https://example.com/page_(1)")));
14344 }
14345
14346 #[test]
14347 fn parse_image_syntax_url_no_parens() {
14348 let result = parse_image_syntax("");
14349 assert_eq!(result, Some(("alt", "https://example.com")));
14350 }
14351
14352 #[test]
14353 fn link_with_parens_round_trip() {
14354 let href = "https://example.com/faq#I-need-access-(permissions)-added-in-Monitor";
14355 let mut text_node = AdfNode::text("here");
14356 text_node.marks = Some(vec![AdfMark::link(href)]);
14357 let adf_input = AdfDocument {
14358 version: 1,
14359 doc_type: "doc".to_string(),
14360 content: vec![AdfNode::paragraph(vec![text_node])],
14361 };
14362
14363 let jfm = adf_to_markdown(&adf_input).unwrap();
14364 let adf_output = markdown_to_adf(&jfm).unwrap();
14365
14366 let para = &adf_output.content[0];
14368 let text_node = ¶.content.as_ref().unwrap()[0];
14369 let mark = &text_node.marks.as_ref().unwrap()[0];
14370 let result_href = mark.attrs.as_ref().unwrap()["href"].as_str().unwrap();
14371
14372 assert_eq!(result_href, href);
14373 }
14374
14375 #[test]
14376 fn flush_plain_empty_range() {
14377 let mut nodes = Vec::new();
14378 flush_plain("hello", 3, 3, &mut nodes);
14379 assert!(nodes.is_empty());
14380 }
14381
14382 #[test]
14383 fn add_mark_to_unmarked_node() {
14384 let mut node = AdfNode::text("test");
14385 add_mark(&mut node, AdfMark::strong());
14386 assert_eq!(node.marks.as_ref().unwrap().len(), 1);
14387 }
14388
14389 #[test]
14390 fn add_mark_to_marked_node() {
14391 let mut node = AdfNode::text_with_marks("test", vec![AdfMark::strong()]);
14392 add_mark(&mut node, AdfMark::em());
14393 assert_eq!(node.marks.as_ref().unwrap().len(), 2);
14394 }
14395
14396 #[test]
14399 fn directive_table_basic() {
14400 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";
14401 let doc = markdown_to_adf(md).unwrap();
14402 assert_eq!(doc.content[0].node_type, "table");
14403 let rows = doc.content[0].content.as_ref().unwrap();
14404 assert_eq!(rows.len(), 2);
14405 assert_eq!(
14406 rows[0].content.as_ref().unwrap()[0].node_type,
14407 "tableHeader"
14408 );
14409 assert_eq!(rows[1].content.as_ref().unwrap()[0].node_type, "tableCell");
14410 }
14411
14412 #[test]
14413 fn directive_table_with_block_content() {
14414 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";
14415 let doc = markdown_to_adf(md).unwrap();
14416 let rows = doc.content[0].content.as_ref().unwrap();
14417 let cell = &rows[0].content.as_ref().unwrap()[0];
14418 let content = cell.content.as_ref().unwrap();
14420 assert!(content.len() >= 2);
14421 assert_eq!(content[1].node_type, "bulletList");
14422 }
14423
14424 #[test]
14425 fn directive_table_with_cell_attrs() {
14426 let md = "::::table\n:::tr\n:::td{colspan=2 bg=#DEEBFF}\nSpanning cell\n:::\n:::\n::::\n";
14427 let doc = markdown_to_adf(md).unwrap();
14428 let cell = &doc.content[0].content.as_ref().unwrap()[0]
14429 .content
14430 .as_ref()
14431 .unwrap()[0];
14432 let attrs = cell.attrs.as_ref().unwrap();
14433 assert_eq!(attrs["colspan"], 2);
14434 assert_eq!(attrs["background"], "#DEEBFF");
14435 }
14436
14437 #[test]
14438 fn directive_table_with_css_var_background() {
14439 let bg = "var(--ds-background-accent-gray-subtlest, var(--ds-background-accent-gray-subtlest, #F1F2F4))";
14440 let md = format!("::::table\n:::tr\n:::th{{bg=\"{bg}\"}}\nHeader\n:::\n:::\n::::\n");
14441 let doc = markdown_to_adf(&md).unwrap();
14442 let row = &doc.content[0].content.as_ref().unwrap()[0];
14443 let cells = row.content.as_ref().unwrap();
14444 assert_eq!(cells.len(), 1, "row must have at least one cell");
14445 let attrs = cells[0].attrs.as_ref().unwrap();
14446 assert_eq!(attrs["background"], bg);
14447 }
14448
14449 #[test]
14450 fn css_var_background_round_trips() {
14451 let bg = "var(--ds-background-accent-gray-subtlest, #F1F2F4)";
14452 let adf = AdfDocument {
14453 version: 1,
14454 doc_type: "doc".to_string(),
14455 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14456 AdfNode::table_header_with_attrs(
14457 vec![AdfNode::paragraph(vec![AdfNode::text("Header")])],
14458 serde_json::json!({"background": bg}),
14459 ),
14460 ])])],
14461 };
14462 let md = adf_to_markdown(&adf).unwrap();
14463 assert!(
14464 md.contains(&format!("bg=\"{bg}\"")),
14465 "bg value must be quoted in markdown: {md}"
14466 );
14467
14468 let round_tripped = markdown_to_adf(&md).unwrap();
14469 let row = &round_tripped.content[0].content.as_ref().unwrap()[0];
14470 let cells = row.content.as_ref().unwrap();
14471 assert_eq!(cells.len(), 1, "round-tripped row must have one cell");
14472 let rt_attrs = cells[0].attrs.as_ref().unwrap();
14473 assert_eq!(rt_attrs["background"], bg);
14474 }
14475
14476 #[test]
14477 fn directive_table_with_table_attrs() {
14478 let md = "::::table{layout=wide numbered}\n:::tr\n:::td\nCell\n:::\n:::\n::::\n";
14479 let doc = markdown_to_adf(md).unwrap();
14480 let attrs = doc.content[0].attrs.as_ref().unwrap();
14481 assert_eq!(attrs["layout"], "wide");
14482 assert_eq!(attrs["isNumberColumnEnabled"], true);
14483 }
14484
14485 #[test]
14486 fn adf_table_with_block_content_renders_directive_form() {
14487 let doc = AdfDocument {
14489 version: 1,
14490 doc_type: "doc".to_string(),
14491 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14492 AdfNode::table_cell(vec![
14493 AdfNode::paragraph(vec![AdfNode::text("Cell with list:")]),
14494 AdfNode::bullet_list(vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
14495 AdfNode::text("Item 1"),
14496 ])])]),
14497 ]),
14498 ])])],
14499 };
14500 let md = adf_to_markdown(&doc).unwrap();
14501 assert!(md.contains("::::table"));
14502 assert!(md.contains(":::td"));
14503 assert!(md.contains("- Item 1"));
14504 }
14505
14506 #[test]
14507 fn adf_table_inline_only_renders_pipe_form() {
14508 let doc = AdfDocument {
14510 version: 1,
14511 doc_type: "doc".to_string(),
14512 content: vec![AdfNode::table(vec![
14513 AdfNode::table_row(vec![
14514 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
14515 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14516 ]),
14517 AdfNode::table_row(vec![
14518 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
14519 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14520 ]),
14521 ])],
14522 };
14523 let md = adf_to_markdown(&doc).unwrap();
14524 assert!(md.contains("| H1 | H2 |"));
14525 assert!(!md.contains("::::table"));
14526 }
14527
14528 #[test]
14529 fn adf_table_header_outside_first_row_renders_directive() {
14530 let doc = AdfDocument {
14531 version: 1,
14532 doc_type: "doc".to_string(),
14533 content: vec![AdfNode::table(vec![
14534 AdfNode::table_row(vec![
14535 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H")])]),
14536 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C")])]),
14537 ]),
14538 AdfNode::table_row(vec![
14539 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14540 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14541 ]),
14542 ])],
14543 };
14544 let md = adf_to_markdown(&doc).unwrap();
14545 assert!(md.contains("::::table"));
14546 assert!(md.contains(":::th"));
14547 }
14548
14549 #[test]
14550 fn adf_table_cell_attrs_rendered() {
14551 let doc = AdfDocument {
14552 version: 1,
14553 doc_type: "doc".to_string(),
14554 content: vec![AdfNode::table(vec![
14555 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
14556 AdfNode::text("H"),
14557 ])])]),
14558 AdfNode::table_row(vec![AdfNode::table_cell_with_attrs(
14559 vec![AdfNode::paragraph(vec![AdfNode::text("C")])],
14560 serde_json::json!({"background": "#DEEBFF", "colspan": 2}),
14561 )]),
14562 ])],
14563 };
14564 let md = adf_to_markdown(&doc).unwrap();
14565 assert!(md.contains("{colspan=2 bg=#DEEBFF}"));
14566 }
14567
14568 #[test]
14571 fn pipe_table_cell_attrs() {
14572 let md = "| H1 | H2 |\n|---|---|\n| {bg=#DEEBFF} highlighted | normal |\n";
14573 let doc = markdown_to_adf(md).unwrap();
14574 let rows = doc.content[0].content.as_ref().unwrap();
14575 let cell = &rows[1].content.as_ref().unwrap()[0];
14576 let attrs = cell.attrs.as_ref().unwrap();
14577 assert_eq!(attrs["background"], "#DEEBFF");
14578 }
14579
14580 #[test]
14581 fn pipe_table_cell_colspan() {
14582 let md = "| H1 | H2 |\n|---|---|\n| {colspan=2} spanning |\n";
14583 let doc = markdown_to_adf(md).unwrap();
14584 let rows = doc.content[0].content.as_ref().unwrap();
14585 let cell = &rows[1].content.as_ref().unwrap()[0];
14586 let attrs = cell.attrs.as_ref().unwrap();
14587 assert_eq!(attrs["colspan"], 2);
14588 }
14589
14590 #[test]
14591 fn trailing_space_after_mention_in_table_cell_preserved() {
14592 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":[
14594 {"type":"mention","attrs":{"id":"aaa","text":"@Rob"}},
14595 {"type":"text","text":" "}
14596 ]}]}]}]}]}"#;
14597 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14598 let md = adf_to_markdown(&doc).unwrap();
14599 let round_tripped = markdown_to_adf(&md).unwrap();
14600 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14601 .content
14602 .as_ref()
14603 .unwrap()[0];
14604 let para = &cell.content.as_ref().unwrap()[0];
14605 let inlines = para.content.as_ref().unwrap();
14606 assert!(
14607 inlines.len() >= 2,
14608 "expected mention + text(' ') nodes, got {} nodes: {:?}",
14609 inlines.len(),
14610 inlines.iter().map(|n| &n.node_type).collect::<Vec<_>>()
14611 );
14612 assert_eq!(inlines[0].node_type, "mention");
14613 assert_eq!(inlines[1].node_type, "text");
14614 assert_eq!(inlines[1].text.as_deref(), Some(" "));
14615 }
14616
14617 #[test]
14620 fn pipe_table_column_alignment() {
14621 let md = "| Left | Center | Right |\n|:---|:---:|---:|\n| L | C | R |\n";
14622 let doc = markdown_to_adf(md).unwrap();
14623 let rows = doc.content[0].content.as_ref().unwrap();
14624 let h_cells = rows[0].content.as_ref().unwrap();
14626 assert!(h_cells[0].content.as_ref().unwrap()[0].marks.is_none());
14628 let center_marks = h_cells[1].content.as_ref().unwrap()[0]
14630 .marks
14631 .as_ref()
14632 .unwrap();
14633 assert_eq!(center_marks[0].attrs.as_ref().unwrap()["align"], "center");
14634 let right_marks = h_cells[2].content.as_ref().unwrap()[0]
14636 .marks
14637 .as_ref()
14638 .unwrap();
14639 assert_eq!(right_marks[0].attrs.as_ref().unwrap()["align"], "end");
14640 }
14641
14642 #[test]
14643 fn adf_table_alignment_roundtrip() {
14644 let doc = AdfDocument {
14645 version: 1,
14646 doc_type: "doc".to_string(),
14647 content: vec![AdfNode::table(vec![
14648 AdfNode::table_row(vec![
14649 AdfNode::table_header(vec![{
14650 let mut p = AdfNode::paragraph(vec![AdfNode::text("Center")]);
14651 p.marks = Some(vec![AdfMark::alignment("center")]);
14652 p
14653 }]),
14654 AdfNode::table_header(vec![{
14655 let mut p = AdfNode::paragraph(vec![AdfNode::text("Right")]);
14656 p.marks = Some(vec![AdfMark::alignment("end")]);
14657 p
14658 }]),
14659 ]),
14660 AdfNode::table_row(vec![
14661 AdfNode::table_cell(vec![{
14662 let mut p = AdfNode::paragraph(vec![AdfNode::text("C")]);
14663 p.marks = Some(vec![AdfMark::alignment("center")]);
14664 p
14665 }]),
14666 AdfNode::table_cell(vec![{
14667 let mut p = AdfNode::paragraph(vec![AdfNode::text("R")]);
14668 p.marks = Some(vec![AdfMark::alignment("end")]);
14669 p
14670 }]),
14671 ]),
14672 ])],
14673 };
14674 let md = adf_to_markdown(&doc).unwrap();
14675 assert!(md.contains(":---:"));
14676 assert!(md.contains("---:"));
14677 }
14678
14679 #[test]
14682 fn panel_custom_attrs_round_trip() {
14683 let md = ":::panel{type=custom icon=\":star:\" color=\"#DEEBFF\"}\nContent\n:::\n";
14684 let doc = markdown_to_adf(md).unwrap();
14685 let panel = &doc.content[0];
14686 let attrs = panel.attrs.as_ref().unwrap();
14687 assert_eq!(attrs["panelType"], "custom");
14688 assert_eq!(attrs["panelIcon"], ":star:");
14689 assert_eq!(attrs["panelColor"], "#DEEBFF");
14690
14691 let result = adf_to_markdown(&doc).unwrap();
14692 assert!(result.contains("type=custom"));
14693 assert!(result.contains("icon="));
14694 assert!(result.contains("color="));
14695 }
14696
14697 #[test]
14700 fn block_card_with_layout() {
14701 let md = "::card[https://example.com]{layout=wide}\n";
14702 let doc = markdown_to_adf(md).unwrap();
14703 let attrs = doc.content[0].attrs.as_ref().unwrap();
14704 assert_eq!(attrs["layout"], "wide");
14705
14706 let result = adf_to_markdown(&doc).unwrap();
14707 assert!(result.contains("::card[https://example.com]{layout=wide}"));
14708 }
14709
14710 #[test]
14713 fn extension_with_params() {
14714 let md = r#"::extension{type=com.atlassian.macro key=jira-chart params='{"jql":"project=PROJ"}'}"#;
14715 let doc = markdown_to_adf(&format!("{md}\n")).unwrap();
14716 let attrs = doc.content[0].attrs.as_ref().unwrap();
14717 assert_eq!(attrs["parameters"]["jql"], "project=PROJ");
14718 }
14719
14720 #[test]
14721 fn leaf_extension_layout_preserved_in_roundtrip() {
14722 let adf_json = r#"{"version":1,"type":"doc","content":[
14724 {"type":"extension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"toc","layout":"default","parameters":{}}}
14725 ]}"#;
14726 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14727 let md = adf_to_markdown(&doc).unwrap();
14728 assert!(
14729 md.contains("layout=default"),
14730 "JFM should contain layout=default, got: {md}"
14731 );
14732 let round_tripped = markdown_to_adf(&md).unwrap();
14733 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14734 assert_eq!(attrs["layout"], "default", "layout should be preserved");
14735 assert_eq!(attrs["extensionKey"], "toc");
14736 }
14737
14738 #[test]
14739 fn bodied_extension_layout_preserved_in_roundtrip() {
14740 let adf_json = r#"{"version":1,"type":"doc","content":[
14742 {"type":"bodiedExtension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"expand","layout":"wide"},
14743 "content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}
14744 ]}"#;
14745 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14746 let md = adf_to_markdown(&doc).unwrap();
14747 assert!(
14748 md.contains("layout=wide"),
14749 "JFM should contain layout=wide, got: {md}"
14750 );
14751 let round_tripped = markdown_to_adf(&md).unwrap();
14752 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14753 assert_eq!(attrs["layout"], "wide", "layout should be preserved");
14754 }
14755
14756 #[test]
14757 fn bodied_extension_parameters_preserved_in_roundtrip() {
14758 let adf_json = r#"{"version":1,"type":"doc","content":[
14760 {"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":{}}},
14761 "content":[{"type":"paragraph","content":[{"type":"text","text":"Content inside bodied extension"}]}]}
14762 ]}"#;
14763 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14764 let md = adf_to_markdown(&doc).unwrap();
14765 assert!(
14766 md.contains("params="),
14767 "JFM should contain params attribute, got: {md}"
14768 );
14769 let round_tripped = markdown_to_adf(&md).unwrap();
14770 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14771 assert_eq!(
14772 attrs["parameters"]["macroMetadata"]["title"], "Page Properties",
14773 "parameters should be preserved in round-trip"
14774 );
14775 assert_eq!(attrs["extensionKey"], "details");
14776 assert_eq!(attrs["layout"], "default");
14777 assert_eq!(attrs["localId"], "aabbccdd-1234");
14778 }
14779
14780 #[test]
14781 fn bodied_extension_malformed_params_ignored() {
14782 let md = ":::extension{type=com.atlassian.macro key=details params='not-valid-json'}\nContent\n:::\n";
14784 let doc = markdown_to_adf(md).unwrap();
14785 let attrs = doc.content[0].attrs.as_ref().unwrap();
14786 assert_eq!(attrs["extensionKey"], "details");
14787 assert!(attrs.get("parameters").is_none());
14789 }
14790
14791 #[test]
14792 fn leaf_extension_localid_preserved_in_roundtrip() {
14793 let adf_json = r#"{"version":1,"type":"doc","content":[
14795 {"type":"extension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"toc","layout":"default","localId":"abc-123"}}
14796 ]}"#;
14797 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14798 let md = adf_to_markdown(&doc).unwrap();
14799 let round_tripped = markdown_to_adf(&md).unwrap();
14800 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14801 assert_eq!(attrs["layout"], "default");
14802 assert_eq!(attrs["localId"], "abc-123");
14803 }
14804
14805 #[test]
14808 fn mention_with_user_type() {
14809 let md = "Hi :mention[Alice]{id=abc123 userType=DEFAULT}.\n";
14810 let doc = markdown_to_adf(md).unwrap();
14811 let mention = &doc.content[0].content.as_ref().unwrap()[1];
14812 assert_eq!(mention.attrs.as_ref().unwrap()["userType"], "DEFAULT");
14813
14814 let result = adf_to_markdown(&doc).unwrap();
14815 assert!(result.contains("userType=DEFAULT"));
14816 }
14817
14818 #[test]
14821 fn directive_table_colwidth() {
14822 let md = "::::table\n:::tr\n:::td{colwidth=100,200}\nCell\n:::\n:::\n::::\n";
14823 let doc = markdown_to_adf(md).unwrap();
14824 let cell = &doc.content[0].content.as_ref().unwrap()[0]
14825 .content
14826 .as_ref()
14827 .unwrap()[0];
14828 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14829 assert_eq!(colwidth, &[serde_json::json!(100), serde_json::json!(200)]);
14830 }
14831
14832 #[test]
14833 fn directive_table_colwidth_float_roundtrip() {
14834 let adf_doc = serde_json::json!({
14837 "type": "doc",
14838 "version": 1,
14839 "content": [{
14840 "type": "table",
14841 "content": [{
14842 "type": "tableRow",
14843 "content": [
14844 {
14845 "type": "tableHeader",
14846 "attrs": { "colwidth": [157.0] },
14847 "content": [{ "type": "paragraph" }]
14848 },
14849 {
14850 "type": "tableHeader",
14851 "attrs": { "colwidth": [863.0] },
14852 "content": [{ "type": "paragraph" }]
14853 }
14854 ]
14855 }]
14856 }]
14857 });
14858 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
14859 let md = adf_to_markdown(&doc).unwrap();
14860 assert!(
14861 md.contains("colwidth=157.0"),
14862 "expected colwidth=157.0 in markdown, got: {md}"
14863 );
14864 assert!(
14865 md.contains("colwidth=863.0"),
14866 "expected colwidth=863.0 in markdown, got: {md}"
14867 );
14868 let doc2 = markdown_to_adf(&md).unwrap();
14870 let row = &doc2.content[0].content.as_ref().unwrap()[0];
14871 let header1 = &row.content.as_ref().unwrap()[0];
14872 let header2 = &row.content.as_ref().unwrap()[1];
14873 assert_eq!(
14874 header1.attrs.as_ref().unwrap()["colwidth"]
14875 .as_array()
14876 .unwrap(),
14877 &[serde_json::json!(157.0)]
14878 );
14879 assert_eq!(
14880 header2.attrs.as_ref().unwrap()["colwidth"]
14881 .as_array()
14882 .unwrap(),
14883 &[serde_json::json!(863.0)]
14884 );
14885 }
14886
14887 #[test]
14888 fn colwidth_float_preserved_in_roundtrip() {
14889 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":[]}]}]}]}]}"#;
14891 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14892 let md = adf_to_markdown(&doc).unwrap();
14893 let round_tripped = markdown_to_adf(&md).unwrap();
14894 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14895 .content
14896 .as_ref()
14897 .unwrap()[0];
14898 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14899 assert_eq!(
14900 colwidth,
14901 &[serde_json::json!(254.0), serde_json::json!(416.0)],
14902 "colwidth should preserve float values"
14903 );
14904 }
14905
14906 #[test]
14907 fn colwidth_integer_preserved_in_roundtrip() {
14908 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"}]}]}]}]}]}"#;
14910 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14911 let md = adf_to_markdown(&doc).unwrap();
14912 assert!(
14913 md.contains("colwidth=150"),
14914 "expected colwidth=150 (no decimal) in markdown, got: {md}"
14915 );
14916 assert!(
14917 !md.contains("colwidth=150.0"),
14918 "colwidth should not have .0 suffix for integers, got: {md}"
14919 );
14920 let round_tripped = markdown_to_adf(&md).unwrap();
14922 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14923 .content
14924 .as_ref()
14925 .unwrap()[0];
14926 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14927 assert_eq!(
14928 colwidth,
14929 &[serde_json::json!(150)],
14930 "colwidth should preserve integer values"
14931 );
14932 let json_output = serde_json::to_string(&round_tripped).unwrap();
14934 assert!(
14935 json_output.contains(r#""colwidth":[150]"#),
14936 "JSON should contain integer colwidth, got: {json_output}"
14937 );
14938 }
14939
14940 #[test]
14941 fn colwidth_mixed_int_and_float_roundtrip() {
14942 let int_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100,200]}}]}]}]}"#;
14945 let float_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100.0,200.0]}}]}]}]}"#;
14946
14947 let int_doc: AdfDocument = serde_json::from_str(int_json).unwrap();
14949 let int_md = adf_to_markdown(&int_doc).unwrap();
14950 assert!(
14951 int_md.contains("colwidth=100,200"),
14952 "integer colwidth in md: {int_md}"
14953 );
14954 let int_rt = markdown_to_adf(&int_md).unwrap();
14955 let int_serial = serde_json::to_string(&int_rt).unwrap();
14956 assert!(
14957 int_serial.contains(r#""colwidth":[100,200]"#),
14958 "integer colwidth in JSON: {int_serial}"
14959 );
14960
14961 let float_doc: AdfDocument = serde_json::from_str(float_json).unwrap();
14963 let float_md = adf_to_markdown(&float_doc).unwrap();
14964 assert!(
14965 float_md.contains("colwidth=100.0,200.0"),
14966 "float colwidth in md: {float_md}"
14967 );
14968 let float_rt = markdown_to_adf(&float_md).unwrap();
14969 let float_serial = serde_json::to_string(&float_rt).unwrap();
14970 assert!(
14971 float_serial.contains(r#""colwidth":[100.0,200.0]"#),
14972 "float colwidth in JSON: {float_serial}"
14973 );
14974 }
14975
14976 #[test]
14977 fn colwidth_fractional_float_preserved() {
14978 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"}]}]}]}]}]}"#;
14980 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14981 let md = adf_to_markdown(&doc).unwrap();
14982 assert!(
14983 md.contains("colwidth=100.5"),
14984 "expected colwidth=100.5 in markdown, got: {md}"
14985 );
14986 }
14987
14988 #[test]
14989 fn colwidth_non_numeric_values_skipped() {
14990 let adf_doc = serde_json::json!({
14992 "type": "doc",
14993 "version": 1,
14994 "content": [{
14995 "type": "table",
14996 "content": [{
14997 "type": "tableRow",
14998 "content": [{
14999 "type": "tableCell",
15000 "attrs": { "colwidth": ["invalid"] },
15001 "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "cell" }] }]
15002 }]
15003 }]
15004 }]
15005 });
15006 let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
15007 let md = adf_to_markdown(&doc).unwrap();
15008 assert!(
15010 !md.contains("colwidth"),
15011 "non-numeric colwidth should be filtered out, got: {md}"
15012 );
15013 }
15014
15015 #[test]
15016 fn default_rowspan_colspan_preserved_in_roundtrip() {
15017 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"}]}]}]}]}]}"#;
15019 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15020 let md = adf_to_markdown(&doc).unwrap();
15021 let round_tripped = markdown_to_adf(&md).unwrap();
15022 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
15023 .content
15024 .as_ref()
15025 .unwrap()[0];
15026 let attrs = cell.attrs.as_ref().unwrap();
15027 assert_eq!(attrs["rowspan"], 1, "rowspan=1 should be preserved");
15028 assert_eq!(attrs["colspan"], 1, "colspan=1 should be preserved");
15029 }
15030
15031 #[test]
15034 fn table_localid_preserved_in_roundtrip() {
15035 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"}]}]}]}]}]}"#;
15037 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15038 let md = adf_to_markdown(&doc).unwrap();
15039 assert!(
15040 md.contains("localId="),
15041 "JFM should contain localId, got: {md}"
15042 );
15043 let round_tripped = markdown_to_adf(&md).unwrap();
15044 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15045 assert_eq!(
15046 attrs["localId"], "7afd4550-e66c-4b12-875f-a91c6c7b62c7",
15047 "localId should be preserved"
15048 );
15049 }
15050
15051 #[test]
15052 fn paragraph_localid_preserved_in_roundtrip() {
15053 let adf_json = r#"{"version":1,"type":"doc","content":[
15055 {"type":"paragraph","attrs":{"localId":"abc-123"},"content":[{"type":"text","text":"hello"}]}
15056 ]}"#;
15057 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15058 let md = adf_to_markdown(&doc).unwrap();
15059 assert!(
15060 md.contains("localId=abc-123"),
15061 "JFM should contain localId, got: {md}"
15062 );
15063 let round_tripped = markdown_to_adf(&md).unwrap();
15064 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15065 assert_eq!(attrs["localId"], "abc-123", "localId should be preserved");
15066 }
15067
15068 #[test]
15069 fn heading_localid_preserved_in_roundtrip() {
15070 let adf_json = r#"{"version":1,"type":"doc","content":[
15071 {"type":"heading","attrs":{"level":2,"localId":"h-456"},"content":[{"type":"text","text":"Title"}]}
15072 ]}"#;
15073 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15074 let md = adf_to_markdown(&doc).unwrap();
15075 let round_tripped = markdown_to_adf(&md).unwrap();
15076 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15077 assert_eq!(attrs["localId"], "h-456");
15078 }
15079
15080 #[test]
15081 fn localid_with_alignment_preserved() {
15082 let adf_json = r#"{"version":1,"type":"doc","content":[
15084 {"type":"paragraph","attrs":{"localId":"p-789"},"marks":[{"type":"alignment","attrs":{"align":"center"}}],
15085 "content":[{"type":"text","text":"centered"}]}
15086 ]}"#;
15087 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15088 let md = adf_to_markdown(&doc).unwrap();
15089 assert!(md.contains("localId=p-789"), "should have localId: {md}");
15090 assert!(md.contains("align=center"), "should have align: {md}");
15091 let round_tripped = markdown_to_adf(&md).unwrap();
15092 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15093 assert_eq!(attrs["localId"], "p-789");
15094 let marks = round_tripped.content[0].marks.as_ref().unwrap();
15095 assert!(marks.iter().any(|m| m.mark_type == "alignment"));
15096 }
15097
15098 #[test]
15099 fn table_layout_default_preserved_in_roundtrip() {
15100 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"}]}]}]}]}]}"#;
15102 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15103 let md = adf_to_markdown(&doc).unwrap();
15104 let round_tripped = markdown_to_adf(&md).unwrap();
15105 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15106 assert_eq!(
15107 attrs["layout"], "default",
15108 "layout='default' should be preserved"
15109 );
15110 }
15111
15112 #[test]
15113 fn table_is_number_column_enabled_false_preserved() {
15114 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"}]}]}]}]}]}"#;
15116 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15117 let md = adf_to_markdown(&doc).unwrap();
15118 let round_tripped = markdown_to_adf(&md).unwrap();
15119 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15120 assert_eq!(
15121 attrs["isNumberColumnEnabled"], false,
15122 "isNumberColumnEnabled=false should be preserved"
15123 );
15124 }
15125
15126 #[test]
15127 fn table_is_number_column_enabled_true_preserved() {
15128 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"}]}]}]}]}]}"#;
15130 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15131 let md = adf_to_markdown(&doc).unwrap();
15132 let round_tripped = markdown_to_adf(&md).unwrap();
15133 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15134 assert_eq!(
15135 attrs["isNumberColumnEnabled"], true,
15136 "isNumberColumnEnabled=true should be preserved"
15137 );
15138 }
15139
15140 #[test]
15141 fn directive_table_is_number_column_enabled_false_preserved() {
15142 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
15145 {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
15146 {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
15147 ]}]}]}]}"#;
15148 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15149 let md = adf_to_markdown(&doc).unwrap();
15150 assert!(md.contains("::::table"), "should use directive table form");
15151 assert!(
15152 md.contains("numbered=false"),
15153 "should contain numbered=false, got: {md}"
15154 );
15155 let round_tripped = markdown_to_adf(&md).unwrap();
15156 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15157 assert_eq!(attrs["isNumberColumnEnabled"], false);
15158 assert_eq!(attrs["layout"], "default");
15159 }
15160
15161 #[test]
15162 fn directive_table_is_number_column_enabled_true_preserved() {
15163 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":true,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
15165 {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
15166 {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
15167 ]}]}]}]}"#;
15168 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15169 let md = adf_to_markdown(&doc).unwrap();
15170 assert!(md.contains("::::table"), "should use directive table form");
15171 assert!(
15172 md.contains("numbered}") || md.contains("numbered "),
15173 "should contain numbered flag, got: {md}"
15174 );
15175 let round_tripped = markdown_to_adf(&md).unwrap();
15176 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15177 assert_eq!(attrs["isNumberColumnEnabled"], true);
15178 }
15179
15180 #[test]
15181 fn trailing_space_in_bullet_list_item_preserved() {
15182 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15184 {"type":"listItem","content":[{"type":"paragraph","content":[
15185 {"type":"text","text":"Before link "},
15186 {"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
15187 {"type":"text","text":" "}
15188 ]}]}
15189 ]}]}"#;
15190 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15191 let md = adf_to_markdown(&doc).unwrap();
15192 let round_tripped = markdown_to_adf(&md).unwrap();
15193 let list = &round_tripped.content[0];
15194 let item = &list.content.as_ref().unwrap()[0];
15195 let para = &item.content.as_ref().unwrap()[0];
15196 let inlines = para.content.as_ref().unwrap();
15197 let last = inlines.last().unwrap();
15198 assert_eq!(
15199 last.text.as_deref(),
15200 Some(" "),
15201 "trailing space text node should be preserved, got nodes: {:?}",
15202 inlines
15203 .iter()
15204 .map(|n| (&n.node_type, &n.text))
15205 .collect::<Vec<_>>()
15206 );
15207 }
15208
15209 #[test]
15210 fn trailing_space_after_mention_in_bullet_list_preserved() {
15211 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15213 {"type":"listItem","content":[{"type":"paragraph","content":[
15214 {"type":"mention","attrs":{"id":"abc","text":"@Alice"}},
15215 {"type":"text","text":" "}
15216 ]}]}
15217 ]}]}"#;
15218 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15219 let md = adf_to_markdown(&doc).unwrap();
15220 let round_tripped = markdown_to_adf(&md).unwrap();
15221 let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
15222 .content
15223 .as_ref()
15224 .unwrap()[0];
15225 let inlines = para.content.as_ref().unwrap();
15226 assert!(
15227 inlines.len() >= 2,
15228 "should have mention + trailing space, got {} nodes",
15229 inlines.len()
15230 );
15231 assert_eq!(inlines.last().unwrap().text.as_deref(), Some(" "));
15232 }
15233
15234 #[test]
15235 fn trailing_space_in_ordered_list_item_preserved() {
15236 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15238 {"type":"listItem","content":[{"type":"paragraph","content":[
15239 {"type":"text","text":"item "},
15240 {"type":"text","text":"link","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
15241 {"type":"text","text":" "}
15242 ]}]}
15243 ]}]}"#;
15244 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15245 let md = adf_to_markdown(&doc).unwrap();
15246 let round_tripped = markdown_to_adf(&md).unwrap();
15247 let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
15248 .content
15249 .as_ref()
15250 .unwrap()[0];
15251 let inlines = para.content.as_ref().unwrap();
15252 let last = inlines.last().unwrap();
15253 assert_eq!(
15254 last.text.as_deref(),
15255 Some(" "),
15256 "trailing space should be preserved in ordered list item"
15257 );
15258 }
15259
15260 #[test]
15261 fn trailing_space_in_heading_text_preserved() {
15262 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[
15264 {"type":"text","text":"Firefighting Engineers "}
15265 ]}]}"#;
15266 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15267 let md = adf_to_markdown(&doc).unwrap();
15268 let round_tripped = markdown_to_adf(&md).unwrap();
15269 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15270 assert_eq!(
15271 inlines[0].text.as_deref(),
15272 Some("Firefighting Engineers "),
15273 "trailing space in heading should be preserved"
15274 );
15275 }
15276
15277 #[test]
15278 fn trailing_space_in_heading_before_bold_preserved() {
15279 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
15281 {"type":"text","text":"Classic "},
15282 {"type":"text","text":"bold","marks":[{"type":"strong"}]}
15283 ]}]}"#;
15284 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15285 let md = adf_to_markdown(&doc).unwrap();
15286 let round_tripped = markdown_to_adf(&md).unwrap();
15287 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15288 assert_eq!(
15289 inlines[0].text.as_deref(),
15290 Some("Classic "),
15291 "trailing space in heading text before bold should be preserved"
15292 );
15293 }
15294
15295 #[test]
15296 fn leading_space_in_heading_text_preserved() {
15297 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":3},"content":[
15299 {"type":"text","text":" #general-channel"}
15300 ]}]}"#;
15301 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15302 let md = adf_to_markdown(&doc).unwrap();
15303 let round_tripped = markdown_to_adf(&md).unwrap();
15304 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15305 assert_eq!(
15306 inlines[0].text.as_deref(),
15307 Some(" #general-channel"),
15308 "leading spaces in heading text should be preserved"
15309 );
15310 }
15311
15312 #[test]
15313 fn leading_space_in_heading_before_bold_preserved() {
15314 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
15316 {"type":"text","text":" indented"},
15317 {"type":"text","text":" bold","marks":[{"type":"strong"}]}
15318 ]}]}"#;
15319 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15320 let md = adf_to_markdown(&doc).unwrap();
15321 let round_tripped = markdown_to_adf(&md).unwrap();
15322 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15323 assert_eq!(
15324 inlines[0].text.as_deref(),
15325 Some(" indented"),
15326 "leading spaces in heading text before bold should be preserved"
15327 );
15328 }
15329
15330 #[test]
15331 fn heading_multiple_leading_spaces_markdown_parse() {
15332 let md = "### \t #general-channel";
15334 let doc = markdown_to_adf(md).unwrap();
15335 let inlines = doc.content[0].content.as_ref().unwrap();
15336 assert_eq!(
15337 inlines[0].text.as_deref(),
15338 Some("\t #general-channel"),
15339 "leading whitespace in heading text should be preserved during JFM parsing"
15340 );
15341 }
15342
15343 #[test]
15344 fn trailing_space_in_paragraph_text_preserved() {
15345 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15347 {"type":"text","text":"word followed by space "},
15348 {"type":"text","text":"next node","marks":[{"type":"strong"}]}
15349 ]}]}"#;
15350 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15351 let md = adf_to_markdown(&doc).unwrap();
15352 let round_tripped = markdown_to_adf(&md).unwrap();
15353 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15354 assert_eq!(
15355 inlines[0].text.as_deref(),
15356 Some("word followed by space "),
15357 "trailing space in paragraph text should be preserved"
15358 );
15359 }
15360
15361 #[test]
15362 fn nested_bullet_list_roundtrip() {
15363 let adf_doc = serde_json::json!({
15365 "type": "doc",
15366 "version": 1,
15367 "content": [{
15368 "type": "bulletList",
15369 "content": [{
15370 "type": "listItem",
15371 "content": [
15372 {
15373 "type": "paragraph",
15374 "content": [{"type": "text", "text": "parent item"}]
15375 },
15376 {
15377 "type": "bulletList",
15378 "content": [
15379 {
15380 "type": "listItem",
15381 "content": [{
15382 "type": "paragraph",
15383 "content": [{"type": "text", "text": "sub item 1"}]
15384 }]
15385 },
15386 {
15387 "type": "listItem",
15388 "content": [{
15389 "type": "paragraph",
15390 "content": [{"type": "text", "text": "sub item 2"}]
15391 }]
15392 }
15393 ]
15394 }
15395 ]
15396 }]
15397 }]
15398 });
15399 let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
15400 let md = adf_to_markdown(&doc).unwrap();
15401 assert!(
15402 md.contains("- parent item\n"),
15403 "expected top-level item in markdown, got: {md}"
15404 );
15405 assert!(
15406 md.contains(" - sub item 1\n"),
15407 "expected indented sub item 1 in markdown, got: {md}"
15408 );
15409 assert!(
15410 md.contains(" - sub item 2\n"),
15411 "expected indented sub item 2 in markdown, got: {md}"
15412 );
15413
15414 let doc2 = markdown_to_adf(&md).unwrap();
15416 let list = &doc2.content[0];
15417 assert_eq!(list.node_type, "bulletList");
15418 let item = &list.content.as_ref().unwrap()[0];
15419 assert_eq!(item.node_type, "listItem");
15420 let item_content = item.content.as_ref().unwrap();
15421 assert_eq!(
15422 item_content.len(),
15423 2,
15424 "listItem should have paragraph + nested list"
15425 );
15426 assert_eq!(item_content[0].node_type, "paragraph");
15427 assert_eq!(item_content[1].node_type, "bulletList");
15428 let sub_items = item_content[1].content.as_ref().unwrap();
15429 assert_eq!(sub_items.len(), 2);
15430 }
15431
15432 #[test]
15433 fn nested_bullet_in_table_cell_roundtrip() {
15434 let md = "::::table\n:::tr\n:::td\n- parent\n - child\n:::\n:::\n::::\n";
15435 let doc = markdown_to_adf(md).unwrap();
15436 let table = &doc.content[0];
15437 let row = &table.content.as_ref().unwrap()[0];
15438 let cell = &row.content.as_ref().unwrap()[0];
15439 let list = &cell.content.as_ref().unwrap()[0];
15440 assert_eq!(list.node_type, "bulletList");
15441 let item = &list.content.as_ref().unwrap()[0];
15442 let item_content = item.content.as_ref().unwrap();
15443 assert_eq!(
15444 item_content.len(),
15445 2,
15446 "listItem should have paragraph + nested list"
15447 );
15448 assert_eq!(item_content[1].node_type, "bulletList");
15449
15450 let md2 = adf_to_markdown(&doc).unwrap();
15452 assert!(
15453 md2.contains(" - child"),
15454 "expected indented child in round-tripped markdown, got: {md2}"
15455 );
15456 }
15457
15458 #[test]
15459 fn nested_ordered_list_roundtrip() {
15460 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15462 {"type":"listItem","content":[
15463 {"type":"paragraph","content":[{"type":"text","text":"Top level"}]},
15464 {"type":"orderedList","attrs":{"order":1},"content":[
15465 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 1"}]}]},
15466 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 2"}]}]}
15467 ]}
15468 ]},
15469 {"type":"listItem","content":[
15470 {"type":"paragraph","content":[{"type":"text","text":"Second top"}]}
15471 ]}
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
15477 let outer = &round_tripped.content[0];
15479 assert_eq!(outer.node_type, "orderedList");
15480 assert_eq!(
15481 outer.attrs.as_ref().unwrap()["order"],
15482 1,
15483 "explicit order=1 must be preserved via trailing {{order=1}} (issue #547)"
15484 );
15485 let outer_items = outer.content.as_ref().unwrap();
15486 assert_eq!(
15487 outer_items.len(),
15488 2,
15489 "outer list should have 2 items, got {}",
15490 outer_items.len()
15491 );
15492
15493 let first_item = &outer_items[0];
15495 let first_content = first_item.content.as_ref().unwrap();
15496 assert_eq!(
15497 first_content.len(),
15498 2,
15499 "first listItem should have paragraph + nested list, got {}",
15500 first_content.len()
15501 );
15502 assert_eq!(first_content[0].node_type, "paragraph");
15503 assert_eq!(first_content[1].node_type, "orderedList");
15504 let nested_items = first_content[1].content.as_ref().unwrap();
15505 assert_eq!(nested_items.len(), 2, "nested list should have 2 items");
15506 }
15507
15508 #[test]
15509 fn nested_ordered_list_markdown_parsing() {
15510 let md = "1. Top level\n 1. Nested 1\n 2. Nested 2\n2. Second top\n";
15512 let doc = markdown_to_adf(md).unwrap();
15513 let outer = &doc.content[0];
15514 assert_eq!(outer.node_type, "orderedList");
15515 let outer_items = outer.content.as_ref().unwrap();
15516 assert_eq!(outer_items.len(), 2, "should have 2 top-level items");
15517
15518 let first_content = outer_items[0].content.as_ref().unwrap();
15519 assert_eq!(
15520 first_content.len(),
15521 2,
15522 "first item should have paragraph + nested list"
15523 );
15524 assert_eq!(first_content[1].node_type, "orderedList");
15525 }
15526
15527 #[test]
15528 fn bullet_list_nested_inside_ordered_list() {
15529 let md = "1. Ordered item\n - Bullet child 1\n - Bullet child 2\n2. Second ordered\n";
15531 let doc = markdown_to_adf(md).unwrap();
15532 let outer = &doc.content[0];
15533 assert_eq!(outer.node_type, "orderedList");
15534 let outer_items = outer.content.as_ref().unwrap();
15535 assert_eq!(outer_items.len(), 2);
15536
15537 let first_content = outer_items[0].content.as_ref().unwrap();
15538 assert_eq!(
15539 first_content.len(),
15540 2,
15541 "first item should have paragraph + nested list"
15542 );
15543 assert_eq!(first_content[1].node_type, "bulletList");
15544 let sub_items = first_content[1].content.as_ref().unwrap();
15545 assert_eq!(sub_items.len(), 2, "nested bullet list should have 2 items");
15546 }
15547
15548 #[test]
15549 fn ordered_list_order_attr_one_is_elided() {
15550 let md = "1. A\n2. B\n";
15554 let doc = markdown_to_adf(md).unwrap();
15555 assert!(
15556 doc.content[0].attrs.is_none(),
15557 "attrs should be elided when order=1"
15558 );
15559
15560 let md2 = adf_to_markdown(&doc).unwrap();
15562 let doc2 = markdown_to_adf(&md2).unwrap();
15563 assert!(
15564 doc2.content[0].attrs.is_none(),
15565 "attrs should remain elided after round-trip"
15566 );
15567 }
15568
15569 #[test]
15570 fn issue_547_ordered_list_no_attrs_roundtrip_byte_identical() {
15571 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"}]}]}]}]}"#;
15574 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15575 let md = adf_to_markdown(&doc).unwrap();
15576 let rt = markdown_to_adf(&md).unwrap();
15577 assert!(
15578 rt.content[0].attrs.is_none(),
15579 "round-tripped orderedList should not have attrs, got: {:?}",
15580 rt.content[0].attrs
15581 );
15582
15583 let rt_json = serde_json::to_string(&rt).unwrap();
15585 assert!(
15586 !rt_json.contains("\"order\""),
15587 "round-tripped JSON should not contain \"order\", got: {rt_json}"
15588 );
15589 }
15590
15591 fn assert_roundtrip_byte_identical(adf_json: &str) {
15597 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15598 let md = adf_to_markdown(&doc).unwrap();
15599 let rt = markdown_to_adf(&md).unwrap();
15600
15601 let canonical_src: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15602 let canonical_rt: serde_json::Value =
15603 serde_json::from_str(&serde_json::to_string(&rt).unwrap()).unwrap();
15604 assert_eq!(
15605 canonical_src, canonical_rt,
15606 "round-trip diverged\n src: {canonical_src}\n rt: {canonical_rt}\n md: {md:?}"
15607 );
15608 }
15609
15610 #[test]
15611 fn issue_547_single_item_no_attrs_roundtrip() {
15612 assert_roundtrip_byte_identical(
15613 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"only"}]}]}]}]}"#,
15614 );
15615 }
15616
15617 #[test]
15618 fn issue_547_many_items_no_attrs_roundtrip() {
15619 assert_roundtrip_byte_identical(
15620 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"}]}]}]}]}"#,
15621 );
15622 }
15623
15624 #[test]
15625 fn issue_547_non_default_order_preserved() {
15626 assert_roundtrip_byte_identical(
15629 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":5},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"fifth"}]}]}]}]}"#,
15630 );
15631 }
15632
15633 #[test]
15634 fn issue_547_nested_ordered_in_ordered_no_attrs_roundtrip() {
15635 assert_roundtrip_byte_identical(
15637 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"}]}]}]}]}]}]}"#,
15638 );
15639 }
15640
15641 #[test]
15642 fn issue_547_ordered_nested_in_bullet_no_attrs_roundtrip() {
15643 assert_roundtrip_byte_identical(
15644 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"}]}]}]}]}]}]}"#,
15645 );
15646 }
15647
15648 #[test]
15649 fn issue_547_bullet_nested_in_ordered_no_attrs_roundtrip() {
15650 assert_roundtrip_byte_identical(
15651 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"}]}]}]}]}]}]}"#,
15652 );
15653 }
15654
15655 #[test]
15656 fn issue_547_ordered_list_between_paragraphs_roundtrip() {
15657 assert_roundtrip_byte_identical(
15658 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"}]}]}"#,
15659 );
15660 }
15661
15662 #[test]
15663 fn issue_547_ordered_list_with_marked_text_roundtrip() {
15664 assert_roundtrip_byte_identical(
15665 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"bold","marks":[{"type":"strong"}]}]}]}]}]}"#,
15666 );
15667 }
15668
15669 #[test]
15670 fn issue_547_ordered_list_with_link_roundtrip() {
15671 assert_roundtrip_byte_identical(
15672 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"}}]}]}]}]}]}"#,
15673 );
15674 }
15675
15676 #[test]
15677 fn issue_547_ordered_list_with_hardbreak_roundtrip() {
15678 assert_roundtrip_byte_identical(
15679 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"}]}]}]}]}"#,
15680 );
15681 }
15682
15683 #[test]
15684 fn issue_547_triple_nested_ordered_roundtrip() {
15685 assert_roundtrip_byte_identical(
15686 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"}]}]}]}]}]}]}]}]}"#,
15687 );
15688 }
15689
15690 #[test]
15691 fn issue_547_ordered_list_heading_rule_mix_roundtrip() {
15692 assert_roundtrip_byte_identical(
15693 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"}]}"#,
15694 );
15695 }
15696
15697 #[test]
15698 fn issue_547_ordered_list_listitem_localid_roundtrip() {
15699 assert_roundtrip_byte_identical(
15701 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]}]}"#,
15702 );
15703 }
15704
15705 #[test]
15706 fn issue_547_explicit_order_one_preserved_roundtrip() {
15707 assert_roundtrip_byte_identical(
15712 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"First item"}]}]}]}]}"#,
15713 );
15714 }
15715
15716 #[test]
15717 fn issue_547_explicit_order_one_nested_preserved_roundtrip() {
15718 assert_roundtrip_byte_identical(
15721 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"}]}]}]}]}]}]}"#,
15722 );
15723 }
15724
15725 #[test]
15726 fn issue_547_mixed_explicit_and_implicit_order_roundtrip() {
15727 assert_roundtrip_byte_identical(
15730 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"}]}]}]}]}"#,
15731 );
15732 }
15733
15734 #[test]
15735 fn issue_547_explicit_order_one_with_listitem_localid_roundtrip() {
15736 assert_roundtrip_byte_identical(
15740 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"}]}]}]}]}"#,
15741 );
15742 }
15743
15744 #[test]
15745 fn issue_547_order_attr_signal_appears_only_for_explicit_one() {
15746 let no_attrs = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
15750 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"}]}]}]}]}"#;
15751 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"}]}]}]}]}"#;
15752
15753 let md_no =
15754 adf_to_markdown(&serde_json::from_str::<AdfDocument>(no_attrs).unwrap()).unwrap();
15755 let md_one =
15756 adf_to_markdown(&serde_json::from_str::<AdfDocument>(explicit_one).unwrap()).unwrap();
15757 let md_five =
15758 adf_to_markdown(&serde_json::from_str::<AdfDocument>(order_five).unwrap()).unwrap();
15759
15760 assert!(
15761 !md_no.contains("{order="),
15762 "no-attrs source must not emit order signal, got: {md_no:?}"
15763 );
15764 assert!(
15765 md_one.contains("{order=1}"),
15766 "explicit order=1 must emit trailing signal, got: {md_one:?}"
15767 );
15768 assert!(
15769 !md_five.contains("{order="),
15770 "order=5 is already encoded by marker; must not emit signal, got: {md_five:?}"
15771 );
15772 }
15773
15774 #[test]
15777 fn file_media_roundtrip() {
15778 let adf_doc = serde_json::json!({
15780 "type": "doc",
15781 "version": 1,
15782 "content": [{
15783 "type": "mediaSingle",
15784 "attrs": {"layout": "center"},
15785 "content": [{
15786 "type": "media",
15787 "attrs": {
15788 "type": "file",
15789 "id": "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d",
15790 "collection": "contentId-8220672100",
15791 "height": 56,
15792 "width": 312,
15793 "alt": "Screenshot.png"
15794 }
15795 }]
15796 }]
15797 });
15798 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15799 let md = adf_to_markdown(&doc).unwrap();
15800 assert!(
15801 md.contains("type=file"),
15802 "expected type=file in markdown, got: {md}"
15803 );
15804 assert!(
15805 md.contains("id=6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d"),
15806 "expected id in markdown, got: {md}"
15807 );
15808 assert!(
15809 md.contains("collection=contentId-8220672100"),
15810 "expected collection in markdown, got: {md}"
15811 );
15812 let doc2 = markdown_to_adf(&md).unwrap();
15814 let ms = &doc2.content[0];
15815 assert_eq!(ms.node_type, "mediaSingle");
15816 let media = &ms.content.as_ref().unwrap()[0];
15817 assert_eq!(media.node_type, "media");
15818 let attrs = media.attrs.as_ref().unwrap();
15819 assert_eq!(attrs["type"], "file");
15820 assert_eq!(attrs["id"], "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d");
15821 assert_eq!(attrs["collection"], "contentId-8220672100");
15822 assert_eq!(attrs["height"], 56);
15823 assert_eq!(attrs["width"], 312);
15824 assert_eq!(attrs["alt"], "Screenshot.png");
15825 }
15826
15827 #[test]
15831 fn file_media_roundtrip_issue_550_reproducer() {
15832 let adf_json = r#"{
15833 "version": 1,
15834 "type": "doc",
15835 "content": [
15836 {
15837 "type": "mediaSingle",
15838 "attrs": {"layout": "center"},
15839 "content": [
15840 {
15841 "type": "media",
15842 "attrs": {
15843 "type": "file",
15844 "id": "abc-123-def-456",
15845 "collection": "my-collection",
15846 "width": 941,
15847 "height": 655
15848 }
15849 }
15850 ]
15851 }
15852 ]
15853 }"#;
15854 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15855 let md = adf_to_markdown(&doc).unwrap();
15856 let rt = markdown_to_adf(&md).unwrap();
15857 let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15858 let actual = serde_json::to_value(&rt).unwrap();
15859 assert_eq!(
15860 actual, expected,
15861 "roundtrip should preserve file media attrs; md was:\n{md}"
15862 );
15863 }
15864
15865 #[test]
15871 fn file_media_roundtrip_id_with_spaces() {
15872 let adf_json = r#"{
15873 "version": 1,
15874 "type": "doc",
15875 "content": [
15876 {
15877 "type": "mediaSingle",
15878 "attrs": {"layout": "center"},
15879 "content": [
15880 {
15881 "type": "media",
15882 "attrs": {
15883 "type": "file",
15884 "id": "abc 123 def 456",
15885 "collection": "my-collection",
15886 "width": 800,
15887 "height": 600
15888 }
15889 }
15890 ]
15891 }
15892 ]
15893 }"#;
15894 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15895 let md = adf_to_markdown(&doc).unwrap();
15896 assert!(
15897 md.contains(r#"id="abc 123 def 456""#),
15898 "id with spaces should be quoted in JFM, got:\n{md}"
15899 );
15900 let rt = markdown_to_adf(&md).unwrap();
15901 let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15902 let actual = serde_json::to_value(&rt).unwrap();
15903 assert_eq!(
15904 actual, expected,
15905 "space-containing id must round-trip; md was:\n{md}"
15906 );
15907 }
15908
15909 #[test]
15911 fn file_media_roundtrip_collection_with_spaces() {
15912 let adf_json = r#"{
15913 "version": 1,
15914 "type": "doc",
15915 "content": [
15916 {
15917 "type": "mediaSingle",
15918 "attrs": {"layout": "center"},
15919 "content": [
15920 {
15921 "type": "media",
15922 "attrs": {
15923 "type": "file",
15924 "id": "abc-123",
15925 "collection": "my collection with spaces"
15926 }
15927 }
15928 ]
15929 }
15930 ]
15931 }"#;
15932 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15933 let md = adf_to_markdown(&doc).unwrap();
15934 let rt = markdown_to_adf(&md).unwrap();
15935 let media = &rt.content[0].content.as_ref().unwrap()[0];
15936 assert_eq!(
15937 media.attrs.as_ref().unwrap()["collection"],
15938 "my collection with spaces"
15939 );
15940 }
15941
15942 #[test]
15944 fn file_media_roundtrip_occurrence_key_with_spaces() {
15945 let adf_json = r#"{
15946 "version": 1,
15947 "type": "doc",
15948 "content": [
15949 {
15950 "type": "mediaSingle",
15951 "attrs": {"layout": "center"},
15952 "content": [
15953 {
15954 "type": "media",
15955 "attrs": {
15956 "type": "file",
15957 "id": "x",
15958 "collection": "y",
15959 "occurrenceKey": "key with spaces"
15960 }
15961 }
15962 ]
15963 }
15964 ]
15965 }"#;
15966 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15967 let md = adf_to_markdown(&doc).unwrap();
15968 let rt = markdown_to_adf(&md).unwrap();
15969 let media = &rt.content[0].content.as_ref().unwrap()[0];
15970 assert_eq!(
15971 media.attrs.as_ref().unwrap()["occurrenceKey"],
15972 "key with spaces"
15973 );
15974 }
15975
15976 #[test]
15978 fn file_media_roundtrip_id_with_quote_char() {
15979 let adf_json = r#"{
15980 "version": 1,
15981 "type": "doc",
15982 "content": [
15983 {
15984 "type": "mediaSingle",
15985 "attrs": {"layout": "center"},
15986 "content": [
15987 {
15988 "type": "media",
15989 "attrs": {
15990 "type": "file",
15991 "id": "a\"b\"c",
15992 "collection": "col"
15993 }
15994 }
15995 ]
15996 }
15997 ]
15998 }"#;
15999 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16000 let md = adf_to_markdown(&doc).unwrap();
16001 let rt = markdown_to_adf(&md).unwrap();
16002 let media = &rt.content[0].content.as_ref().unwrap()[0];
16003 assert_eq!(media.attrs.as_ref().unwrap()["id"], "a\"b\"c");
16004 }
16005
16006 #[test]
16009 fn media_inline_roundtrip_id_with_spaces() {
16010 let adf_json = r#"{
16011 "version": 1,
16012 "type": "doc",
16013 "content": [
16014 {
16015 "type": "paragraph",
16016 "content": [
16017 {"type": "text", "text": "before "},
16018 {
16019 "type": "mediaInline",
16020 "attrs": {
16021 "type": "file",
16022 "id": "a b c",
16023 "collection": "my col"
16024 }
16025 },
16026 {"type": "text", "text": " after"}
16027 ]
16028 }
16029 ]
16030 }"#;
16031 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16032 let md = adf_to_markdown(&doc).unwrap();
16033 let rt = markdown_to_adf(&md).unwrap();
16034 let inline = &rt.content[0].content.as_ref().unwrap()[1];
16035 assert_eq!(inline.node_type, "mediaInline");
16036 let attrs = inline.attrs.as_ref().unwrap();
16037 assert_eq!(attrs["id"], "a b c");
16038 assert_eq!(attrs["collection"], "my col");
16039 }
16040
16041 #[test]
16044 fn file_media_roundtrip_preserves_occurrence_key() {
16045 let adf_json = r#"{
16046 "version": 1,
16047 "type": "doc",
16048 "content": [
16049 {
16050 "type": "mediaSingle",
16051 "attrs": {"layout": "center"},
16052 "content": [
16053 {
16054 "type": "media",
16055 "attrs": {
16056 "type": "file",
16057 "id": "abc-123",
16058 "collection": "my-collection",
16059 "occurrenceKey": "unique-key-xyz",
16060 "width": 200,
16061 "height": 100
16062 }
16063 }
16064 ]
16065 }
16066 ]
16067 }"#;
16068 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16069 let md = adf_to_markdown(&doc).unwrap();
16070 assert!(
16071 md.contains("occurrenceKey=unique-key-xyz"),
16072 "expected occurrenceKey in markdown, got: {md}"
16073 );
16074 let rt = markdown_to_adf(&md).unwrap();
16075 let media = &rt.content[0].content.as_ref().unwrap()[0];
16076 let attrs = media.attrs.as_ref().unwrap();
16077 assert_eq!(attrs["occurrenceKey"], "unique-key-xyz");
16078 assert_eq!(attrs["type"], "file");
16079 assert_eq!(attrs["id"], "abc-123");
16080 assert_eq!(attrs["collection"], "my-collection");
16081 }
16082
16083 #[test]
16086 fn media_single_caption_adf_to_markdown() {
16087 let adf_doc = serde_json::json!({
16088 "type": "doc",
16089 "version": 1,
16090 "content": [{
16091 "type": "mediaSingle",
16092 "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
16093 "content": [
16094 {
16095 "type": "media",
16096 "attrs": {
16097 "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
16098 "type": "file",
16099 "collection": "contentId-123456",
16100 "width": 800,
16101 "height": 600
16102 }
16103 },
16104 {
16105 "type": "caption",
16106 "content": [{"type": "text", "text": "An image caption here"}]
16107 }
16108 ]
16109 }]
16110 });
16111 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16112 let md = adf_to_markdown(&doc).unwrap();
16113 assert!(
16114 md.contains(":::caption"),
16115 "expected :::caption in markdown, got: {md}"
16116 );
16117 assert!(
16118 md.contains("An image caption here"),
16119 "expected caption text in markdown, got: {md}"
16120 );
16121 }
16122
16123 #[test]
16124 fn media_single_caption_markdown_to_adf() {
16125 let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nAn image caption here\n:::\n";
16126 let doc = markdown_to_adf(md).unwrap();
16127 let ms = &doc.content[0];
16128 assert_eq!(ms.node_type, "mediaSingle");
16129 let content = ms.content.as_ref().unwrap();
16130 assert_eq!(content.len(), 2, "expected media + caption children");
16131 assert_eq!(content[0].node_type, "media");
16132 assert_eq!(content[1].node_type, "caption");
16133 let caption_content = content[1].content.as_ref().unwrap();
16134 assert_eq!(
16135 caption_content[0].text.as_deref(),
16136 Some("An image caption here")
16137 );
16138 }
16139
16140 #[test]
16141 fn media_single_caption_round_trip() {
16142 let adf_doc = serde_json::json!({
16144 "type": "doc",
16145 "version": 1,
16146 "content": [{
16147 "type": "mediaSingle",
16148 "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
16149 "content": [
16150 {
16151 "type": "media",
16152 "attrs": {
16153 "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
16154 "type": "file",
16155 "collection": "contentId-123456",
16156 "width": 800,
16157 "height": 600
16158 }
16159 },
16160 {
16161 "type": "caption",
16162 "content": [{"type": "text", "text": "An image caption here"}]
16163 }
16164 ]
16165 }]
16166 });
16167 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16168 let md = adf_to_markdown(&doc).unwrap();
16169 let doc2 = markdown_to_adf(&md).unwrap();
16170 let ms = &doc2.content[0];
16171 assert_eq!(ms.node_type, "mediaSingle");
16172 let content = ms.content.as_ref().unwrap();
16173 assert_eq!(
16174 content.len(),
16175 2,
16176 "expected media + caption after round-trip"
16177 );
16178 assert_eq!(content[1].node_type, "caption");
16179 let caption_content = content[1].content.as_ref().unwrap();
16180 assert_eq!(
16181 caption_content[0].text.as_deref(),
16182 Some("An image caption here")
16183 );
16184 }
16185
16186 #[test]
16187 fn media_single_caption_with_inline_marks() {
16188 let adf_doc = serde_json::json!({
16189 "type": "doc",
16190 "version": 1,
16191 "content": [{
16192 "type": "mediaSingle",
16193 "attrs": {"layout": "center"},
16194 "content": [
16195 {
16196 "type": "media",
16197 "attrs": {"type": "external", "url": "https://example.com/img.png"}
16198 },
16199 {
16200 "type": "caption",
16201 "content": [
16202 {"type": "text", "text": "A "},
16203 {"type": "text", "text": "bold", "marks": [{"type": "strong"}]},
16204 {"type": "text", "text": " caption"}
16205 ]
16206 }
16207 ]
16208 }]
16209 });
16210 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16211 let md = adf_to_markdown(&doc).unwrap();
16212 assert!(
16213 md.contains("**bold**"),
16214 "expected bold in caption, got: {md}"
16215 );
16216
16217 let doc2 = markdown_to_adf(&md).unwrap();
16218 let content = doc2.content[0].content.as_ref().unwrap();
16219 assert_eq!(content.len(), 2, "expected media + caption");
16220 assert_eq!(content[1].node_type, "caption");
16221 let caption_inlines = content[1].content.as_ref().unwrap();
16222 let bold_node = caption_inlines
16223 .iter()
16224 .find(|n| n.text.as_deref() == Some("bold"))
16225 .unwrap();
16226 let marks = bold_node.marks.as_ref().unwrap();
16227 assert_eq!(marks[0].mark_type, "strong");
16228 }
16229
16230 #[test]
16231 fn media_single_no_caption_unaffected() {
16232 let adf_doc = serde_json::json!({
16234 "type": "doc",
16235 "version": 1,
16236 "content": [{
16237 "type": "mediaSingle",
16238 "attrs": {"layout": "center"},
16239 "content": [{
16240 "type": "media",
16241 "attrs": {"type": "external", "url": "https://example.com/img.png"}
16242 }]
16243 }]
16244 });
16245 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16246 let md = adf_to_markdown(&doc).unwrap();
16247 assert!(
16248 !md.contains(":::caption"),
16249 "should not emit caption when none present"
16250 );
16251 let doc2 = markdown_to_adf(&md).unwrap();
16252 let content = doc2.content[0].content.as_ref().unwrap();
16253 assert_eq!(content.len(), 1, "should only have media child");
16254 assert_eq!(content[0].node_type, "media");
16255 }
16256
16257 #[test]
16258 fn media_single_empty_caption_round_trip() {
16259 let adf_doc = serde_json::json!({
16261 "type": "doc",
16262 "version": 1,
16263 "content": [{
16264 "type": "mediaSingle",
16265 "attrs": {"layout": "center"},
16266 "content": [
16267 {
16268 "type": "media",
16269 "attrs": {"type": "external", "url": "https://example.com/img.png"}
16270 },
16271 {
16272 "type": "caption"
16273 }
16274 ]
16275 }]
16276 });
16277 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16278 let md = adf_to_markdown(&doc).unwrap();
16279 assert!(
16280 md.contains(":::caption"),
16281 "expected :::caption even for empty caption, got: {md}"
16282 );
16283 assert!(
16284 md.contains(":::\n"),
16285 "expected closing ::: fence, got: {md}"
16286 );
16287 }
16288
16289 #[test]
16290 fn media_single_external_caption_round_trip() {
16291 let md = "\n:::caption\nImage description\n:::\n";
16293 let doc = markdown_to_adf(md).unwrap();
16294 let ms = &doc.content[0];
16295 assert_eq!(ms.node_type, "mediaSingle");
16296 let content = ms.content.as_ref().unwrap();
16297 assert_eq!(content.len(), 2);
16298 assert_eq!(content[0].node_type, "media");
16299 assert_eq!(content[1].node_type, "caption");
16300
16301 let md2 = adf_to_markdown(&doc).unwrap();
16302 let doc2 = markdown_to_adf(&md2).unwrap();
16303 let content2 = doc2.content[0].content.as_ref().unwrap();
16304 assert_eq!(content2.len(), 2);
16305 assert_eq!(content2[1].node_type, "caption");
16306 let caption_text = content2[1].content.as_ref().unwrap();
16307 assert_eq!(caption_text[0].text.as_deref(), Some("Image description"));
16308 }
16309
16310 #[test]
16313 fn media_single_caption_localid_roundtrip() {
16314 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"}]}]}]}"#;
16315 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16316 let md = adf_to_markdown(&doc).unwrap();
16317 assert!(
16318 md.contains("localId=9da8c2104471"),
16319 "caption localId should appear in markdown: {md}"
16320 );
16321 let rt = markdown_to_adf(&md).unwrap();
16322 let content = rt.content[0].content.as_ref().unwrap();
16323 let caption = &content[1];
16324 assert_eq!(caption.node_type, "caption");
16325 assert_eq!(
16326 caption.attrs.as_ref().unwrap()["localId"],
16327 "9da8c2104471",
16328 "caption localId should round-trip"
16329 );
16330 }
16331
16332 #[test]
16333 fn media_single_caption_without_localid() {
16334 let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nPlain caption\n:::\n";
16335 let doc = markdown_to_adf(md).unwrap();
16336 let caption = &doc.content[0].content.as_ref().unwrap()[1];
16337 assert_eq!(caption.node_type, "caption");
16338 assert!(
16339 caption.attrs.is_none(),
16340 "caption without localId should not gain attrs"
16341 );
16342 let md2 = adf_to_markdown(&doc).unwrap();
16343 assert!(
16344 !md2.contains("localId"),
16345 "no localId should appear in output: {md2}"
16346 );
16347 }
16348
16349 #[test]
16350 fn media_single_caption_localid_stripped_when_option_set() {
16351 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"}]}]}]}"#;
16352 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16353 let opts = RenderOptions {
16354 strip_local_ids: true,
16355 ..Default::default()
16356 };
16357 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16358 assert!(!md.contains("localId"), "localId should be stripped: {md}");
16359 }
16360
16361 #[test]
16362 fn table_width_roundtrip() {
16363 let adf_doc = serde_json::json!({
16365 "type": "doc",
16366 "version": 1,
16367 "content": [{
16368 "type": "table",
16369 "attrs": {"layout": "default", "width": 760.0},
16370 "content": [{
16371 "type": "tableRow",
16372 "content": [{
16373 "type": "tableHeader",
16374 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16375 }]
16376 }]
16377 }]
16378 });
16379 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16380 let md = adf_to_markdown(&doc).unwrap();
16381 assert!(
16382 md.contains("width=760.0"),
16383 "expected width=760.0 in markdown (float preserved), got: {md}"
16384 );
16385 let doc2 = markdown_to_adf(&md).unwrap();
16387 let table = &doc2.content[0];
16388 assert_eq!(table.node_type, "table");
16389 let table_attrs = table.attrs.as_ref().unwrap();
16390 assert_eq!(table_attrs["width"], 760.0);
16391 assert!(
16392 table_attrs["width"].is_f64(),
16393 "expected float width to be preserved as f64, got: {:?}",
16394 table_attrs["width"]
16395 );
16396 }
16397
16398 #[test]
16399 fn table_integer_width_roundtrip_preserves_integer() {
16400 let adf_doc = serde_json::json!({
16403 "type": "doc",
16404 "version": 1,
16405 "content": [{
16406 "type": "table",
16407 "attrs": {
16408 "isNumberColumnEnabled": false,
16409 "layout": "center",
16410 "localId": "abc-123",
16411 "width": 1420
16412 },
16413 "content": [{
16414 "type": "tableRow",
16415 "content": [{
16416 "type": "tableCell",
16417 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Cell"}]}]
16418 }]
16419 }]
16420 }]
16421 });
16422 let doc: crate::atlassian::adf::AdfDocument =
16423 serde_json::from_value(adf_doc.clone()).unwrap();
16424 let md = adf_to_markdown(&doc).unwrap();
16425 assert!(
16426 md.contains("width=1420"),
16427 "expected width=1420 in markdown, got: {md}"
16428 );
16429 assert!(
16430 !md.contains("width=1420.0"),
16431 "integer width should not be rendered with decimal: {md}"
16432 );
16433
16434 let doc2 = markdown_to_adf(&md).unwrap();
16435 let table = &doc2.content[0];
16436 assert_eq!(table.node_type, "table");
16437 let table_attrs = table.attrs.as_ref().unwrap();
16438 assert_eq!(table_attrs["width"], 1420);
16439 assert!(
16440 table_attrs["width"].is_u64() || table_attrs["width"].is_i64(),
16441 "width should remain an integer, got: {:?}",
16442 table_attrs["width"]
16443 );
16444 assert!(
16445 !table_attrs["width"].is_f64(),
16446 "width should not be a float, got: {:?}",
16447 table_attrs["width"]
16448 );
16449
16450 let roundtripped = serde_json::to_value(&doc2).unwrap();
16452 let orig_width = &adf_doc["content"][0]["attrs"]["width"];
16453 let rt_width = &roundtripped["content"][0]["attrs"]["width"];
16454 assert_eq!(
16455 orig_width, rt_width,
16456 "width value must roundtrip byte-for-byte"
16457 );
16458 }
16459
16460 #[test]
16461 fn table_fractional_width_roundtrip() {
16462 let adf_doc = serde_json::json!({
16464 "type": "doc",
16465 "version": 1,
16466 "content": [{
16467 "type": "table",
16468 "attrs": {"layout": "default", "width": 760.5},
16469 "content": [{
16470 "type": "tableRow",
16471 "content": [{
16472 "type": "tableHeader",
16473 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16474 }]
16475 }]
16476 }]
16477 });
16478 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16479 let md = adf_to_markdown(&doc).unwrap();
16480 assert!(
16481 md.contains("width=760.5"),
16482 "expected width=760.5 in markdown, got: {md}"
16483 );
16484 let doc2 = markdown_to_adf(&md).unwrap();
16485 let table_attrs = doc2.content[0].attrs.as_ref().unwrap();
16486 assert_eq!(table_attrs["width"], 760.5);
16487 assert!(table_attrs["width"].is_f64());
16488 }
16489
16490 #[test]
16491 fn pipe_table_integer_width_roundtrip() {
16492 let md = "| A | B |\n|---|---|\n| 1 | 2 |\n{layout=default width=1420}\n";
16494 let doc = markdown_to_adf(md).unwrap();
16495 let table = &doc.content[0];
16496 assert_eq!(table.node_type, "table");
16497 let attrs = table.attrs.as_ref().unwrap();
16498 assert_eq!(attrs["width"], 1420);
16499 assert!(
16500 attrs["width"].is_u64() || attrs["width"].is_i64(),
16501 "pipe-table width must stay integer, got: {:?}",
16502 attrs["width"]
16503 );
16504 }
16505
16506 #[test]
16507 fn file_media_width_type_roundtrip() {
16508 let adf_doc = serde_json::json!({
16510 "type": "doc",
16511 "version": 1,
16512 "content": [{
16513 "type": "mediaSingle",
16514 "attrs": {"layout": "center", "width": 312, "widthType": "pixel"},
16515 "content": [{
16516 "type": "media",
16517 "attrs": {
16518 "type": "file",
16519 "id": "abc123",
16520 "collection": "contentId-999",
16521 "height": 56,
16522 "width": 312
16523 }
16524 }]
16525 }]
16526 });
16527 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16528 let md = adf_to_markdown(&doc).unwrap();
16529 assert!(
16530 md.contains("widthType=pixel"),
16531 "expected widthType=pixel in markdown, got: {md}"
16532 );
16533 let doc2 = markdown_to_adf(&md).unwrap();
16534 let ms = &doc2.content[0];
16535 let ms_attrs = ms.attrs.as_ref().unwrap();
16536 assert_eq!(ms_attrs["widthType"], "pixel");
16537 assert_eq!(ms_attrs["width"], 312);
16538 }
16539
16540 #[test]
16541 fn file_media_pixel_width_above_100_passes_validation() {
16542 let md = "![alt](){type=file id=11111111-2222-3333-4444-555555555555 collection=contentId-98765 height=904 width=900 mediaWidth=900 widthType=pixel}\n";
16551 let adf = markdown_to_adf(md).unwrap();
16552
16553 let ms_attrs = adf.content[0].attrs.as_ref().unwrap();
16554 assert_eq!(ms_attrs["width"], 900);
16555 assert_eq!(ms_attrs["widthType"], "pixel");
16556
16557 crate::atlassian::adf_validated::validate(&adf).unwrap();
16558 }
16559
16560 #[test]
16561 fn file_media_mode_roundtrip() {
16562 let adf_doc = serde_json::json!({
16564 "type": "doc",
16565 "version": 1,
16566 "content": [{
16567 "type": "mediaSingle",
16568 "attrs": {"layout": "wide", "mode": "wide", "width": 1200},
16569 "content": [{
16570 "type": "media",
16571 "attrs": {
16572 "type": "file",
16573 "id": "abc123",
16574 "collection": "test",
16575 "width": 1200,
16576 "height": 600
16577 }
16578 }]
16579 }]
16580 });
16581 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16582 let md = adf_to_markdown(&doc).unwrap();
16583 assert!(
16584 md.contains("mode=wide"),
16585 "expected mode=wide in markdown, got: {md}"
16586 );
16587 let doc2 = markdown_to_adf(&md).unwrap();
16588 let ms = &doc2.content[0];
16589 let ms_attrs = ms.attrs.as_ref().unwrap();
16590 assert_eq!(ms_attrs["mode"], "wide");
16591 assert_eq!(ms_attrs["layout"], "wide");
16592 assert_eq!(ms_attrs["width"], 1200);
16593 }
16594
16595 #[test]
16596 fn external_media_mode_roundtrip() {
16597 let adf_doc = serde_json::json!({
16599 "type": "doc",
16600 "version": 1,
16601 "content": [{
16602 "type": "mediaSingle",
16603 "attrs": {"layout": "wide", "mode": "wide"},
16604 "content": [{
16605 "type": "media",
16606 "attrs": {
16607 "type": "external",
16608 "url": "https://example.com/image.png"
16609 }
16610 }]
16611 }]
16612 });
16613 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16614 let md = adf_to_markdown(&doc).unwrap();
16615 assert!(
16616 md.contains("mode=wide"),
16617 "expected mode=wide in markdown, got: {md}"
16618 );
16619 let doc2 = markdown_to_adf(&md).unwrap();
16620 let ms = &doc2.content[0];
16621 let ms_attrs = ms.attrs.as_ref().unwrap();
16622 assert_eq!(ms_attrs["mode"], "wide");
16623 assert_eq!(ms_attrs["layout"], "wide");
16624 }
16625
16626 #[test]
16627 fn media_mode_only_roundtrip() {
16628 let adf_doc = serde_json::json!({
16630 "type": "doc",
16631 "version": 1,
16632 "content": [{
16633 "type": "mediaSingle",
16634 "attrs": {"layout": "center", "mode": "default"},
16635 "content": [{
16636 "type": "media",
16637 "attrs": {
16638 "type": "external",
16639 "url": "https://example.com/image.png"
16640 }
16641 }]
16642 }]
16643 });
16644 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16645 let md = adf_to_markdown(&doc).unwrap();
16646 assert!(
16647 md.contains("mode=default"),
16648 "expected mode=default in markdown, got: {md}"
16649 );
16650 let doc2 = markdown_to_adf(&md).unwrap();
16651 let ms = &doc2.content[0];
16652 let ms_attrs = ms.attrs.as_ref().unwrap();
16653 assert_eq!(ms_attrs["mode"], "default");
16654 }
16655
16656 #[test]
16657 fn file_media_hex_localid_roundtrip() {
16658 let adf_doc = serde_json::json!({
16660 "type": "doc",
16661 "version": 1,
16662 "content": [{
16663 "type": "mediaSingle",
16664 "attrs": {"layout": "wide", "width": 1200, "widthType": "pixel"},
16665 "content": [{
16666 "type": "media",
16667 "attrs": {
16668 "type": "file",
16669 "id": "eb7a9c3b-314e-4458-8200-4b22b67b122e",
16670 "collection": "contentId-123",
16671 "height": 484,
16672 "width": 915,
16673 "alt": "image.png",
16674 "localId": "0e79f58ac382"
16675 }
16676 }]
16677 }]
16678 });
16679 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16680 let md = adf_to_markdown(&doc).unwrap();
16681 assert!(
16682 md.contains("localId=0e79f58ac382"),
16683 "expected localId=0e79f58ac382 in markdown, got: {md}"
16684 );
16685 let doc2 = markdown_to_adf(&md).unwrap();
16686 let ms = &doc2.content[0];
16687 let media = &ms.content.as_ref().unwrap()[0];
16688 let attrs = media.attrs.as_ref().unwrap();
16689 assert_eq!(attrs["localId"], "0e79f58ac382");
16690 }
16691
16692 #[test]
16693 fn file_media_uuid_localid_roundtrip() {
16694 let adf_doc = serde_json::json!({
16696 "type": "doc",
16697 "version": 1,
16698 "content": [{
16699 "type": "mediaSingle",
16700 "attrs": {"layout": "center"},
16701 "content": [{
16702 "type": "media",
16703 "attrs": {
16704 "type": "file",
16705 "id": "abc-123",
16706 "collection": "contentId-456",
16707 "height": 100,
16708 "width": 200,
16709 "localId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
16710 }
16711 }]
16712 }]
16713 });
16714 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16715 let md = adf_to_markdown(&doc).unwrap();
16716 assert!(
16717 md.contains("localId=a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
16718 "expected UUID localId in markdown, got: {md}"
16719 );
16720 let doc2 = markdown_to_adf(&md).unwrap();
16721 let media = &doc2.content[0].content.as_ref().unwrap()[0];
16722 let attrs = media.attrs.as_ref().unwrap();
16723 assert_eq!(attrs["localId"], "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
16724 }
16725
16726 #[test]
16727 fn file_media_null_uuid_localid_stripped() {
16728 let adf_doc = serde_json::json!({
16730 "type": "doc",
16731 "version": 1,
16732 "content": [{
16733 "type": "mediaSingle",
16734 "attrs": {"layout": "center"},
16735 "content": [{
16736 "type": "media",
16737 "attrs": {
16738 "type": "file",
16739 "id": "abc-123",
16740 "collection": "contentId-456",
16741 "height": 100,
16742 "width": 200,
16743 "localId": "00000000-0000-0000-0000-000000000000"
16744 }
16745 }]
16746 }]
16747 });
16748 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16749 let md = adf_to_markdown(&doc).unwrap();
16750 assert!(
16751 !md.contains("localId="),
16752 "null UUID localId should be stripped, got: {md}"
16753 );
16754 }
16755
16756 #[test]
16757 fn file_media_localid_stripped_when_option_set() {
16758 let adf_doc = serde_json::json!({
16760 "type": "doc",
16761 "version": 1,
16762 "content": [{
16763 "type": "mediaSingle",
16764 "attrs": {"layout": "center"},
16765 "content": [{
16766 "type": "media",
16767 "attrs": {
16768 "type": "file",
16769 "id": "abc-123",
16770 "collection": "contentId-456",
16771 "height": 100,
16772 "width": 200,
16773 "localId": "0e79f58ac382"
16774 }
16775 }]
16776 }]
16777 });
16778 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16779 let opts = RenderOptions {
16780 strip_local_ids: true,
16781 ..Default::default()
16782 };
16783 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16784 assert!(
16785 !md.contains("localId="),
16786 "localId should be stripped with strip_local_ids, got: {md}"
16787 );
16788 }
16789
16790 #[test]
16791 fn external_media_localid_roundtrip() {
16792 let adf_doc = serde_json::json!({
16794 "type": "doc",
16795 "version": 1,
16796 "content": [{
16797 "type": "mediaSingle",
16798 "attrs": {"layout": "center"},
16799 "content": [{
16800 "type": "media",
16801 "attrs": {
16802 "type": "external",
16803 "url": "https://example.com/image.png",
16804 "alt": "test",
16805 "localId": "deadbeef1234"
16806 }
16807 }]
16808 }]
16809 });
16810 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16811 let md = adf_to_markdown(&doc).unwrap();
16812 assert!(
16813 md.contains("localId=deadbeef1234"),
16814 "expected localId in markdown for external media, got: {md}"
16815 );
16816 let doc2 = markdown_to_adf(&md).unwrap();
16817 let media = &doc2.content[0].content.as_ref().unwrap()[0];
16818 let attrs = media.attrs.as_ref().unwrap();
16819 assert_eq!(attrs["localId"], "deadbeef1234");
16820 }
16821
16822 #[test]
16823 fn bracket_in_text_not_parsed_as_link() {
16824 let md = ":check_mark: [Task] Unable to start trial ([Link](https://example.com/link))";
16826 let doc = markdown_to_adf(md).unwrap();
16827 let para = &doc.content[0];
16828 assert_eq!(para.node_type, "paragraph");
16829 let content = para.content.as_ref().unwrap();
16830 let text_nodes: Vec<_> = content.iter().filter(|n| n.node_type == "text").collect();
16832 let has_task_bracket = text_nodes
16833 .iter()
16834 .any(|n| n.text.as_deref().unwrap_or("").contains("[Task]"));
16835 assert!(
16836 has_task_bracket,
16837 "expected [Task] in plain text, nodes: {content:?}"
16838 );
16839 let link_nodes: Vec<_> = content
16841 .iter()
16842 .filter(|n| {
16843 n.marks
16844 .as_ref()
16845 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
16846 })
16847 .collect();
16848 assert!(!link_nodes.is_empty(), "expected a link node");
16849 assert_eq!(
16850 link_nodes[0].text.as_deref(),
16851 Some("Link"),
16852 "link text should be 'Link'"
16853 );
16854 }
16855
16856 #[test]
16857 fn empty_paragraph_roundtrip() {
16858 let mut adf_in = AdfDocument::new();
16860 adf_in.content = vec![
16861 AdfNode::paragraph(vec![AdfNode::text("before")]),
16862 AdfNode::paragraph(vec![]),
16863 AdfNode::paragraph(vec![AdfNode::text("after")]),
16864 ];
16865 let md = adf_to_markdown(&adf_in).unwrap();
16866 let adf_out = markdown_to_adf(&md).unwrap();
16867 assert_eq!(
16868 adf_out.content.len(),
16869 3,
16870 "should have 3 blocks, markdown:\n{md}"
16871 );
16872 assert_eq!(adf_out.content[0].node_type, "paragraph");
16873 assert_eq!(adf_out.content[1].node_type, "paragraph");
16874 assert!(
16875 adf_out.content[1].content.is_none(),
16876 "middle paragraph should be empty"
16877 );
16878 assert_eq!(adf_out.content[2].node_type, "paragraph");
16879 }
16880
16881 #[test]
16882 fn nbsp_paragraph_roundtrip() {
16883 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}";
16885 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16886 let md = adf_to_markdown(&doc).unwrap();
16887 assert!(
16888 md.contains("::paragraph["),
16889 "NBSP paragraph should use directive form: {md}"
16890 );
16891 let rt = markdown_to_adf(&md).unwrap();
16892 assert_eq!(rt.content.len(), 1, "should have 1 block");
16893 assert_eq!(rt.content[0].node_type, "paragraph");
16894 let text = rt.content[0].content.as_ref().unwrap()[0]
16895 .text
16896 .as_deref()
16897 .unwrap_or("");
16898 assert_eq!(text, "\u{00a0}", "NBSP should survive round-trip");
16899 }
16900
16901 #[test]
16902 fn nbsp_in_nested_expand_roundtrip() {
16903 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"nestedExpand\",\"attrs\":{\"title\":\"Section\"},\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}]}";
16905 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16906 let md = adf_to_markdown(&doc).unwrap();
16907 let rt = markdown_to_adf(&md).unwrap();
16908 let ne = &rt.content[0];
16909 assert_eq!(ne.node_type, "nestedExpand");
16910 let inner = ne.content.as_ref().unwrap();
16911 assert_eq!(inner.len(), 1, "should have 1 inner block");
16912 assert_eq!(inner[0].node_type, "paragraph");
16913 let content = inner[0].content.as_ref().unwrap();
16914 assert!(!content.is_empty(), "paragraph should not be empty");
16915 let text = content[0].text.as_deref().unwrap_or("");
16916 assert_eq!(text, "\u{00a0}", "NBSP should survive in nestedExpand");
16917 }
16918
16919 #[test]
16920 fn nbsp_followed_by_content() {
16921 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\"}]}]}";
16923 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16924 let md = adf_to_markdown(&doc).unwrap();
16925 let rt = markdown_to_adf(&md).unwrap();
16926 assert!(rt.content.len() >= 2, "should have at least 2 blocks");
16927 let after_para = rt.content.iter().find(|n| {
16929 n.node_type == "paragraph"
16930 && n.content
16931 .as_ref()
16932 .and_then(|c| c.first())
16933 .and_then(|n| n.text.as_deref())
16934 .is_some_and(|t| t.contains("after"))
16935 });
16936 assert!(after_para.is_some(), "should have paragraph with 'after'");
16937 }
16938
16939 #[test]
16940 fn nbsp_paragraph_with_marks_survives() {
16941 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\",\"marks\":[{\"type\":\"strong\"}]}]}]}";
16944 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16945 let md = adf_to_markdown(&doc).unwrap();
16946 assert!(md.contains("**"), "should have bold markers: {md}");
16947 let rt = markdown_to_adf(&md).unwrap();
16948 let content = rt.content[0].content.as_ref().unwrap();
16949 assert!(!content.is_empty(), "should preserve content");
16950 }
16951
16952 #[test]
16953 fn regular_paragraph_unchanged() {
16954 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}"#;
16956 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16957 let md = adf_to_markdown(&doc).unwrap();
16958 assert!(
16959 !md.contains("::paragraph"),
16960 "regular paragraphs should not use directive form: {md}"
16961 );
16962 assert!(md.contains("hello"));
16963 }
16964
16965 #[test]
16966 fn paragraph_directive_with_content_parsed() {
16967 let md = "::paragraph[\u{00a0}]\n";
16969 let doc = markdown_to_adf(md).unwrap();
16970 assert_eq!(doc.content.len(), 1);
16971 assert_eq!(doc.content[0].node_type, "paragraph");
16972 let content = doc.content[0].content.as_ref().unwrap();
16973 assert!(!content.is_empty(), "should have inline content");
16974 assert_eq!(content[0].text.as_deref().unwrap(), "\u{00a0}");
16975 }
16976
16977 #[test]
16978 fn nbsp_paragraph_in_list_item_with_nested_list() {
16979 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"}]}]}]}]}]}]}"#;
16981 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16982 let md = adf_to_markdown(&doc).unwrap();
16983 let rt = markdown_to_adf(&md).unwrap();
16984 let list = &rt.content[0];
16985 assert_eq!(list.node_type, "bulletList");
16986 let item = &list.content.as_ref().unwrap()[0];
16987 let item_content = item.content.as_ref().unwrap();
16988 assert_eq!(
16989 item_content.len(),
16990 2,
16991 "listItem should have paragraph + nested list, got: {item_content:?}"
16992 );
16993 let para = &item_content[0];
16994 assert_eq!(para.node_type, "paragraph");
16995 let para_content = para
16996 .content
16997 .as_ref()
16998 .expect("paragraph should have content");
16999 assert!(
17000 !para_content.is_empty(),
17001 "NBSP paragraph content should not be empty"
17002 );
17003 assert_eq!(
17004 para_content[0].text.as_deref().unwrap(),
17005 "\u{00a0}",
17006 "NBSP should survive round-trip inside listItem"
17007 );
17008 }
17009
17010 #[test]
17011 fn nbsp_paragraph_in_list_item_with_local_ids() {
17012 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"}]}]}]}]}]}]}"#;
17014 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17015 let md = adf_to_markdown(&doc).unwrap();
17016 let rt = markdown_to_adf(&md).unwrap();
17017 let list = &rt.content[0];
17018 let item = &list.content.as_ref().unwrap()[0];
17019 assert_eq!(
17021 item.attrs.as_ref().unwrap()["localId"],
17022 "li-001",
17023 "listItem localId should survive"
17024 );
17025 let item_content = item.content.as_ref().unwrap();
17026 assert_eq!(item_content.len(), 2);
17027 let para = &item_content[0];
17029 assert_eq!(
17030 para.attrs.as_ref().unwrap()["localId"],
17031 "p-001",
17032 "paragraph localId should survive"
17033 );
17034 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
17035 assert_eq!(text, "\u{00a0}", "NBSP should survive with localIds");
17036 }
17037
17038 #[test]
17039 fn nbsp_paragraph_in_list_item_without_nested_list() {
17040 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"}]}]}]}]}"#;
17042 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17043 let md = adf_to_markdown(&doc).unwrap();
17044 let rt = markdown_to_adf(&md).unwrap();
17045 let list = &rt.content[0];
17046 let item = &list.content.as_ref().unwrap()[0];
17047 let para = &item.content.as_ref().unwrap()[0];
17048 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
17049 assert_eq!(text, "\u{00a0}", "NBSP should survive in simple list item");
17050 }
17051
17052 #[test]
17053 fn nbsp_paragraph_in_ordered_list_item_with_nested_list() {
17054 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"}]}]}]}]}]}]}"#;
17056 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17057 let md = adf_to_markdown(&doc).unwrap();
17058 let rt = markdown_to_adf(&md).unwrap();
17059 let list = &rt.content[0];
17060 let item = &list.content.as_ref().unwrap()[0];
17061 let item_content = item.content.as_ref().unwrap();
17062 assert_eq!(item_content.len(), 2);
17063 let para = &item_content[0];
17064 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
17065 assert_eq!(text, "\u{00a0}", "NBSP should survive in ordered list item");
17066 }
17067
17068 #[test]
17069 fn list_item_leading_space_preserved() {
17070 let md = "- hello world\n- - text";
17072 let doc = markdown_to_adf(md).unwrap();
17073 let list = &doc.content[0];
17074 assert_eq!(list.node_type, "bulletList");
17075 let items = list.content.as_ref().unwrap();
17076 let first_para = &items[0].content.as_ref().unwrap()[0];
17078 let first_text = &first_para.content.as_ref().unwrap()[0];
17079 assert_eq!(first_text.text.as_deref(), Some("hello world"));
17080 }
17081
17082 #[test]
17083 fn list_item_leading_space_not_stripped() {
17084 let md = "- leading space text";
17087 let doc = markdown_to_adf(md).unwrap();
17088 let list = &doc.content[0];
17089 let items = list.content.as_ref().unwrap();
17090 let para = &items[0].content.as_ref().unwrap()[0];
17091 let text_node = ¶.content.as_ref().unwrap()[0];
17092 assert_eq!(
17094 text_node.text.as_deref(),
17095 Some(" leading space text"),
17096 "leading space should be preserved"
17097 );
17098 }
17099
17100 #[test]
17105 fn hardbreak_in_cell_uses_directive_table() {
17106 let adf = AdfDocument {
17109 version: 1,
17110 doc_type: "doc".to_string(),
17111 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17112 AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17113 AdfNode::text("before"),
17114 AdfNode::hard_break(),
17115 AdfNode::text("after"),
17116 ])]),
17117 ])])],
17118 };
17119 let md = adf_to_markdown(&adf).unwrap();
17120 assert!(
17122 md.contains(":::td") || md.contains("::::table"),
17123 "Table with hardBreak should use directive form, got:\n{md}"
17124 );
17125 assert!(
17126 !md.contains("| before"),
17127 "Should NOT use pipe syntax with hardBreak"
17128 );
17129 }
17130
17131 #[test]
17132 fn hardbreak_in_cell_roundtrips() {
17133 let adf = AdfDocument {
17135 version: 1,
17136 doc_type: "doc".to_string(),
17137 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17138 AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17139 AdfNode::text("line one"),
17140 AdfNode::hard_break(),
17141 AdfNode::text("line two"),
17142 ])]),
17143 ])])],
17144 };
17145 let md = adf_to_markdown(&adf).unwrap();
17146 let roundtripped = markdown_to_adf(&md).unwrap();
17147
17148 assert_eq!(roundtripped.content.len(), 1);
17150 assert_eq!(roundtripped.content[0].node_type, "table");
17151 let rows = roundtripped.content[0].content.as_ref().unwrap();
17152 assert_eq!(
17153 rows.len(),
17154 1,
17155 "Should have exactly 1 row, got {}",
17156 rows.len()
17157 );
17158 }
17159
17160 #[test]
17161 fn hardbreak_in_paragraph_roundtrips() {
17162 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17164 {"type":"text","text":"line one"},
17165 {"type":"hardBreak"},
17166 {"type":"text","text":"line two"}
17167 ]}]}"#;
17168 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17169 let md = adf_to_markdown(&doc).unwrap();
17170 let round_tripped = markdown_to_adf(&md).unwrap();
17171 let inlines = round_tripped.content[0].content.as_ref().unwrap();
17172 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17173 assert_eq!(
17174 types,
17175 vec!["text", "hardBreak", "text"],
17176 "hardBreak should be preserved, got: {types:?}"
17177 );
17178 assert_eq!(inlines[0].text.as_deref(), Some("line one"));
17179 assert_eq!(inlines[2].text.as_deref(), Some("line two"));
17180 }
17181
17182 #[test]
17183 fn consecutive_hardbreaks_in_paragraph_roundtrip() {
17184 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17186 {"type":"text","text":"before"},
17187 {"type":"hardBreak"},
17188 {"type":"hardBreak"},
17189 {"type":"text","text":"after"}
17190 ]}]}"#;
17191 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17192 let md = adf_to_markdown(&doc).unwrap();
17193 let round_tripped = markdown_to_adf(&md).unwrap();
17194 assert_eq!(
17195 round_tripped.content.len(),
17196 1,
17197 "Should remain a single paragraph, got {} blocks",
17198 round_tripped.content.len()
17199 );
17200 let inlines = round_tripped.content[0].content.as_ref().unwrap();
17201 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17202 assert_eq!(
17203 types,
17204 vec!["text", "hardBreak", "hardBreak", "text"],
17205 "Both hardBreaks should be preserved, got: {types:?}"
17206 );
17207 assert_eq!(inlines[0].text.as_deref(), Some("before"));
17208 assert_eq!(inlines[3].text.as_deref(), Some("after"));
17209 }
17210
17211 #[test]
17212 fn hardbreak_only_paragraph_roundtrips() {
17213 let adf_json = r#"{"version":1,"type":"doc","content":[
17215 {"type":"paragraph","content":[{"type":"hardBreak"}]}
17216 ]}"#;
17217 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17218 let md = adf_to_markdown(&doc).unwrap();
17219 let round_tripped = markdown_to_adf(&md).unwrap();
17220 assert_eq!(
17221 round_tripped.content.len(),
17222 1,
17223 "Paragraph should not be dropped, got {} blocks",
17224 round_tripped.content.len()
17225 );
17226 let inlines = round_tripped.content[0].content.as_ref().unwrap();
17227 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17228 assert_eq!(
17229 types,
17230 vec!["hardBreak"],
17231 "hardBreak-only paragraph should preserve its content, got: {types:?}"
17232 );
17233 }
17234
17235 #[test]
17236 fn issue_410_full_reproducer_roundtrips() {
17237 let adf_json = r#"{"version":1,"type":"doc","content":[
17239 {"type":"paragraph","content":[
17240 {"type":"text","text":"before"},
17241 {"type":"hardBreak"},
17242 {"type":"hardBreak"},
17243 {"type":"text","text":"after"}
17244 ]},
17245 {"type":"paragraph","content":[
17246 {"type":"hardBreak"}
17247 ]}
17248 ]}"#;
17249 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17250 let md = adf_to_markdown(&doc).unwrap();
17251 let round_tripped = markdown_to_adf(&md).unwrap();
17252 assert_eq!(
17253 round_tripped.content.len(),
17254 2,
17255 "Should have exactly 2 paragraphs, got {}",
17256 round_tripped.content.len()
17257 );
17258 let p1 = round_tripped.content[0].content.as_ref().unwrap();
17260 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
17261 assert_eq!(types1, vec!["text", "hardBreak", "hardBreak", "text"]);
17262 let p2 = round_tripped.content[1].content.as_ref().unwrap();
17264 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
17265 assert_eq!(types2, vec!["hardBreak"]);
17266 }
17267
17268 #[test]
17269 fn trailing_space_hardbreak_still_parsed() {
17270 let md = "line one \nline two\n";
17272 let doc = markdown_to_adf(md).unwrap();
17273 let inlines = doc.content[0].content.as_ref().unwrap();
17274 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17275 assert_eq!(
17276 types,
17277 vec!["text", "hardBreak", "text"],
17278 "Trailing-space hardBreak should still parse, got: {types:?}"
17279 );
17280 }
17281
17282 #[test]
17283 fn trailing_hardbreak_at_end_of_paragraph_roundtrips() {
17284 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17286 {"type":"text","text":"text"},
17287 {"type":"hardBreak"}
17288 ]}]}"#;
17289 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17290 let md = adf_to_markdown(&doc).unwrap();
17291 let round_tripped = markdown_to_adf(&md).unwrap();
17292 let inlines = round_tripped.content[0].content.as_ref().unwrap();
17293 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17294 assert_eq!(
17295 types,
17296 vec!["text", "hardBreak"],
17297 "Trailing hardBreak should be preserved, got: {types:?}"
17298 );
17299 }
17300
17301 #[test]
17302 #[test]
17303 fn table_with_header_row_uses_pipe_syntax() {
17304 let adf = AdfDocument {
17306 version: 1,
17307 doc_type: "doc".to_string(),
17308 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17309 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("header cell")])]),
17310 ])])],
17311 };
17312 let md = adf_to_markdown(&adf).unwrap();
17313 assert!(
17314 md.contains("| header cell |"),
17315 "Table with header row should use pipe syntax, got:\n{md}"
17316 );
17317 }
17318
17319 #[test]
17320 fn table_without_header_row_uses_directive_syntax() {
17321 let adf = AdfDocument {
17324 version: 1,
17325 doc_type: "doc".to_string(),
17326 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17327 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("simple cell")])]),
17328 ])])],
17329 };
17330 let md = adf_to_markdown(&adf).unwrap();
17331 assert!(
17332 md.contains("::::table"),
17333 "Table without header row should use directive syntax, got:\n{md}"
17334 );
17335 }
17336
17337 #[test]
17338 fn tablecell_first_row_preserved_on_roundtrip() {
17339 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[
17341 {"type":"tableRow","content":[
17342 {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row1 cell"}]}]}
17343 ]},
17344 {"type":"tableRow","content":[
17345 {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row2 cell"}]}]}
17346 ]}
17347 ]}]}"#;
17348 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17349 let md = adf_to_markdown(&doc).unwrap();
17350 let round_tripped = markdown_to_adf(&md).unwrap();
17351 let rows = round_tripped.content[0].content.as_ref().unwrap();
17352 let row0_cell = &rows[0].content.as_ref().unwrap()[0];
17353 assert_eq!(
17354 row0_cell.node_type, "tableCell",
17355 "first row cell should remain tableCell, got: {}",
17356 row0_cell.node_type
17357 );
17358 let row1_cell = &rows[1].content.as_ref().unwrap()[0];
17359 assert_eq!(row1_cell.node_type, "tableCell");
17360 }
17361
17362 #[test]
17363 fn mixed_header_and_cell_first_row_uses_pipe() {
17364 let adf = AdfDocument {
17366 version: 1,
17367 doc_type: "doc".to_string(),
17368 content: vec![AdfNode::table(vec![
17369 AdfNode::table_row(vec![
17370 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17371 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17372 ]),
17373 AdfNode::table_row(vec![
17374 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
17375 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
17376 ]),
17377 ])],
17378 };
17379 let md = adf_to_markdown(&adf).unwrap();
17380 assert!(
17381 md.contains("| H1 |"),
17382 "Table with header first row should use pipe syntax, got:\n{md}"
17383 );
17384 assert!(!md.contains("::::table"), "should not use directive syntax");
17385 }
17386
17387 #[test]
17390 fn render_pipe_table_escapes_pipe_in_code_span_cell() {
17391 let adf = AdfDocument {
17394 version: 1,
17395 doc_type: "doc".to_string(),
17396 content: vec![AdfNode::table(vec![
17397 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17398 AdfNode::text("Header"),
17399 ])])]),
17400 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17401 AdfNode::text_with_marks("a|b", vec![AdfMark::code()]),
17402 ])])]),
17403 ])],
17404 };
17405 let md = adf_to_markdown(&adf).unwrap();
17406 assert!(
17407 md.contains(r"`a\|b`"),
17408 "Pipe inside code span must be escaped, got:\n{md}"
17409 );
17410 }
17411
17412 #[test]
17413 fn render_pipe_table_escapes_pipe_in_plain_text_cell() {
17414 let adf = AdfDocument {
17415 version: 1,
17416 doc_type: "doc".to_string(),
17417 content: vec![AdfNode::table(vec![
17418 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17419 AdfNode::text("Header"),
17420 ])])]),
17421 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17422 AdfNode::text("x|y"),
17423 ])])]),
17424 ])],
17425 };
17426 let md = adf_to_markdown(&adf).unwrap();
17427 assert!(
17428 md.contains(r"x\|y"),
17429 "Pipe inside plain-text cell must be escaped, got:\n{md}"
17430 );
17431 }
17432
17433 #[test]
17434 fn code_span_with_pipe_in_table_cell_roundtrips() {
17435 let adf_json = r#"{
17437 "version": 1,
17438 "type": "doc",
17439 "content": [{
17440 "type": "table",
17441 "attrs": {"isNumberColumnEnabled": false, "layout": "default", "localId": "abc-789"},
17442 "content": [
17443 {"type": "tableRow", "content": [
17444 {"type": "tableHeader", "attrs": {}, "content": [
17445 {"type": "paragraph", "content": [{"type": "text", "text": "Before"}]}
17446 ]},
17447 {"type": "tableHeader", "attrs": {}, "content": [
17448 {"type": "paragraph", "content": [{"type": "text", "text": "After"}]}
17449 ]}
17450 ]},
17451 {"type": "tableRow", "content": [
17452 {"type": "tableCell", "attrs": {}, "content": [
17453 {"type": "paragraph", "content": [
17454 {"type": "text", "text": "parse(json).extract[T]", "marks": [{"type": "code"}]}
17455 ]}
17456 ]},
17457 {"type": "tableCell", "attrs": {}, "content": [
17458 {"type": "paragraph", "content": [
17459 {"type": "text", "text": "parser.decode[T|json]", "marks": [{"type": "code"}]}
17460 ]}
17461 ]}
17462 ]}
17463 ]
17464 }]
17465 }"#;
17466 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17467 let md = adf_to_markdown(&doc).unwrap();
17468 let round_tripped = markdown_to_adf(&md).unwrap();
17469
17470 let rows = round_tripped.content[0].content.as_ref().unwrap();
17471 assert_eq!(
17472 rows.len(),
17473 2,
17474 "Table should have 2 rows, got: {}",
17475 rows.len()
17476 );
17477
17478 let body_row = rows[1].content.as_ref().unwrap();
17479 assert_eq!(
17480 body_row.len(),
17481 2,
17482 "Body row should have 2 cells (not split by the pipe), got: {}",
17483 body_row.len()
17484 );
17485
17486 let second_cell = &body_row[1];
17487 let para = second_cell.content.as_ref().unwrap().first().unwrap();
17488 let inlines = para.content.as_ref().unwrap();
17489 assert_eq!(inlines.len(), 1, "Cell should have a single text node");
17490 assert_eq!(
17491 inlines[0].text.as_deref(),
17492 Some("parser.decode[T|json]"),
17493 "Code-span text must be preserved with literal pipe"
17494 );
17495 let marks = inlines[0]
17496 .marks
17497 .as_ref()
17498 .expect("code mark must be preserved");
17499 assert!(
17500 marks.iter().any(|m| m.mark_type == "code"),
17501 "text node should carry the code mark"
17502 );
17503 }
17504
17505 #[test]
17506 fn plain_text_pipe_in_table_cell_roundtrips() {
17507 let adf = AdfDocument {
17509 version: 1,
17510 doc_type: "doc".to_string(),
17511 content: vec![AdfNode::table(vec![
17512 AdfNode::table_row(vec![
17513 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17514 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17515 ]),
17516 AdfNode::table_row(vec![
17517 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a|b")])]),
17518 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("c")])]),
17519 ]),
17520 ])],
17521 };
17522 let md = adf_to_markdown(&adf).unwrap();
17523 let round_tripped = markdown_to_adf(&md).unwrap();
17524 let rows = round_tripped.content[0].content.as_ref().unwrap();
17525 let body_row = rows[1].content.as_ref().unwrap();
17526 assert_eq!(
17527 body_row.len(),
17528 2,
17529 "Body row should keep 2 cells, got: {}",
17530 body_row.len()
17531 );
17532 let first_cell_text = body_row[0].content.as_ref().unwrap()[0]
17533 .content
17534 .as_ref()
17535 .unwrap()[0]
17536 .text
17537 .as_deref();
17538 assert_eq!(first_cell_text, Some("a|b"));
17539 }
17540
17541 #[test]
17542 fn cell_contains_hard_break_true() {
17543 let para = AdfNode::paragraph(vec![
17544 AdfNode::text("a"),
17545 AdfNode::hard_break(),
17546 AdfNode::text("b"),
17547 ]);
17548 assert!(cell_contains_hard_break(¶));
17549 }
17550
17551 #[test]
17552 fn cell_contains_hard_break_false() {
17553 let para = AdfNode::paragraph(vec![AdfNode::text("no break here")]);
17554 assert!(!cell_contains_hard_break(¶));
17555 }
17556
17557 #[test]
17558 fn cell_contains_hard_break_empty() {
17559 let para = AdfNode::paragraph(vec![]);
17560 assert!(!cell_contains_hard_break(¶));
17561 }
17562
17563 #[test]
17566 fn multi_paragraph_panel_roundtrips() {
17567 let adf = AdfDocument {
17568 version: 1,
17569 doc_type: "doc".to_string(),
17570 content: vec![AdfNode {
17571 node_type: "panel".to_string(),
17572 attrs: Some(serde_json::json!({"panelType": "info"})),
17573 content: Some(vec![
17574 AdfNode::paragraph(vec![AdfNode::text("First paragraph.")]),
17575 AdfNode::paragraph(vec![AdfNode::text("Second paragraph.")]),
17576 ]),
17577 text: None,
17578 marks: None,
17579 local_id: None,
17580 parameters: None,
17581 }],
17582 };
17583
17584 let md = adf_to_markdown(&adf).unwrap();
17585 assert!(
17587 md.contains("First paragraph.\n\nSecond paragraph."),
17588 "Panel should have blank line between paragraphs, got:\n{md}"
17589 );
17590
17591 let roundtripped = markdown_to_adf(&md).unwrap();
17593 assert_eq!(roundtripped.content.len(), 1);
17594 assert_eq!(roundtripped.content[0].node_type, "panel");
17595 let panel_content = roundtripped.content[0].content.as_ref().unwrap();
17596 assert_eq!(
17597 panel_content.len(),
17598 2,
17599 "Panel should have 2 paragraphs after round-trip, got {}",
17600 panel_content.len()
17601 );
17602 }
17603
17604 #[test]
17605 fn multi_paragraph_expand_roundtrips() {
17606 let adf = AdfDocument {
17607 version: 1,
17608 doc_type: "doc".to_string(),
17609 content: vec![AdfNode {
17610 node_type: "expand".to_string(),
17611 attrs: Some(serde_json::json!({"title": "Details"})),
17612 content: Some(vec![
17613 AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17614 AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17615 ]),
17616 text: None,
17617 marks: None,
17618 local_id: None,
17619 parameters: None,
17620 }],
17621 };
17622
17623 let md = adf_to_markdown(&adf).unwrap();
17624 let roundtripped = markdown_to_adf(&md).unwrap();
17625 let expand_content = roundtripped.content[0].content.as_ref().unwrap();
17626 assert_eq!(
17627 expand_content.len(),
17628 2,
17629 "Expand should have 2 paragraphs after round-trip, got {}",
17630 expand_content.len()
17631 );
17632 }
17633
17634 #[test]
17635 fn consecutive_nested_expands_in_table_cell_roundtrip() {
17636 let cell_content = vec![
17637 AdfNode {
17638 node_type: "nestedExpand".to_string(),
17639 attrs: Some(serde_json::json!({"title": "First"})),
17640 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 1")])]),
17641 text: None,
17642 marks: None,
17643 local_id: None,
17644 parameters: None,
17645 },
17646 AdfNode {
17647 node_type: "nestedExpand".to_string(),
17648 attrs: Some(serde_json::json!({"title": "Second"})),
17649 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 2")])]),
17650 text: None,
17651 marks: None,
17652 local_id: None,
17653 parameters: None,
17654 },
17655 ];
17656 let adf = AdfDocument {
17657 version: 1,
17658 doc_type: "doc".to_string(),
17659 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17660 AdfNode::table_cell(cell_content),
17661 ])])],
17662 };
17663
17664 let md = adf_to_markdown(&adf).unwrap();
17665 assert!(
17666 md.contains(":::\n\n:::nested-expand"),
17667 "Should have blank line between consecutive nested-expands in cell, got:\n{md}"
17668 );
17669
17670 let rt = markdown_to_adf(&md).unwrap();
17671 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17672 .content
17673 .as_ref()
17674 .unwrap()[0];
17675 let cell_nodes = cell.content.as_ref().unwrap();
17676 let expand_count = cell_nodes
17677 .iter()
17678 .filter(|n| n.node_type == "nestedExpand")
17679 .count();
17680 assert_eq!(
17681 expand_count, 2,
17682 "Both nested-expands should survive round-trip, got {expand_count}"
17683 );
17684 }
17685
17686 #[test]
17687 fn multi_paragraph_in_table_cell_roundtrip() {
17688 let adf = AdfDocument {
17690 version: 1,
17691 doc_type: "doc".to_string(),
17692 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17693 AdfNode::table_cell(vec![
17694 AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17695 AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17696 ]),
17697 ])])],
17698 };
17699
17700 let md = adf_to_markdown(&adf).unwrap();
17701 assert!(
17702 md.contains("Para one.\n\nPara two."),
17703 "Should have blank line between paragraphs in cell, got:\n{md}"
17704 );
17705
17706 let rt = markdown_to_adf(&md).unwrap();
17707 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17708 .content
17709 .as_ref()
17710 .unwrap()[0];
17711 let para_count = cell
17712 .content
17713 .as_ref()
17714 .unwrap()
17715 .iter()
17716 .filter(|n| n.node_type == "paragraph")
17717 .count();
17718 assert_eq!(para_count, 2, "Both paragraphs should survive round-trip");
17719 }
17720
17721 #[test]
17722 fn panel_inside_table_cell_roundtrip() {
17723 let adf = AdfDocument {
17725 version: 1,
17726 doc_type: "doc".to_string(),
17727 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17728 AdfNode::table_cell(vec![
17729 AdfNode::paragraph(vec![AdfNode::text("Before panel.")]),
17730 AdfNode {
17731 node_type: "panel".to_string(),
17732 attrs: Some(serde_json::json!({"panelType": "info"})),
17733 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text(
17734 "Panel content",
17735 )])]),
17736 text: None,
17737 marks: None,
17738 local_id: None,
17739 parameters: None,
17740 },
17741 ]),
17742 ])])],
17743 };
17744
17745 let md = adf_to_markdown(&adf).unwrap();
17746 assert!(
17747 md.contains(":::panel"),
17748 "Should contain panel directive, got:\n{md}"
17749 );
17750
17751 let rt = markdown_to_adf(&md).unwrap();
17752 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17753 .content
17754 .as_ref()
17755 .unwrap()[0];
17756 let has_panel = cell
17757 .content
17758 .as_ref()
17759 .unwrap()
17760 .iter()
17761 .any(|n| n.node_type == "panel");
17762 assert!(has_panel, "Panel should survive round-trip in table cell");
17763 }
17764
17765 #[test]
17766 fn three_consecutive_expands_in_table_cell() {
17767 let make_expand = |title: &str| AdfNode {
17768 node_type: "nestedExpand".to_string(),
17769 attrs: Some(serde_json::json!({"title": title})),
17770 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("content")])]),
17771 text: None,
17772 marks: None,
17773 local_id: None,
17774 parameters: None,
17775 };
17776 let adf = AdfDocument {
17777 version: 1,
17778 doc_type: "doc".to_string(),
17779 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17780 AdfNode::table_cell(vec![
17781 make_expand("First"),
17782 make_expand("Second"),
17783 make_expand("Third"),
17784 ]),
17785 ])])],
17786 };
17787
17788 let md = adf_to_markdown(&adf).unwrap();
17789 let rt = markdown_to_adf(&md).unwrap();
17790 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17791 .content
17792 .as_ref()
17793 .unwrap()[0];
17794 let expand_count = cell
17795 .content
17796 .as_ref()
17797 .unwrap()
17798 .iter()
17799 .filter(|n| n.node_type == "nestedExpand")
17800 .count();
17801 assert_eq!(expand_count, 3, "All 3 expands should survive round-trip");
17802 }
17803
17804 #[test]
17807 fn nested_expand_inside_panel() {
17808 let md = ":::panel{type=info}\n:::expand{title=\"Details\"}\nHidden content\n:::\nMore panel content\n:::";
17813 let adf = markdown_to_adf(md).unwrap();
17814
17815 let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
17816 assert!(err.violations.iter().any(|v| matches!(
17817 v,
17818 crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
17819 parent_type, child_type, ..
17820 } if parent_type == "panel" && child_type == "expand"
17821 )));
17822 }
17823
17824 #[test]
17825 fn nested_expand_inside_table_cell() {
17826 let md = "::::table\n:::tr\n:::td\n:::expand{title=\"Details\"}\nExpand content\n:::\n:::\n:::\n::::";
17830 let adf = markdown_to_adf(md).unwrap();
17831
17832 let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
17833 assert!(err.violations.iter().any(|v| matches!(
17834 v,
17835 crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
17836 parent_type, child_type, ..
17837 } if parent_type == "tableCell" && child_type == "expand"
17838 )));
17839 }
17840
17841 #[test]
17842 fn nested_expand_inside_layout_column() {
17843 let md = ":::layout\n:::column{width=50}\n:::expand{title=\"Col Expand\"}\nExpanded\n:::\n:::\n:::column{width=50}\nFiller paragraph.\n:::\n:::";
17848 let adf = markdown_to_adf(md).unwrap();
17849
17850 assert_eq!(adf.content.len(), 1);
17851 assert_eq!(adf.content[0].node_type, "layoutSection");
17852
17853 let columns = adf.content[0].content.as_ref().unwrap();
17854 assert_eq!(columns.len(), 2);
17855 let col_content = columns[0].content.as_ref().unwrap();
17856 assert!(
17857 col_content.iter().any(|n| n.node_type == "expand"),
17858 "Column should contain an expand node, got: {:?}",
17859 col_content.iter().map(|n| &n.node_type).collect::<Vec<_>>()
17860 );
17861
17862 crate::atlassian::adf_validated::validate(&adf).unwrap();
17864 }
17865
17866 #[test]
17867 fn expand_localid_in_directive_attrs() {
17868 let adf_json = r#"{"version":1,"type":"doc","content":[
17870 {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
17871 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17872 ]}
17873 ]}"#;
17874 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17875 let md = adf_to_markdown(&doc).unwrap();
17876 assert!(
17877 md.contains("localId=exp-001"),
17878 "should contain localId: {md}"
17879 );
17880 assert!(
17881 md.contains(":::expand{"),
17882 "should have expand directive with attrs: {md}"
17883 );
17884 assert!(
17885 !md.contains(":::\n{localId="),
17886 "localId should NOT be trailing: {md}"
17887 );
17888 }
17889
17890 #[test]
17891 fn expand_localid_roundtrip() {
17892 let adf_json = r#"{"version":1,"type":"doc","content":[
17893 {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
17894 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17895 ]}
17896 ]}"#;
17897 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17898 let md = adf_to_markdown(&doc).unwrap();
17899 let rt = markdown_to_adf(&md).unwrap();
17900 let expand = &rt.content[0];
17901 assert_eq!(expand.node_type, "expand");
17902 assert_eq!(
17903 expand.local_id.as_deref(),
17904 Some("exp-001"),
17905 "expand localId should survive round-trip"
17906 );
17907 assert_eq!(
17908 expand.attrs.as_ref().unwrap()["title"],
17909 "Details",
17910 "expand title should survive round-trip"
17911 );
17912 }
17913
17914 #[test]
17915 fn nested_expand_localid_roundtrip() {
17916 let adf_json = r#"{"version":1,"type":"doc","content":[
17917 {"type":"nestedExpand","attrs":{"localId":"ne-001","title":"S"},"content":[
17918 {"type":"paragraph","content":[{"type":"text","text":"content"}]}
17919 ]}
17920 ]}"#;
17921 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17922 let md = adf_to_markdown(&doc).unwrap();
17923 assert!(
17924 md.contains(":::nested-expand{"),
17925 "should have directive: {md}"
17926 );
17927 assert!(md.contains("localId=ne-001"), "should have localId: {md}");
17928 let rt = markdown_to_adf(&md).unwrap();
17929 let ne = &rt.content[0];
17930 assert_eq!(ne.node_type, "nestedExpand");
17931 assert_eq!(ne.local_id.as_deref(), Some("ne-001"));
17932 }
17933
17934 #[test]
17935 fn nested_expand_localid_followed_by_content() {
17936 let adf_json = "{\
17938 \"version\":1,\"type\":\"doc\",\"content\":[\
17939 {\"type\":\"nestedExpand\",\"attrs\":{\"localId\":\"exp-001\",\"title\":\"S\"},\"content\":[\
17940 {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}\
17941 ]},\
17942 {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]}\
17943 ]}";
17944 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17945 let md = adf_to_markdown(&doc).unwrap();
17946 let rt = markdown_to_adf(&md).unwrap();
17947 let ne = &rt.content[0];
17949 assert_eq!(ne.node_type, "nestedExpand");
17950 assert_eq!(
17951 ne.local_id.as_deref(),
17952 Some("exp-001"),
17953 "nestedExpand should preserve localId"
17954 );
17955 let para = &rt.content[1];
17957 assert_eq!(para.node_type, "paragraph");
17958 let text = para.content.as_ref().unwrap()[0]
17959 .text
17960 .as_deref()
17961 .unwrap_or("");
17962 assert!(
17963 !text.contains("localId"),
17964 "following paragraph should not contain localId: {text}"
17965 );
17966 assert!(
17967 text.contains("after"),
17968 "following paragraph should contain 'after': {text}"
17969 );
17970 }
17971
17972 #[test]
17973 fn expand_localid_without_title() {
17974 let adf_json = r#"{"version":1,"type":"doc","content":[
17975 {"type":"expand","attrs":{"localId":"exp-002"},"content":[
17976 {"type":"paragraph","content":[{"type":"text","text":"no title"}]}
17977 ]}
17978 ]}"#;
17979 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17980 let md = adf_to_markdown(&doc).unwrap();
17981 assert!(
17982 md.contains(":::expand{localId=exp-002}"),
17983 "should have localId without title: {md}"
17984 );
17985 let rt = markdown_to_adf(&md).unwrap();
17986 assert_eq!(rt.content[0].local_id.as_deref(), Some("exp-002"));
17987 }
17988
17989 #[test]
17990 fn expand_localid_stripped() {
17991 let adf_json = r#"{"version":1,"type":"doc","content":[
17992 {"type":"expand","attrs":{"localId":"exp-001","title":"X"},"content":[
17993 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17994 ]}
17995 ]}"#;
17996 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17997 let opts = RenderOptions {
17998 strip_local_ids: true,
17999 };
18000 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18001 assert!(!md.contains("localId"), "localId should be stripped: {md}");
18002 assert!(
18003 md.contains(":::expand{title=\"X\"}"),
18004 "title should remain: {md}"
18005 );
18006 }
18007
18008 #[test]
18011 fn expand_top_level_localid_roundtrip() {
18012 let adf_json = r#"{"version":1,"type":"doc","content":[
18014 {"type":"expand","attrs":{"title":"My Section"},"localId":"abc-123","content":[
18015 {"type":"paragraph","content":[{"type":"text","text":"hello"}]}
18016 ]}
18017 ]}"#;
18018 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18019 assert_eq!(doc.content[0].local_id.as_deref(), Some("abc-123"));
18020 let md = adf_to_markdown(&doc).unwrap();
18021 assert!(
18022 md.contains("localId=abc-123"),
18023 "JFM should contain localId: {md}"
18024 );
18025 let rt = markdown_to_adf(&md).unwrap();
18026 let expand = &rt.content[0];
18027 assert_eq!(expand.node_type, "expand");
18028 assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
18029 assert_eq!(
18030 expand.attrs.as_ref().unwrap()["title"],
18031 "My Section",
18032 "title should survive round-trip"
18033 );
18034 }
18035
18036 #[test]
18037 fn expand_parameters_roundtrip() {
18038 let adf_json = r#"{"version":1,"type":"doc","content":[
18040 {"type":"expand","attrs":{"title":"Props"},"parameters":{"macroMetadata":{"macroId":{"value":"m-001"},"schemaVersion":{"value":"1"}}},"content":[
18041 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
18042 ]}
18043 ]}"#;
18044 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18045 assert!(doc.content[0].parameters.is_some());
18046 let md = adf_to_markdown(&doc).unwrap();
18047 assert!(md.contains("params="), "JFM should contain params: {md}");
18048 let rt = markdown_to_adf(&md).unwrap();
18049 let expand = &rt.content[0];
18050 let params = expand
18051 .parameters
18052 .as_ref()
18053 .expect("parameters should survive round-trip");
18054 assert_eq!(params["macroMetadata"]["macroId"]["value"], "m-001");
18055 assert_eq!(params["macroMetadata"]["schemaVersion"]["value"], "1");
18056 }
18057
18058 #[test]
18059 fn expand_localid_and_parameters_roundtrip() {
18060 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"}]}]}]}"#;
18062 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18063 let md = adf_to_markdown(&doc).unwrap();
18064 let rt = markdown_to_adf(&md).unwrap();
18065 let expand = &rt.content[0];
18066 assert_eq!(expand.node_type, "expand");
18067 assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
18068 assert_eq!(expand.attrs.as_ref().unwrap()["title"], "My Section");
18069 let params = expand
18070 .parameters
18071 .as_ref()
18072 .expect("parameters should survive");
18073 assert_eq!(params["macroMetadata"]["macroId"]["value"], "macro-001");
18074 assert_eq!(params["macroMetadata"]["title"], "Page Properties");
18075 }
18076
18077 #[test]
18078 fn nested_expand_top_level_localid_and_parameters_roundtrip() {
18079 let adf_json = r#"{"version":1,"type":"doc","content":[
18080 {"type":"nestedExpand","attrs":{"title":"Nested"},"localId":"ne-100","parameters":{"macroMetadata":{"macroId":{"value":"nm-001"}}},"content":[
18081 {"type":"paragraph","content":[{"type":"text","text":"inner"}]}
18082 ]}
18083 ]}"#;
18084 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18085 let md = adf_to_markdown(&doc).unwrap();
18086 assert!(
18087 md.contains(":::nested-expand{"),
18088 "should use nested-expand: {md}"
18089 );
18090 assert!(md.contains("localId=ne-100"), "should have localId: {md}");
18091 assert!(md.contains("params="), "should have params: {md}");
18092 let rt = markdown_to_adf(&md).unwrap();
18093 let ne = &rt.content[0];
18094 assert_eq!(ne.node_type, "nestedExpand");
18095 assert_eq!(ne.local_id.as_deref(), Some("ne-100"));
18096 assert_eq!(
18097 ne.parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
18098 "nm-001"
18099 );
18100 }
18101
18102 #[test]
18103 fn expand_top_level_localid_stripped() {
18104 let adf_json = r#"{"version":1,"type":"doc","content":[
18106 {"type":"expand","attrs":{"title":"X"},"localId":"exp-strip","content":[
18107 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
18108 ]}
18109 ]}"#;
18110 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18111 let opts = RenderOptions {
18112 strip_local_ids: true,
18113 };
18114 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18115 assert!(!md.contains("localId"), "localId should be stripped: {md}");
18116 assert!(
18117 md.contains(":::expand{title=\"X\"}"),
18118 "title should remain: {md}"
18119 );
18120 }
18121
18122 #[test]
18123 fn expand_parameters_without_localid() {
18124 let adf_json = r#"{"version":1,"type":"doc","content":[
18126 {"type":"expand","attrs":{"title":"P"},"parameters":{"macroMetadata":{"macroId":{"value":"solo"}}},"content":[
18127 {"type":"paragraph","content":[{"type":"text","text":"data"}]}
18128 ]}
18129 ]}"#;
18130 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18131 let md = adf_to_markdown(&doc).unwrap();
18132 assert!(!md.contains("localId"), "no localId: {md}");
18133 assert!(md.contains("params="), "has params: {md}");
18134 let rt = markdown_to_adf(&md).unwrap();
18135 assert!(rt.content[0].local_id.is_none());
18136 assert_eq!(
18137 rt.content[0].parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
18138 "solo"
18139 );
18140 }
18141
18142 #[test]
18143 fn expand_localid_without_parameters() {
18144 let adf_json = r#"{"version":1,"type":"doc","content":[
18146 {"type":"expand","attrs":{"title":"L"},"localId":"lid-only","content":[
18147 {"type":"paragraph","content":[{"type":"text","text":"txt"}]}
18148 ]}
18149 ]}"#;
18150 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18151 let md = adf_to_markdown(&doc).unwrap();
18152 assert!(md.contains("localId=lid-only"), "has localId: {md}");
18153 assert!(!md.contains("params="), "no params: {md}");
18154 let rt = markdown_to_adf(&md).unwrap();
18155 assert_eq!(rt.content[0].local_id.as_deref(), Some("lid-only"));
18156 assert!(rt.content[0].parameters.is_none());
18157 }
18158
18159 #[test]
18160 fn nested_panel_inside_panel() {
18161 let md = ":::panel{type=info}\n:::panel{type=warning}\nInner warning\n:::\n:::";
18162 let adf = markdown_to_adf(md).unwrap();
18163
18164 assert_eq!(adf.content.len(), 1);
18166 assert_eq!(adf.content[0].node_type, "panel");
18167
18168 let panel_content = adf.content[0].content.as_ref().unwrap();
18170 assert!(
18171 panel_content.iter().any(|n| n.node_type == "panel"),
18172 "Outer panel should contain an inner panel, got: {:?}",
18173 panel_content
18174 .iter()
18175 .map(|n| &n.node_type)
18176 .collect::<Vec<_>>()
18177 );
18178 }
18179
18180 #[test]
18181 fn content_after_directive_table_is_preserved() {
18182 let md = "\
18184## Before table
18185
18186::::table{layout=default}
18187:::tr
18188:::th{}
18189Cell
18190:::
18191:::
18192::::
18193
18194## After table
18195
18196Paragraph after.";
18197 let adf = markdown_to_adf(md).unwrap();
18198 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18199 assert_eq!(
18200 types,
18201 vec!["heading", "table", "heading", "paragraph"],
18202 "Content after table was dropped: got {types:?}"
18203 );
18204 }
18205
18206 #[test]
18207 fn paragraph_after_directive_table_is_preserved() {
18208 let md = "\
18210::::table{layout=default}
18211:::tr
18212:::th{}
18213Header
18214:::
18215:::
18216::::
18217
18218Just a paragraph.";
18219 let adf = markdown_to_adf(md).unwrap();
18220 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18221 assert_eq!(
18222 types,
18223 vec!["table", "paragraph"],
18224 "Paragraph after table was dropped: got {types:?}"
18225 );
18226 }
18227
18228 #[test]
18229 fn extension_after_directive_table_is_preserved() {
18230 let md = "\
18232::::table{layout=default}
18233:::tr
18234:::th{}
18235Header
18236:::
18237:::
18238::::
18239
18240::extension{type=com.atlassian.confluence.macro.core key=toc}";
18241 let adf = markdown_to_adf(md).unwrap();
18242 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18243 assert_eq!(
18244 types,
18245 vec!["table", "extension"],
18246 "Extension after table was dropped: got {types:?}"
18247 );
18248 }
18249
18250 #[test]
18251 fn multiple_blocks_after_directive_table() {
18252 let md = "\
18254## Heading 1
18255
18256::::table{layout=default}
18257:::tr
18258:::td{}
18259A
18260:::
18261:::td{}
18262B
18263:::
18264:::
18265::::
18266
18267## Heading 2
18268
18269Some text.
18270
18271---
18272
18273::::table{layout=default}
18274:::tr
18275:::th{}
18276C
18277:::
18278:::
18279::::
18280
18281## Heading 3";
18282 let adf = markdown_to_adf(md).unwrap();
18283 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18284 assert_eq!(
18285 types,
18286 vec![
18287 "heading",
18288 "table",
18289 "heading",
18290 "paragraph",
18291 "rule",
18292 "table",
18293 "heading"
18294 ],
18295 "Content after tables was dropped: got {types:?}"
18296 );
18297 }
18298
18299 #[test]
18302 fn adf_table_caption_to_markdown() {
18303 let doc = AdfDocument {
18304 version: 1,
18305 doc_type: "doc".to_string(),
18306 content: vec![AdfNode::table(vec![
18307 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18308 AdfNode::text("cell"),
18309 ])])]),
18310 AdfNode::caption(vec![AdfNode::text("Table caption")]),
18311 ])],
18312 };
18313 let md = adf_to_markdown(&doc).unwrap();
18314 assert!(
18315 md.contains("::::table"),
18316 "table with caption must use directive form"
18317 );
18318 assert!(
18319 md.contains(":::caption"),
18320 "caption directive missing, got: {md}"
18321 );
18322 assert!(
18323 md.contains("Table caption"),
18324 "caption text missing, got: {md}"
18325 );
18326 }
18327
18328 #[test]
18329 fn directive_table_caption_parses() {
18330 let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nTable caption\n:::\n::::\n";
18331 let doc = markdown_to_adf(md).unwrap();
18332 let table = &doc.content[0];
18333 assert_eq!(table.node_type, "table");
18334 let children = table.content.as_ref().unwrap();
18335 assert_eq!(children.len(), 2, "expected row + caption");
18336 assert_eq!(children[0].node_type, "tableRow");
18337 assert_eq!(children[1].node_type, "caption");
18338 let caption_content = children[1].content.as_ref().unwrap();
18339 assert_eq!(caption_content[0].text.as_deref(), Some("Table caption"));
18340 }
18341
18342 #[test]
18343 fn table_caption_round_trip_from_adf_json() {
18344 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18345 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18346 {"type":"caption","content":[{"type":"text","text":"Table caption"}]}
18347 ]}]}"#;
18348 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18349 let md = adf_to_markdown(&doc).unwrap();
18350 assert!(md.contains("Table caption"), "caption text lost in ADF→JFM");
18351 let round_tripped = markdown_to_adf(&md).unwrap();
18352 let children = round_tripped.content[0].content.as_ref().unwrap();
18353 let caption = children.iter().find(|n| n.node_type == "caption");
18354 assert!(caption.is_some(), "caption lost on round-trip");
18355 let caption_text = caption.unwrap().content.as_ref().unwrap();
18356 assert_eq!(caption_text[0].text.as_deref(), Some("Table caption"));
18357 }
18358
18359 #[test]
18360 fn table_caption_with_inline_marks_round_trips() {
18361 let doc = AdfDocument {
18362 version: 1,
18363 doc_type: "doc".to_string(),
18364 content: vec![AdfNode::table(vec![
18365 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18366 AdfNode::text("data"),
18367 ])])]),
18368 AdfNode::caption(vec![
18369 AdfNode::text("Caption with "),
18370 AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
18371 ]),
18372 ])],
18373 };
18374 let md = adf_to_markdown(&doc).unwrap();
18375 assert!(md.contains("**bold**"), "bold mark missing in caption");
18376 let round_tripped = markdown_to_adf(&md).unwrap();
18377 let caption = round_tripped.content[0]
18378 .content
18379 .as_ref()
18380 .unwrap()
18381 .iter()
18382 .find(|n| n.node_type == "caption")
18383 .expect("caption node missing after round-trip");
18384 let inlines = caption.content.as_ref().unwrap();
18385 let bold_node = inlines.iter().find(|n| {
18386 n.marks
18387 .as_ref()
18388 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
18389 });
18390 assert!(bold_node.is_some(), "bold mark lost in caption round-trip");
18391 }
18392
18393 #[test]
18396 fn table_caption_localid_roundtrip() {
18397 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18398 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18399 {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Table with localId"}]}
18400 ]}]}"#;
18401 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18402 let md = adf_to_markdown(&doc).unwrap();
18403 assert!(
18404 md.contains("localId=abcdef123456"),
18405 "table caption localId should appear in markdown: {md}"
18406 );
18407 let rt = markdown_to_adf(&md).unwrap();
18408 let caption = rt.content[0]
18409 .content
18410 .as_ref()
18411 .unwrap()
18412 .iter()
18413 .find(|n| n.node_type == "caption")
18414 .expect("caption should survive round-trip");
18415 assert_eq!(
18416 caption.attrs.as_ref().unwrap()["localId"],
18417 "abcdef123456",
18418 "table caption localId should round-trip"
18419 );
18420 }
18421
18422 #[test]
18423 fn table_caption_without_localid_unchanged() {
18424 let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nPlain caption\n:::\n::::\n";
18425 let doc = markdown_to_adf(md).unwrap();
18426 let caption = doc.content[0]
18427 .content
18428 .as_ref()
18429 .unwrap()
18430 .iter()
18431 .find(|n| n.node_type == "caption")
18432 .unwrap();
18433 assert!(
18434 caption.attrs.is_none(),
18435 "table caption without localId should not gain attrs"
18436 );
18437 let md2 = adf_to_markdown(&doc).unwrap();
18438 assert!(!md2.contains("localId"), "no localId should appear: {md2}");
18439 }
18440
18441 #[test]
18442 fn table_caption_localid_stripped_when_option_set() {
18443 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18444 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18445 {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Stripped"}]}
18446 ]}]}"#;
18447 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18448 let opts = RenderOptions {
18449 strip_local_ids: true,
18450 ..Default::default()
18451 };
18452 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18453 assert!(
18454 !md.contains("localId"),
18455 "table caption localId should be stripped: {md}"
18456 );
18457 }
18458
18459 #[test]
18460 #[test]
18461 fn tablecell_empty_attrs_preserved_on_roundtrip() {
18462 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"}]}]}]}]}]}"#;
18464 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18465 let md = adf_to_markdown(&doc).unwrap();
18466 let round_tripped = markdown_to_adf(&md).unwrap();
18467 let rows = round_tripped.content[0].content.as_ref().unwrap();
18468 let cell = &rows[0].content.as_ref().unwrap()[0];
18469 assert!(
18470 cell.attrs.is_some(),
18471 "tableCell attrs should be preserved, got None"
18472 );
18473 assert_eq!(
18474 cell.attrs.as_ref().unwrap(),
18475 &serde_json::json!({}),
18476 "tableCell attrs should be an empty object"
18477 );
18478 }
18479
18480 #[test]
18481 fn tablecell_empty_attrs_serialized_in_json() {
18482 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"}]}]}]}]}]}"#;
18484 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18485 let md = adf_to_markdown(&doc).unwrap();
18486 let round_tripped = markdown_to_adf(&md).unwrap();
18487 let json = serde_json::to_string(&round_tripped).unwrap();
18488 assert!(
18489 json.contains(r#""attrs":{}"#),
18490 "serialized JSON should contain \"attrs\":{{}}, got: {json}"
18491 );
18492 }
18493
18494 #[test]
18495 fn tablecell_empty_attrs_renders_braces_in_markdown() {
18496 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"}]}]}]}]}]}"#;
18498 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18499 let md = adf_to_markdown(&doc).unwrap();
18500 assert!(
18502 md.contains("{} hello"),
18503 "cell with empty attrs should render '{{}} hello', got: {md}"
18504 );
18505 assert!(
18506 !md.contains("{} world"),
18507 "cell without attrs should not render '{{}}', got: {md}"
18508 );
18509 }
18510
18511 #[test]
18512 fn tablecell_no_attrs_unchanged_on_roundtrip() {
18513 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"}]}]}]}]}]}"#;
18515 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18516 let md = adf_to_markdown(&doc).unwrap();
18517 let round_tripped = markdown_to_adf(&md).unwrap();
18518 let rows = round_tripped.content[0].content.as_ref().unwrap();
18519 let cell = &rows[0].content.as_ref().unwrap()[0];
18520 assert!(
18521 cell.attrs.is_none(),
18522 "tableCell without attrs should stay None, got: {:?}",
18523 cell.attrs
18524 );
18525 }
18526
18527 #[test]
18528 fn tablecell_nonempty_attrs_preserved_on_roundtrip() {
18529 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"}]}]}]}]}]}"##;
18531 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18532 let md = adf_to_markdown(&doc).unwrap();
18533 let round_tripped = markdown_to_adf(&md).unwrap();
18534 let rows = round_tripped.content[0].content.as_ref().unwrap();
18535 let cell = &rows[1].content.as_ref().unwrap()[0];
18536 let attrs = cell.attrs.as_ref().unwrap();
18537 assert_eq!(attrs["background"], "#DEEBFF");
18538 assert_eq!(attrs["colspan"], 2);
18539 }
18540
18541 #[test]
18542 fn pipe_table_not_used_when_caption_present() {
18543 let doc = AdfDocument {
18544 version: 1,
18545 doc_type: "doc".to_string(),
18546 content: vec![AdfNode::table(vec![
18547 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
18548 AdfNode::text("H"),
18549 ])])]),
18550 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18551 AdfNode::text("D"),
18552 ])])]),
18553 AdfNode::caption(vec![AdfNode::text("cap")]),
18554 ])],
18555 };
18556 let md = adf_to_markdown(&doc).unwrap();
18557 assert!(
18558 md.contains("::::table"),
18559 "pipe syntax should not be used when caption is present"
18560 );
18561 }
18562
18563 #[test]
18566 fn hardbreak_with_ordered_marker_in_bullet_item_roundtrips() {
18567 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18570 {"type":"listItem","content":[{"type":"paragraph","content":[
18571 {"type":"text","text":"1. First item"},
18572 {"type":"hardBreak"},
18573 {"type":"text","text":"2. Honouring existing commitments"}
18574 ]}]}
18575 ]}]}"#;
18576 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18577 let md = adf_to_markdown(&doc).unwrap();
18578
18579 assert!(
18581 md.contains(" 2. Honouring"),
18582 "Continuation line should be indented, got:\n{md}"
18583 );
18584
18585 let rt = markdown_to_adf(&md).unwrap();
18587 let list = &rt.content[0];
18588 assert_eq!(list.node_type, "bulletList");
18589 let items = list.content.as_ref().unwrap();
18590 assert_eq!(
18591 items.len(),
18592 1,
18593 "Should be one list item, got {}",
18594 items.len()
18595 );
18596
18597 let para = &items[0].content.as_ref().unwrap()[0];
18598 let inlines = para.content.as_ref().unwrap();
18599 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18600 assert_eq!(
18601 types,
18602 vec!["text", "hardBreak", "text"],
18603 "Expected text+hardBreak+text, got {types:?}"
18604 );
18605 assert_eq!(
18606 inlines[2].text.as_deref().unwrap(),
18607 "2. Honouring existing commitments"
18608 );
18609 }
18610
18611 #[test]
18612 fn hardbreak_with_ordered_marker_in_ordered_item_roundtrips() {
18613 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18615 {"type":"listItem","content":[{"type":"paragraph","content":[
18616 {"type":"text","text":"Introduction "},
18617 {"type":"hardBreak"},
18618 {"type":"text","text":"3. Third point"}
18619 ]}]}
18620 ]}]}"#;
18621 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18622 let md = adf_to_markdown(&doc).unwrap();
18623 let rt = markdown_to_adf(&md).unwrap();
18624
18625 let list = &rt.content[0];
18626 assert_eq!(list.node_type, "orderedList");
18627 let items = list.content.as_ref().unwrap();
18628 assert_eq!(items.len(), 1);
18629
18630 let para = &items[0].content.as_ref().unwrap()[0];
18631 let inlines = para.content.as_ref().unwrap();
18632 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18633 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18634 assert_eq!(inlines[2].text.as_deref().unwrap(), "3. Third point");
18635 }
18636
18637 #[test]
18638 fn hardbreak_with_bullet_marker_in_bullet_item_roundtrips() {
18639 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18641 {"type":"listItem","content":[{"type":"paragraph","content":[
18642 {"type":"text","text":"Header "},
18643 {"type":"hardBreak"},
18644 {"type":"text","text":"- not a sub-item"}
18645 ]}]}
18646 ]}]}"#;
18647 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18648 let md = adf_to_markdown(&doc).unwrap();
18649 let rt = markdown_to_adf(&md).unwrap();
18650
18651 let list = &rt.content[0];
18652 assert_eq!(list.node_type, "bulletList");
18653 let items = list.content.as_ref().unwrap();
18654 assert_eq!(
18655 items.len(),
18656 1,
18657 "Should be one list item, not {}",
18658 items.len()
18659 );
18660
18661 let para = &items[0].content.as_ref().unwrap()[0];
18662 let inlines = para.content.as_ref().unwrap();
18663 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18664 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18665 assert_eq!(inlines[2].text.as_deref().unwrap(), "- not a sub-item");
18666 }
18667
18668 #[test]
18669 fn hardbreak_continuation_followed_by_sub_list() {
18670 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18672 {"type":"listItem","content":[
18673 {"type":"paragraph","content":[
18674 {"type":"text","text":"Main item "},
18675 {"type":"hardBreak"},
18676 {"type":"text","text":"continued here"}
18677 ]},
18678 {"type":"bulletList","content":[
18679 {"type":"listItem","content":[{"type":"paragraph","content":[
18680 {"type":"text","text":"sub-item"}
18681 ]}]}
18682 ]}
18683 ]}
18684 ]}]}"#;
18685 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18686 let md = adf_to_markdown(&doc).unwrap();
18687 let rt = markdown_to_adf(&md).unwrap();
18688
18689 let list = &rt.content[0];
18690 let items = list.content.as_ref().unwrap();
18691 assert_eq!(items.len(), 1);
18692
18693 let item_content = items[0].content.as_ref().unwrap();
18694 assert_eq!(item_content.len(), 2, "Expected paragraph + nested list");
18695 assert_eq!(item_content[0].node_type, "paragraph");
18696 assert_eq!(item_content[1].node_type, "bulletList");
18697
18698 let inlines = item_content[0].content.as_ref().unwrap();
18700 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18701 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18702 }
18703
18704 #[test]
18705 fn multiple_hardbreaks_with_numbered_text_roundtrip() {
18706 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18708 {"type":"listItem","content":[{"type":"paragraph","content":[
18709 {"type":"text","text":"Preamble "},
18710 {"type":"hardBreak"},
18711 {"type":"text","text":"1. Alpha "},
18712 {"type":"hardBreak"},
18713 {"type":"text","text":"2. Bravo"}
18714 ]}]}
18715 ]}]}"#;
18716 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18717 let md = adf_to_markdown(&doc).unwrap();
18718 let rt = markdown_to_adf(&md).unwrap();
18719
18720 let items = rt.content[0].content.as_ref().unwrap();
18721 assert_eq!(items.len(), 1);
18722
18723 let inlines = items[0].content.as_ref().unwrap()[0]
18724 .content
18725 .as_ref()
18726 .unwrap();
18727 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18728 assert_eq!(
18729 types,
18730 vec!["text", "hardBreak", "text", "hardBreak", "text"]
18731 );
18732 }
18733
18734 #[test]
18735 fn trailing_hardbreak_in_bullet_item_roundtrips() {
18736 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18740 {"type":"listItem","content":[{"type":"paragraph","content":[
18741 {"type":"text","text":"ends with break"},
18742 {"type":"hardBreak"}
18743 ]}]}
18744 ]}]}"#;
18745 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18746 let md = adf_to_markdown(&doc).unwrap();
18747 let rt = markdown_to_adf(&md).unwrap();
18748
18749 let list = &rt.content[0];
18750 assert_eq!(list.node_type, "bulletList");
18751 let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18752 .content
18753 .as_ref()
18754 .unwrap();
18755 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18756 assert_eq!(types, vec!["text", "hardBreak"]);
18757 }
18758
18759 #[test]
18760 fn trailing_hardbreak_in_ordered_item_roundtrips() {
18761 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18764 {"type":"listItem","content":[{"type":"paragraph","content":[
18765 {"type":"text","text":"ends with break"},
18766 {"type":"hardBreak"}
18767 ]}]}
18768 ]}]}"#;
18769 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18770 let md = adf_to_markdown(&doc).unwrap();
18771 let rt = markdown_to_adf(&md).unwrap();
18772
18773 let list = &rt.content[0];
18774 assert_eq!(list.node_type, "orderedList");
18775 let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18776 .content
18777 .as_ref()
18778 .unwrap();
18779 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18780 assert_eq!(types, vec!["text", "hardBreak"]);
18781 }
18782
18783 #[test]
18784 fn trailing_space_hardbreak_continuation_in_bullet_item() {
18785 let md = "- first line \n 2. continued\n";
18789 let doc = markdown_to_adf(md).unwrap();
18790
18791 let list = &doc.content[0];
18792 assert_eq!(list.node_type, "bulletList");
18793 let items = list.content.as_ref().unwrap();
18794 assert_eq!(
18795 items.len(),
18796 1,
18797 "Should be one list item, got {}",
18798 items.len()
18799 );
18800
18801 let para = &items[0].content.as_ref().unwrap()[0];
18802 let inlines = para.content.as_ref().unwrap();
18803 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18804 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18805 assert_eq!(inlines[2].text.as_deref().unwrap(), "2. continued");
18806 }
18807
18808 #[test]
18809 fn trailing_space_hardbreak_continuation_in_ordered_item() {
18810 let md = "1. first line \n - continued\n";
18813 let doc = markdown_to_adf(md).unwrap();
18814
18815 let list = &doc.content[0];
18816 assert_eq!(list.node_type, "orderedList");
18817 let items = list.content.as_ref().unwrap();
18818 assert_eq!(
18819 items.len(),
18820 1,
18821 "Should be one list item, got {}",
18822 items.len()
18823 );
18824
18825 let para = &items[0].content.as_ref().unwrap()[0];
18826 let inlines = para.content.as_ref().unwrap();
18827 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18828 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18829 assert_eq!(inlines[2].text.as_deref().unwrap(), "- continued");
18830 }
18831
18832 #[test]
18833 fn multi_paragraph_list_item_with_ordered_marker_roundtrips() {
18834 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18837 {"type":"listItem","content":[
18838 {"type":"paragraph","content":[{"type":"text","text":"some preamble"}]},
18839 {"type":"paragraph","content":[{"type":"text","text":"2. Honouring existing commitments"}]}
18840 ]}
18841 ]}]}"#;
18842 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18843 let md = adf_to_markdown(&doc).unwrap();
18844 let rt = markdown_to_adf(&md).unwrap();
18845
18846 assert_eq!(rt.content.len(), 1, "Should be one top-level block");
18847 let list = &rt.content[0];
18848 assert_eq!(list.node_type, "bulletList");
18849 let items = list.content.as_ref().unwrap();
18850 assert_eq!(items.len(), 1);
18851 let item_content = items[0].content.as_ref().unwrap();
18852 assert_eq!(
18853 item_content.len(),
18854 2,
18855 "Expected 2 paragraphs inside the list item, got {}",
18856 item_content.len()
18857 );
18858 assert_eq!(item_content[0].node_type, "paragraph");
18859 assert_eq!(item_content[1].node_type, "paragraph");
18860 let text = item_content[1].content.as_ref().unwrap()[0]
18861 .text
18862 .as_deref()
18863 .unwrap();
18864 assert_eq!(text, "2. Honouring existing commitments");
18865 }
18866
18867 #[test]
18868 fn multi_paragraph_list_item_with_bullet_marker_roundtrips() {
18869 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18871 {"type":"listItem","content":[
18872 {"type":"paragraph","content":[{"type":"text","text":"preamble"}]},
18873 {"type":"paragraph","content":[{"type":"text","text":"- not a sub-item"}]}
18874 ]}
18875 ]}]}"#;
18876 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18877 let md = adf_to_markdown(&doc).unwrap();
18878 let rt = markdown_to_adf(&md).unwrap();
18879
18880 let items = rt.content[0].content.as_ref().unwrap();
18881 assert_eq!(items.len(), 1);
18882 let item_content = items[0].content.as_ref().unwrap();
18883 assert_eq!(item_content.len(), 2);
18884 assert_eq!(item_content[1].node_type, "paragraph");
18885 let text = item_content[1].content.as_ref().unwrap()[0]
18886 .text
18887 .as_deref()
18888 .unwrap();
18889 assert_eq!(text, "- not a sub-item");
18890 }
18891
18892 #[test]
18893 fn backslash_escape_in_inline_text() {
18894 let nodes = parse_inline(r"2\. text");
18896 assert_eq!(nodes.len(), 1, "Should be one text node");
18897 assert_eq!(nodes[0].text.as_deref().unwrap(), "2. text");
18898 }
18899
18900 #[test]
18901 fn escape_list_marker_ordered() {
18902 assert_eq!(escape_list_marker("2. text"), r"2\. text");
18903 assert_eq!(escape_list_marker("10. tenth"), r"10\. tenth");
18904 }
18905
18906 #[test]
18907 fn escape_list_marker_bullet() {
18908 assert_eq!(escape_list_marker("- text"), r"\- text");
18909 assert_eq!(escape_list_marker("* text"), r"\* text");
18910 assert_eq!(escape_list_marker("+ text"), r"\+ text");
18911 }
18912
18913 #[test]
18914 fn escape_list_marker_plain() {
18915 assert_eq!(escape_list_marker("plain text"), "plain text");
18916 assert_eq!(escape_list_marker("no. marker"), "no. marker");
18917 }
18918
18919 #[test]
18920 fn escape_emoji_shortcodes_basic() {
18921 assert_eq!(escape_emoji_shortcodes(":fire:"), r"\:fire:");
18922 assert_eq!(
18923 escape_emoji_shortcodes("hello :wave: world"),
18924 r"hello \:wave: world"
18925 );
18926 }
18927
18928 #[test]
18929 fn escape_emoji_shortcodes_double_colon() {
18930 assert_eq!(
18932 escape_emoji_shortcodes("Status::Active::Running"),
18933 r"Status:\:Active::Running"
18934 );
18935 }
18936
18937 #[test]
18938 fn escape_emoji_shortcodes_no_match() {
18939 assert_eq!(escape_emoji_shortcodes("Time is 10:30"), "Time is 10:30");
18941 assert_eq!(escape_emoji_shortcodes("no colons here"), "no colons here");
18942 assert_eq!(escape_emoji_shortcodes("trailing:"), "trailing:");
18943 assert_eq!(escape_emoji_shortcodes(":"), ":");
18944 }
18945
18946 #[test]
18947 fn escape_emoji_shortcodes_mixed() {
18948 assert_eq!(
18949 escape_emoji_shortcodes("Alert :fire: on pod:pod42"),
18950 r"Alert \:fire: on pod:pod42"
18951 );
18952 }
18953
18954 #[test]
18955 fn escape_emoji_shortcodes_unicode() {
18956 assert_eq!(escape_emoji_shortcodes(":Café:"), r"\:Café:");
18961 assert_eq!(escape_emoji_shortcodes(":über:"), r"\:über:");
18962 assert_eq!(escape_emoji_shortcodes(":配置:"), r"\:配置:");
18963 assert_eq!(
18964 escape_emoji_shortcodes("ZBC::配置::Production"),
18965 r"ZBC:\:配置::Production"
18966 );
18967 }
18968
18969 #[test]
18970 fn escape_emoji_shortcodes_mixed_script_name() {
18971 assert_eq!(escape_emoji_shortcodes(":abc配置:"), r"\:abc配置:");
18974 assert_eq!(escape_emoji_shortcodes(":配置abc:"), r"\:配置abc:");
18975 }
18976
18977 #[test]
18978 fn escape_emoji_shortcodes_unicode_followed_by_non_colon() {
18979 assert_eq!(escape_emoji_shortcodes(":Café world:"), ":Café world:");
18984 }
18985
18986 #[test]
18987 fn escape_emoji_shortcodes_name_runs_to_end() {
18988 assert_eq!(escape_emoji_shortcodes(":abc"), ":abc");
18994 assert_eq!(escape_emoji_shortcodes(":配置"), ":配置");
18995 }
18996
18997 #[test]
18998 fn unicode_shortcode_pattern_text_round_trips_as_text() {
18999 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19002 {"type":"text","text":"Visit :Café: today"}
19003 ]}]}"#;
19004 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19005
19006 let md = adf_to_markdown(&doc).unwrap();
19007 let round_tripped = markdown_to_adf(&md).unwrap();
19008 let content = round_tripped.content[0].content.as_ref().unwrap();
19009
19010 assert_eq!(
19011 content.len(),
19012 1,
19013 "should be a single text node, got: {content:?}"
19014 );
19015 assert_eq!(content[0].node_type, "text");
19016 assert_eq!(content[0].text.as_deref().unwrap(), "Visit :Café: today");
19017 }
19018
19019 #[test]
19020 fn unicode_double_colon_pattern_text_round_trips() {
19021 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19023 {"type":"text","text":"Use ZBC::配置::Production for prod"}
19024 ]}]}"#;
19025 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19026
19027 let md = adf_to_markdown(&doc).unwrap();
19028 let round_tripped = markdown_to_adf(&md).unwrap();
19029 let content = round_tripped.content[0].content.as_ref().unwrap();
19030
19031 assert_eq!(
19032 content.len(),
19033 1,
19034 "should be a single text node, got: {content:?}"
19035 );
19036 assert_eq!(
19037 content[0].text.as_deref().unwrap(),
19038 "Use ZBC::配置::Production for prod"
19039 );
19040 }
19041
19042 #[test]
19043 fn merge_adjacent_text_nodes() {
19044 let mut nodes = vec![AdfNode::text("a"), AdfNode::text("b"), AdfNode::text("c")];
19045 merge_adjacent_text(&mut nodes);
19046 assert_eq!(nodes.len(), 1);
19047 assert_eq!(nodes[0].text.as_deref().unwrap(), "abc");
19048 }
19049
19050 #[test]
19053 fn issue_455_paragraph_hardbreak_ordered_marker_roundtrips() {
19054 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19057 {"type":"text","text":"Introduction: "},
19058 {"type":"hardBreak"},
19059 {"type":"text","text":"1. This text follows a hardBreak"}
19060 ]}]}"#;
19061 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19062 let md = adf_to_markdown(&doc).unwrap();
19063 let rt = markdown_to_adf(&md).unwrap();
19064
19065 assert_eq!(rt.content.len(), 1, "Should remain one block");
19066 assert_eq!(rt.content[0].node_type, "paragraph");
19067 let inlines = rt.content[0].content.as_ref().unwrap();
19068 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19069 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19070 assert_eq!(
19071 inlines[2].text.as_deref(),
19072 Some("1. This text follows a hardBreak")
19073 );
19074 }
19075
19076 #[test]
19077 fn issue_455_paragraph_hardbreak_bullet_marker_roundtrips() {
19078 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19080 {"type":"text","text":"Intro"},
19081 {"type":"hardBreak"},
19082 {"type":"text","text":"- not a list item"}
19083 ]}]}"#;
19084 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19085 let md = adf_to_markdown(&doc).unwrap();
19086 let rt = markdown_to_adf(&md).unwrap();
19087
19088 assert_eq!(rt.content.len(), 1);
19089 assert_eq!(rt.content[0].node_type, "paragraph");
19090 let inlines = rt.content[0].content.as_ref().unwrap();
19091 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19092 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19093 assert_eq!(inlines[2].text.as_deref(), Some("- not a list item"));
19094 }
19095
19096 #[test]
19097 fn issue_455_paragraph_hardbreak_heading_marker_roundtrips() {
19098 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19100 {"type":"text","text":"Intro"},
19101 {"type":"hardBreak"},
19102 {"type":"text","text":"# not a heading"}
19103 ]}]}"##;
19104 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19105 let md = adf_to_markdown(&doc).unwrap();
19106 let rt = markdown_to_adf(&md).unwrap();
19107
19108 assert_eq!(rt.content.len(), 1);
19109 assert_eq!(rt.content[0].node_type, "paragraph");
19110 let inlines = rt.content[0].content.as_ref().unwrap();
19111 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19112 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19113 assert_eq!(inlines[2].text.as_deref(), Some("# not a heading"));
19114 }
19115
19116 #[test]
19117 fn issue_455_paragraph_hardbreak_blockquote_marker_roundtrips() {
19118 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19120 {"type":"text","text":"Intro"},
19121 {"type":"hardBreak"},
19122 {"type":"text","text":"> not a blockquote"}
19123 ]}]}"#;
19124 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19125 let md = adf_to_markdown(&doc).unwrap();
19126 let rt = markdown_to_adf(&md).unwrap();
19127
19128 assert_eq!(rt.content.len(), 1);
19129 assert_eq!(rt.content[0].node_type, "paragraph");
19130 let inlines = rt.content[0].content.as_ref().unwrap();
19131 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19132 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19133 assert_eq!(inlines[2].text.as_deref(), Some("> not a blockquote"));
19134 }
19135
19136 #[test]
19137 fn issue_455_paragraph_multiple_hardbreaks_with_ordered_markers() {
19138 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19140 {"type":"text","text":"Preamble"},
19141 {"type":"hardBreak"},
19142 {"type":"text","text":"1. First"},
19143 {"type":"hardBreak"},
19144 {"type":"text","text":"2. Second"},
19145 {"type":"hardBreak"},
19146 {"type":"text","text":"3. Third"}
19147 ]}]}"#;
19148 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19149 let md = adf_to_markdown(&doc).unwrap();
19150 let rt = markdown_to_adf(&md).unwrap();
19151
19152 assert_eq!(rt.content.len(), 1);
19153 assert_eq!(rt.content[0].node_type, "paragraph");
19154 let inlines = rt.content[0].content.as_ref().unwrap();
19155 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19156 assert_eq!(
19157 types,
19158 vec![
19159 "text",
19160 "hardBreak",
19161 "text",
19162 "hardBreak",
19163 "text",
19164 "hardBreak",
19165 "text"
19166 ]
19167 );
19168 assert_eq!(inlines[2].text.as_deref(), Some("1. First"));
19169 assert_eq!(inlines[4].text.as_deref(), Some("2. Second"));
19170 assert_eq!(inlines[6].text.as_deref(), Some("3. Third"));
19171 }
19172
19173 #[test]
19174 fn issue_455_paragraph_hardbreak_jfm_indentation() {
19175 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19177 {"type":"text","text":"Intro"},
19178 {"type":"hardBreak"},
19179 {"type":"text","text":"1. continued"}
19180 ]}]}"#;
19181 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19182 let md = adf_to_markdown(&doc).unwrap();
19183 assert!(
19184 md.contains("Intro\\\n 1. continued"),
19185 "Continuation should be 2-space-indented, got: {md:?}"
19186 );
19187 }
19188
19189 #[test]
19190 fn issue_455_paragraph_hardbreak_from_jfm() {
19191 let md = "Intro\\\n 1. This is continuation text\n";
19194 let doc = markdown_to_adf(md).unwrap();
19195
19196 assert_eq!(doc.content.len(), 1);
19197 assert_eq!(doc.content[0].node_type, "paragraph");
19198 let inlines = doc.content[0].content.as_ref().unwrap();
19199 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19200 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19201 assert_eq!(
19202 inlines[2].text.as_deref(),
19203 Some("1. This is continuation text")
19204 );
19205 }
19206
19207 #[test]
19208 fn issue_455_paragraph_starts_with_ordered_marker_and_hardbreak() {
19209 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19213 {"type":"text","text":"1. Starting with a number"},
19214 {"type":"hardBreak"},
19215 {"type":"text","text":"continuation after break"}
19216 ]}]}"#;
19217 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19218 let md = adf_to_markdown(&doc).unwrap();
19219 assert!(
19221 md.contains(r"1\. Starting with a number"),
19222 "First line should have escaped list marker, got: {md:?}"
19223 );
19224 let rt = markdown_to_adf(&md).unwrap();
19225
19226 assert_eq!(rt.content.len(), 1);
19227 assert_eq!(rt.content[0].node_type, "paragraph");
19228 let inlines = rt.content[0].content.as_ref().unwrap();
19229 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19230 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19231 assert_eq!(
19232 inlines[0].text.as_deref(),
19233 Some("1. Starting with a number")
19234 );
19235 assert_eq!(inlines[2].text.as_deref(), Some("continuation after break"));
19236 }
19237
19238 #[test]
19239 fn ordered_marker_paragraph_in_table_cell_roundtrips() {
19240 let adf_json = r#"{"version":1,"type":"doc","content":[{
19243 "type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},
19244 "content":[{"type":"tableRow","content":[{
19245 "type":"tableCell","attrs":{"colspan":1,"rowspan":1},
19246 "content":[{"type":"paragraph","content":[
19247 {"type":"text","text":"2. Honouring existing commitments"}
19248 ]}]
19249 }]}]
19250 }]}"#;
19251 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19252 let md = adf_to_markdown(&doc).unwrap();
19253 let rt = markdown_to_adf(&md).unwrap();
19254
19255 let table = &rt.content[0];
19256 let cell = &table.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0];
19257 let para = &cell.content.as_ref().unwrap()[0];
19258 assert_eq!(para.node_type, "paragraph");
19259 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
19260 assert_eq!(text, "2. Honouring existing commitments");
19261 }
19262
19263 #[test]
19264 fn bullet_marker_paragraph_standalone_roundtrips() {
19265 let adf_json = r#"{"version":1,"type":"doc","content":[
19268 {"type":"paragraph","content":[
19269 {"type":"text","text":"- not a list item"}
19270 ]}
19271 ]}"#;
19272 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19273 let md = adf_to_markdown(&doc).unwrap();
19274 assert!(
19275 md.contains(r"\- not a list item"),
19276 "Should escape the leading dash, got:\n{md}"
19277 );
19278 let rt = markdown_to_adf(&md).unwrap();
19279 assert_eq!(rt.content[0].node_type, "paragraph");
19280 let text = rt.content[0].content.as_ref().unwrap()[0]
19281 .text
19282 .as_deref()
19283 .unwrap();
19284 assert_eq!(text, "- not a list item");
19285 }
19286
19287 #[test]
19288 fn merge_adjacent_text_skips_non_text_nodes() {
19289 let mut nodes = vec![
19292 AdfNode::text("a"),
19293 AdfNode::hard_break(),
19294 AdfNode::text("b"),
19295 ];
19296 merge_adjacent_text(&mut nodes);
19297 assert_eq!(nodes.len(), 3);
19298 }
19299
19300 #[test]
19301 fn star_bullet_paragraph_roundtrips() {
19302 let adf_json = r#"{"version":1,"type":"doc","content":[
19305 {"type":"paragraph","content":[
19306 {"type":"text","text":"* starred"}
19307 ]}
19308 ]}"#;
19309 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19310 let md = adf_to_markdown(&doc).unwrap();
19311 let rt = markdown_to_adf(&md).unwrap();
19312 assert_eq!(rt.content[0].node_type, "paragraph");
19313 assert_eq!(
19314 rt.content[0].content.as_ref().unwrap()[0]
19315 .text
19316 .as_deref()
19317 .unwrap(),
19318 "* starred"
19319 );
19320 }
19321
19322 #[test]
19325 fn issue_388_ordered_list_with_strong_hardbreak_roundtrips() {
19326 let adf_json = r#"{"version":1,"type":"doc","content":[
19329 {"type":"orderedList","attrs":{"order":1},"content":[
19330 {"type":"listItem","content":[
19331 {"type":"paragraph","content":[
19332 {"type":"text","text":"Bold heading","marks":[{"type":"strong"}]},
19333 {"type":"hardBreak"},
19334 {"type":"text","text":"Content after break"}
19335 ]}
19336 ]},
19337 {"type":"listItem","content":[
19338 {"type":"paragraph","content":[
19339 {"type":"text","text":"Second item","marks":[{"type":"strong"}]},
19340 {"type":"hardBreak"},
19341 {"type":"text","text":"More content"}
19342 ]}
19343 ]}
19344 ]}
19345 ]}"#;
19346 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19347 let md = adf_to_markdown(&doc).unwrap();
19348 let rt = markdown_to_adf(&md).unwrap();
19349
19350 assert_eq!(
19352 rt.content.len(),
19353 1,
19354 "Should be 1 block (orderedList), got {}",
19355 rt.content.len()
19356 );
19357 assert_eq!(rt.content[0].node_type, "orderedList");
19358 let items = rt.content[0].content.as_ref().unwrap();
19359 assert_eq!(
19360 items.len(),
19361 2,
19362 "Should have 2 listItems, got {}",
19363 items.len()
19364 );
19365
19366 let p1 = items[0].content.as_ref().unwrap()[0]
19368 .content
19369 .as_ref()
19370 .unwrap();
19371 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19372 assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19373 assert_eq!(p1[0].text.as_deref(), Some("Bold heading"));
19374 assert_eq!(p1[2].text.as_deref(), Some("Content after break"));
19375
19376 let p2 = items[1].content.as_ref().unwrap()[0]
19378 .content
19379 .as_ref()
19380 .unwrap();
19381 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19382 assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19383 assert_eq!(p2[0].text.as_deref(), Some("Second item"));
19384 assert_eq!(p2[2].text.as_deref(), Some("More content"));
19385 }
19386
19387 #[test]
19388 fn issue_388_bullet_list_with_strong_hardbreak_roundtrips() {
19389 let adf_json = r#"{"version":1,"type":"doc","content":[
19391 {"type":"bulletList","content":[
19392 {"type":"listItem","content":[
19393 {"type":"paragraph","content":[
19394 {"type":"text","text":"First","marks":[{"type":"strong"}]},
19395 {"type":"hardBreak"},
19396 {"type":"text","text":"details"}
19397 ]}
19398 ]},
19399 {"type":"listItem","content":[
19400 {"type":"paragraph","content":[
19401 {"type":"text","text":"Second","marks":[{"type":"em"}]},
19402 {"type":"hardBreak"},
19403 {"type":"text","text":"more details"}
19404 ]}
19405 ]}
19406 ]}
19407 ]}"#;
19408 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19409 let md = adf_to_markdown(&doc).unwrap();
19410 let rt = markdown_to_adf(&md).unwrap();
19411
19412 assert_eq!(rt.content.len(), 1);
19413 assert_eq!(rt.content[0].node_type, "bulletList");
19414 let items = rt.content[0].content.as_ref().unwrap();
19415 assert_eq!(items.len(), 2);
19416
19417 let p1 = items[0].content.as_ref().unwrap()[0]
19418 .content
19419 .as_ref()
19420 .unwrap();
19421 assert_eq!(p1[0].text.as_deref(), Some("First"));
19422 assert_eq!(p1[2].text.as_deref(), Some("details"));
19423
19424 let p2 = items[1].content.as_ref().unwrap()[0]
19425 .content
19426 .as_ref()
19427 .unwrap();
19428 assert_eq!(p2[0].text.as_deref(), Some("Second"));
19429 assert_eq!(p2[2].text.as_deref(), Some("more details"));
19430 }
19431
19432 #[test]
19433 fn issue_388_ordered_list_hardbreak_jfm_indentation() {
19434 let adf_json = r#"{"version":1,"type":"doc","content":[
19436 {"type":"orderedList","attrs":{"order":1},"content":[
19437 {"type":"listItem","content":[
19438 {"type":"paragraph","content":[
19439 {"type":"text","text":"heading","marks":[{"type":"strong"}]},
19440 {"type":"hardBreak"},
19441 {"type":"text","text":"body"}
19442 ]}
19443 ]}
19444 ]}
19445 ]}"#;
19446 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19447 let md = adf_to_markdown(&doc).unwrap();
19448 assert!(
19449 md.contains("1. **heading**\\\n body"),
19450 "Continuation should be indented, got:\n{md}"
19451 );
19452 }
19453
19454 #[test]
19455 fn issue_388_ordered_list_hardbreak_from_jfm() {
19456 let md = "1. **bold**\\\n continued\n2. **also bold**\\\n also continued\n";
19458 let doc = markdown_to_adf(md).unwrap();
19459
19460 assert_eq!(doc.content.len(), 1);
19461 assert_eq!(doc.content[0].node_type, "orderedList");
19462 let items = doc.content[0].content.as_ref().unwrap();
19463 assert_eq!(items.len(), 2);
19464
19465 let p1 = items[0].content.as_ref().unwrap()[0]
19466 .content
19467 .as_ref()
19468 .unwrap();
19469 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19470 assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19471 assert_eq!(p1[0].text.as_deref(), Some("bold"));
19472 assert_eq!(p1[2].text.as_deref(), Some("continued"));
19473
19474 let p2 = items[1].content.as_ref().unwrap()[0]
19475 .content
19476 .as_ref()
19477 .unwrap();
19478 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19479 assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19480 }
19481
19482 #[test]
19483 fn issue_388_bullet_list_hardbreak_from_jfm() {
19484 let md = "- first\\\n second\n- third\\\n fourth\n";
19486 let doc = markdown_to_adf(md).unwrap();
19487
19488 assert_eq!(doc.content.len(), 1);
19489 assert_eq!(doc.content[0].node_type, "bulletList");
19490 let items = doc.content[0].content.as_ref().unwrap();
19491 assert_eq!(items.len(), 2);
19492
19493 for (i, expected) in [("first", "second"), ("third", "fourth")]
19494 .iter()
19495 .enumerate()
19496 {
19497 let p = items[i].content.as_ref().unwrap()[0]
19498 .content
19499 .as_ref()
19500 .unwrap();
19501 let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19502 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19503 assert_eq!(p[0].text.as_deref(), Some(expected.0));
19504 assert_eq!(p[2].text.as_deref(), Some(expected.1));
19505 }
19506 }
19507
19508 #[test]
19509 fn issue_433_heading_hardbreak_roundtrips() {
19510 let adf_json = r#"{"version":1,"type":"doc","content":[{
19512 "type":"heading",
19513 "attrs":{"level":1},
19514 "content":[
19515 {"type":"text","text":"Line one"},
19516 {"type":"hardBreak"},
19517 {"type":"text","text":"Line two"}
19518 ]
19519 }]}"#;
19520 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19521 let md = adf_to_markdown(&doc).unwrap();
19522 let rt = markdown_to_adf(&md).unwrap();
19523
19524 assert_eq!(
19525 rt.content.len(),
19526 1,
19527 "Should remain a single heading, got {} blocks",
19528 rt.content.len()
19529 );
19530 assert_eq!(rt.content[0].node_type, "heading");
19531 let inlines = rt.content[0].content.as_ref().unwrap();
19532 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19533 assert_eq!(
19534 types,
19535 vec!["text", "hardBreak", "text"],
19536 "hardBreak should be preserved, got: {types:?}"
19537 );
19538 assert_eq!(inlines[0].text.as_deref(), Some("Line one"));
19539 assert_eq!(inlines[2].text.as_deref(), Some("Line two"));
19540 }
19541
19542 #[test]
19543 fn issue_433_heading_hardbreak_jfm_indentation() {
19544 let adf_json = r#"{"version":1,"type":"doc","content":[{
19546 "type":"heading",
19547 "attrs":{"level":2},
19548 "content":[
19549 {"type":"text","text":"Title"},
19550 {"type":"hardBreak"},
19551 {"type":"text","text":"Subtitle"}
19552 ]
19553 }]}"#;
19554 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19555 let md = adf_to_markdown(&doc).unwrap();
19556 assert!(
19557 md.contains("## Title\\\n Subtitle"),
19558 "Continuation should be indented, got:\n{md}"
19559 );
19560 }
19561
19562 #[test]
19563 fn issue_433_heading_hardbreak_from_jfm() {
19564 let md = "# First\\\n Second\n";
19566 let doc = markdown_to_adf(md).unwrap();
19567
19568 assert_eq!(doc.content.len(), 1);
19569 assert_eq!(doc.content[0].node_type, "heading");
19570 let inlines = doc.content[0].content.as_ref().unwrap();
19571 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19572 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19573 assert_eq!(inlines[0].text.as_deref(), Some("First"));
19574 assert_eq!(inlines[2].text.as_deref(), Some("Second"));
19575 }
19576
19577 #[test]
19578 fn issue_433_heading_consecutive_hardbreaks_roundtrip() {
19579 let adf_json = r#"{"version":1,"type":"doc","content":[{
19581 "type":"heading",
19582 "attrs":{"level":3},
19583 "content":[
19584 {"type":"text","text":"A"},
19585 {"type":"hardBreak"},
19586 {"type":"hardBreak"},
19587 {"type":"text","text":"B"}
19588 ]
19589 }]}"#;
19590 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19591 let md = adf_to_markdown(&doc).unwrap();
19592 let rt = markdown_to_adf(&md).unwrap();
19593
19594 assert_eq!(rt.content.len(), 1, "Should remain a single heading");
19595 assert_eq!(rt.content[0].node_type, "heading");
19596 let inlines = rt.content[0].content.as_ref().unwrap();
19597 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19598 assert_eq!(types, vec!["text", "hardBreak", "hardBreak", "text"]);
19599 }
19600
19601 #[test]
19602 fn issue_433_heading_with_strong_and_hardbreak_roundtrips() {
19603 let adf_json = r#"{"version":1,"type":"doc","content":[{
19605 "type":"heading",
19606 "attrs":{"level":1},
19607 "content":[
19608 {"type":"text","text":"Bold title","marks":[{"type":"strong"}]},
19609 {"type":"hardBreak"},
19610 {"type":"text","text":"plain continuation"}
19611 ]
19612 }]}"#;
19613 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19614 let md = adf_to_markdown(&doc).unwrap();
19615 let rt = markdown_to_adf(&md).unwrap();
19616
19617 assert_eq!(rt.content.len(), 1);
19618 assert_eq!(rt.content[0].node_type, "heading");
19619 let inlines = rt.content[0].content.as_ref().unwrap();
19620 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19621 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19622 assert_eq!(inlines[0].text.as_deref(), Some("Bold title"));
19623 assert_eq!(inlines[2].text.as_deref(), Some("plain continuation"));
19624 }
19625
19626 #[test]
19627 fn issue_433_heading_with_link_and_hardbreak_roundtrips() {
19628 let adf_json = r#"{"version":1,"type":"doc","content":[{
19630 "type":"heading",
19631 "attrs":{"level":1},
19632 "content":[
19633 {"type":"text","text":"Click here","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
19634 {"type":"hardBreak"},
19635 {"type":"text","text":"Subtitle text"}
19636 ]
19637 }]}"#;
19638 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19639 let md = adf_to_markdown(&doc).unwrap();
19640 let rt = markdown_to_adf(&md).unwrap();
19641
19642 assert_eq!(rt.content.len(), 1);
19643 assert_eq!(rt.content[0].node_type, "heading");
19644 let inlines = rt.content[0].content.as_ref().unwrap();
19645 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19646 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19647 assert_eq!(inlines[2].text.as_deref(), Some("Subtitle text"));
19648 }
19649
19650 #[test]
19651 fn has_trailing_hard_break_backslash() {
19652 assert!(has_trailing_hard_break("text\\"));
19653 assert!(has_trailing_hard_break("**bold**\\"));
19654 }
19655
19656 #[test]
19657 fn has_trailing_hard_break_trailing_spaces() {
19658 assert!(has_trailing_hard_break("text "));
19659 assert!(has_trailing_hard_break("word "));
19660 }
19661
19662 #[test]
19663 fn has_trailing_hard_break_false() {
19664 assert!(!has_trailing_hard_break("plain text"));
19665 assert!(!has_trailing_hard_break("text "));
19666 assert!(!has_trailing_hard_break(""));
19667 }
19668
19669 #[test]
19670 fn collect_hardbreak_continuations_collects_indented() {
19671 let input = "first\\\n second\n third\n";
19674 let mut parser = MarkdownParser::new(input);
19675 parser.advance(); let mut text = "first\\".to_string();
19677 parser.collect_hardbreak_continuations(&mut text);
19678 assert_eq!(text, "first\\\nsecond");
19679 }
19680
19681 #[test]
19682 fn collect_hardbreak_continuations_stops_at_non_indented() {
19683 let input = "first\\\nnot indented\n";
19684 let mut parser = MarkdownParser::new(input);
19685 parser.advance();
19686 let mut text = "first\\".to_string();
19687 parser.collect_hardbreak_continuations(&mut text);
19688 assert_eq!(text, "first\\");
19690 }
19691
19692 #[test]
19693 fn collect_hardbreak_continuations_no_trailing_break() {
19694 let input = "plain\n indented\n";
19696 let mut parser = MarkdownParser::new(input);
19697 parser.advance();
19698 let mut text = "plain".to_string();
19699 parser.collect_hardbreak_continuations(&mut text);
19700 assert_eq!(text, "plain");
19701 }
19702
19703 #[test]
19704 fn collect_hardbreak_continuations_chained() {
19705 let input = "a\\\n b\\\n c\\\n d\n";
19707 let mut parser = MarkdownParser::new(input);
19708 parser.advance();
19709 let mut text = "a\\".to_string();
19710 parser.collect_hardbreak_continuations(&mut text);
19711 assert_eq!(text, "a\\\nb\\\nc\\\nd");
19712 }
19713
19714 #[test]
19715 fn collect_hardbreak_continuations_stops_before_image_line() {
19716 let input = "text\\\n {type=file id=x}\n";
19719 let mut parser = MarkdownParser::new(input);
19720 parser.advance(); let mut text = "text\\".to_string();
19722 parser.collect_hardbreak_continuations(&mut text);
19723 assert_eq!(text, "text\\");
19725 assert!(!parser.at_end());
19727 assert!(parser.current_line().contains(""));
19728 }
19729
19730 #[test]
19731 fn is_block_level_continuation_marker_positive_cases() {
19732 assert!(is_block_level_continuation_marker(""));
19734 assert!(is_block_level_continuation_marker("```ruby"));
19735 assert!(is_block_level_continuation_marker(":::panel{type=info}"));
19736 }
19737
19738 #[test]
19739 fn is_block_level_continuation_marker_negative_cases() {
19740 assert!(!is_block_level_continuation_marker("plain text"));
19742 assert!(!is_block_level_continuation_marker("- nested item"));
19743 assert!(!is_block_level_continuation_marker("continuation\\"));
19744 assert!(!is_block_level_continuation_marker(""));
19745 assert!(!is_block_level_continuation_marker("::partial"));
19747 assert!(!is_block_level_continuation_marker("`inline`"));
19749 }
19750
19751 #[test]
19752 fn collect_hardbreak_continuations_stops_before_code_fence() {
19753 let input = "text\\\n ```ruby\n Foo::Bar::Baz\n ```\n";
19757 let mut parser = MarkdownParser::new(input);
19758 parser.advance();
19759 let mut text = "text\\".to_string();
19760 parser.collect_hardbreak_continuations(&mut text);
19761 assert_eq!(text, "text\\");
19762 assert!(!parser.at_end());
19763 assert!(parser.current_line().starts_with(" ```"));
19764 }
19765
19766 #[test]
19767 fn collect_hardbreak_continuations_stops_before_container_directive() {
19768 let input = "text\\\n :::panel{type=info}\n body\n :::\n";
19772 let mut parser = MarkdownParser::new(input);
19773 parser.advance();
19774 let mut text = "text\\".to_string();
19775 parser.collect_hardbreak_continuations(&mut text);
19776 assert_eq!(text, "text\\");
19777 assert!(!parser.at_end());
19778 assert!(parser.current_line().contains(":::panel"));
19779 }
19780
19781 #[test]
19782 fn collect_hardbreak_continuations_stops_before_indented_code_fence() {
19783 let input = "text\\\n ```text\n :fire:\n ```\n";
19787 let mut parser = MarkdownParser::new(input);
19788 parser.advance();
19789 let mut text = "text\\".to_string();
19790 parser.collect_hardbreak_continuations(&mut text);
19791 assert_eq!(text, "text\\");
19792 assert!(!parser.at_end());
19793 assert!(parser.current_line().contains("```text"));
19794 }
19795
19796 #[test]
19797 fn ordered_list_with_sub_content_after_hardbreak() {
19798 let adf_json = r#"{"version":1,"type":"doc","content":[
19801 {"type":"orderedList","attrs":{"order":1},"content":[
19802 {"type":"listItem","content":[
19803 {"type":"paragraph","content":[
19804 {"type":"text","text":"parent"},
19805 {"type":"hardBreak"},
19806 {"type":"text","text":"continued"}
19807 ]},
19808 {"type":"bulletList","content":[
19809 {"type":"listItem","content":[
19810 {"type":"paragraph","content":[
19811 {"type":"text","text":"child"}
19812 ]}
19813 ]}
19814 ]}
19815 ]}
19816 ]}
19817 ]}"#;
19818 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19819 let md = adf_to_markdown(&doc).unwrap();
19820 let rt = markdown_to_adf(&md).unwrap();
19821
19822 assert_eq!(rt.content.len(), 1);
19823 assert_eq!(rt.content[0].node_type, "orderedList");
19824 let item_content = rt.content[0].content.as_ref().unwrap()[0]
19825 .content
19826 .as_ref()
19827 .unwrap();
19828 let p = item_content[0].content.as_ref().unwrap();
19830 let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19831 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19832 assert_eq!(p[0].text.as_deref(), Some("parent"));
19833 assert_eq!(p[2].text.as_deref(), Some("continued"));
19834 assert_eq!(item_content[1].node_type, "bulletList");
19836 }
19837
19838 #[test]
19839 fn render_list_item_content_no_content() {
19840 let item = AdfNode {
19842 node_type: "listItem".to_string(),
19843 attrs: None,
19844 content: None,
19845 text: None,
19846 marks: None,
19847 local_id: None,
19848 parameters: None,
19849 };
19850 let mut output = String::new();
19851 let opts = RenderOptions::default();
19852 render_list_item_content(&item, &mut output, &opts);
19853 assert_eq!(output, "\n");
19854 }
19855
19856 #[test]
19857 fn render_list_item_content_empty_content() {
19858 let item = AdfNode::list_item(vec![]);
19860 let mut output = String::new();
19861 let opts = RenderOptions::default();
19862 render_list_item_content(&item, &mut output, &opts);
19863 assert_eq!(output, "\n");
19864 }
19865
19866 #[test]
19867 fn plus_bullet_paragraph_roundtrips() {
19868 let adf_json = r#"{"version":1,"type":"doc","content":[
19871 {"type":"paragraph","content":[
19872 {"type":"text","text":"+ plus"}
19873 ]}
19874 ]}"#;
19875 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19876 let md = adf_to_markdown(&doc).unwrap();
19877 let rt = markdown_to_adf(&md).unwrap();
19878 assert_eq!(rt.content[0].node_type, "paragraph");
19879 assert_eq!(
19880 rt.content[0].content.as_ref().unwrap()[0]
19881 .text
19882 .as_deref()
19883 .unwrap(),
19884 "+ plus"
19885 );
19886 }
19887
19888 #[test]
19891 fn issue_430_file_media_in_bullet_list_roundtrip() {
19892 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19895 {"type":"listItem","content":[{
19896 "type":"mediaSingle",
19897 "attrs":{"layout":"center","width":1009,"widthType":"pixel"},
19898 "content":[{
19899 "type":"media",
19900 "attrs":{"collection":"contentId-123","height":576,"id":"00066e8e-554e-4d7e-af59-a0ef2888bdb6","type":"file","width":1009}
19901 }]
19902 }]}
19903 ]}]}"#;
19904 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19905 let md = adf_to_markdown(&doc).unwrap();
19906 let rt = markdown_to_adf(&md).unwrap();
19907
19908 let list = &rt.content[0];
19909 assert_eq!(list.node_type, "bulletList");
19910 let item = &list.content.as_ref().unwrap()[0];
19911 assert_eq!(item.node_type, "listItem");
19912 let ms = &item.content.as_ref().unwrap()[0];
19913 assert_eq!(ms.node_type, "mediaSingle");
19914 let ms_attrs = ms.attrs.as_ref().unwrap();
19915 assert_eq!(ms_attrs["layout"], "center");
19916 assert_eq!(ms_attrs["width"], 1009);
19917 assert_eq!(ms_attrs["widthType"], "pixel");
19918 let media = &ms.content.as_ref().unwrap()[0];
19919 assert_eq!(media.node_type, "media");
19920 let m_attrs = media.attrs.as_ref().unwrap();
19921 assert_eq!(m_attrs["type"], "file");
19922 assert_eq!(m_attrs["id"], "00066e8e-554e-4d7e-af59-a0ef2888bdb6");
19923 assert_eq!(m_attrs["collection"], "contentId-123");
19924 assert_eq!(m_attrs["height"], 576);
19925 assert_eq!(m_attrs["width"], 1009);
19926 }
19927
19928 #[test]
19929 fn issue_430_file_media_in_ordered_list_roundtrip() {
19930 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19932 {"type":"listItem","content":[{
19933 "type":"mediaSingle",
19934 "attrs":{"layout":"center"},
19935 "content":[{
19936 "type":"media",
19937 "attrs":{"type":"file","id":"abc-123","collection":"contentId-456","height":100,"width":200}
19938 }]
19939 }]}
19940 ]}]}"#;
19941 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19942 let md = adf_to_markdown(&doc).unwrap();
19943 let rt = markdown_to_adf(&md).unwrap();
19944
19945 let list = &rt.content[0];
19946 assert_eq!(list.node_type, "orderedList");
19947 let item = &list.content.as_ref().unwrap()[0];
19948 assert_eq!(item.node_type, "listItem");
19949 let ms = &item.content.as_ref().unwrap()[0];
19950 assert_eq!(ms.node_type, "mediaSingle");
19951 let media = &ms.content.as_ref().unwrap()[0];
19952 assert_eq!(media.node_type, "media");
19953 let m_attrs = media.attrs.as_ref().unwrap();
19954 assert_eq!(m_attrs["type"], "file");
19955 assert_eq!(m_attrs["id"], "abc-123");
19956 assert_eq!(m_attrs["collection"], "contentId-456");
19957 }
19958
19959 #[test]
19960 fn issue_430_external_media_in_bullet_list_roundtrip() {
19961 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19963 {"type":"listItem","content":[{
19964 "type":"mediaSingle",
19965 "attrs":{"layout":"center"},
19966 "content":[{
19967 "type":"media",
19968 "attrs":{"type":"external","url":"https://example.com/img.png","alt":"Photo"}
19969 }]
19970 }]}
19971 ]}]}"#;
19972 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19973 let md = adf_to_markdown(&doc).unwrap();
19974 let rt = markdown_to_adf(&md).unwrap();
19975
19976 let list = &rt.content[0];
19977 assert_eq!(list.node_type, "bulletList");
19978 let item = &list.content.as_ref().unwrap()[0];
19979 let ms = &item.content.as_ref().unwrap()[0];
19980 assert_eq!(ms.node_type, "mediaSingle");
19981 let media = &ms.content.as_ref().unwrap()[0];
19982 assert_eq!(media.node_type, "media");
19983 let m_attrs = media.attrs.as_ref().unwrap();
19984 assert_eq!(m_attrs["type"], "external");
19985 assert_eq!(m_attrs["url"], "https://example.com/img.png");
19986 }
19987
19988 #[test]
19989 fn issue_430_media_with_paragraph_siblings_in_list_item() {
19990 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19993 {"type":"listItem","content":[
19994 {"type":"paragraph","content":[{"type":"text","text":"Caption:"}]},
19995 {"type":"mediaSingle","attrs":{"layout":"center"},
19996 "content":[{"type":"media","attrs":{"type":"file","id":"img-001","collection":"col-1","height":50,"width":100}}]}
19997 ]}
19998 ]}]}"#;
19999 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20000 let md = adf_to_markdown(&doc).unwrap();
20001 let rt = markdown_to_adf(&md).unwrap();
20002
20003 let item = &rt.content[0].content.as_ref().unwrap()[0];
20004 let children = item.content.as_ref().unwrap();
20005 assert_eq!(children.len(), 2, "expected 2 children in listItem");
20006 assert_eq!(children[0].node_type, "paragraph");
20007 assert_eq!(children[1].node_type, "mediaSingle");
20008 let media = &children[1].content.as_ref().unwrap()[0];
20009 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-001");
20010 }
20011
20012 #[test]
20013 fn issue_430_multiple_media_in_list_items() {
20014 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20016 {"type":"listItem","content":[{
20017 "type":"mediaSingle","attrs":{"layout":"center"},
20018 "content":[{"type":"media","attrs":{"type":"file","id":"img-a","collection":"c1","height":10,"width":20}}]
20019 }]},
20020 {"type":"listItem","content":[{
20021 "type":"mediaSingle","attrs":{"layout":"center"},
20022 "content":[{"type":"media","attrs":{"type":"file","id":"img-b","collection":"c2","height":30,"width":40}}]
20023 }]}
20024 ]}]}"#;
20025 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20026 let md = adf_to_markdown(&doc).unwrap();
20027 let rt = markdown_to_adf(&md).unwrap();
20028
20029 let items = rt.content[0].content.as_ref().unwrap();
20030 assert_eq!(items.len(), 2);
20031 for (i, expected_id) in [("img-a", "c1"), ("img-b", "c2")].iter().enumerate() {
20032 let ms = &items[i].content.as_ref().unwrap()[0];
20033 assert_eq!(ms.node_type, "mediaSingle");
20034 let m_attrs = ms.content.as_ref().unwrap()[0].attrs.as_ref().unwrap();
20035 assert_eq!(m_attrs["id"], expected_id.0);
20036 assert_eq!(m_attrs["collection"], expected_id.1);
20037 }
20038 }
20039
20040 #[test]
20041 fn issue_430_jfm_to_adf_media_in_bullet_item() {
20042 let md = "- ![](){type=file id=test-id collection=col-1 height=100 width=200}\n";
20045 let doc = markdown_to_adf(md).unwrap();
20046
20047 let list = &doc.content[0];
20048 assert_eq!(list.node_type, "bulletList");
20049 let item = &list.content.as_ref().unwrap()[0];
20050 let ms = &item.content.as_ref().unwrap()[0];
20051 assert_eq!(
20052 ms.node_type, "mediaSingle",
20053 "expected mediaSingle, got {}",
20054 ms.node_type
20055 );
20056 let media = &ms.content.as_ref().unwrap()[0];
20057 assert_eq!(media.node_type, "media");
20058 let m_attrs = media.attrs.as_ref().unwrap();
20059 assert_eq!(m_attrs["type"], "file");
20060 assert_eq!(m_attrs["id"], "test-id");
20061 }
20062
20063 #[test]
20064 fn issue_430_jfm_to_adf_media_in_ordered_item() {
20065 let md = "1. \n";
20067 let doc = markdown_to_adf(md).unwrap();
20068
20069 let list = &doc.content[0];
20070 assert_eq!(list.node_type, "orderedList");
20071 let item = &list.content.as_ref().unwrap()[0];
20072 let ms = &item.content.as_ref().unwrap()[0];
20073 assert_eq!(
20074 ms.node_type, "mediaSingle",
20075 "expected mediaSingle, got {}",
20076 ms.node_type
20077 );
20078 }
20079
20080 #[test]
20081 fn issue_430_media_then_paragraph_in_bullet_list_roundtrip() {
20082 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20085 {"type":"listItem","content":[
20086 {"type":"mediaSingle","attrs":{"layout":"center"},
20087 "content":[{"type":"media","attrs":{"type":"file","id":"img-first","collection":"col-1","height":50,"width":100}}]},
20088 {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
20089 ]}
20090 ]}]}"#;
20091 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20092 let md = adf_to_markdown(&doc).unwrap();
20093 let rt = markdown_to_adf(&md).unwrap();
20094
20095 let item = &rt.content[0].content.as_ref().unwrap()[0];
20096 let children = item.content.as_ref().unwrap();
20097 assert_eq!(children.len(), 2, "expected 2 children in listItem");
20098 assert_eq!(children[0].node_type, "mediaSingle");
20099 let media = &children[0].content.as_ref().unwrap()[0];
20100 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-first");
20101 assert_eq!(children[1].node_type, "paragraph");
20102 }
20103
20104 #[test]
20105 fn issue_430_media_then_paragraph_in_ordered_list_roundtrip() {
20106 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20108 {"type":"listItem","content":[
20109 {"type":"mediaSingle","attrs":{"layout":"center"},
20110 "content":[{"type":"media","attrs":{"type":"file","id":"img-ord","collection":"col-2","height":60,"width":120}}]},
20111 {"type":"paragraph","content":[{"type":"text","text":"Description"}]}
20112 ]}
20113 ]}]}"#;
20114 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20115 let md = adf_to_markdown(&doc).unwrap();
20116 let rt = markdown_to_adf(&md).unwrap();
20117
20118 let item = &rt.content[0].content.as_ref().unwrap()[0];
20119 let children = item.content.as_ref().unwrap();
20120 assert_eq!(children.len(), 2, "expected 2 children in listItem");
20121 assert_eq!(children[0].node_type, "mediaSingle");
20122 assert_eq!(children[1].node_type, "paragraph");
20123 }
20124
20125 #[test]
20126 fn issue_430_external_media_with_width_type_roundtrip() {
20127 let adf_json = r#"{"version":1,"type":"doc","content":[{
20129 "type":"mediaSingle",
20130 "attrs":{"layout":"wide","width":800,"widthType":"pixel"},
20131 "content":[{
20132 "type":"media",
20133 "attrs":{"type":"external","url":"https://example.com/photo.png","alt":"wide photo"}
20134 }]
20135 }]}"#;
20136 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20137 let md = adf_to_markdown(&doc).unwrap();
20138 assert!(
20139 md.contains("widthType=pixel"),
20140 "expected widthType=pixel in markdown, got: {md}"
20141 );
20142 let rt = markdown_to_adf(&md).unwrap();
20143 let ms = &rt.content[0];
20144 assert_eq!(ms.node_type, "mediaSingle");
20145 let ms_attrs = ms.attrs.as_ref().unwrap();
20146 assert_eq!(ms_attrs["widthType"], "pixel");
20147 assert_eq!(ms_attrs["width"], 800);
20148 assert_eq!(ms_attrs["layout"], "wide");
20149 }
20150
20151 #[test]
20154 fn issue_490_paragraph_with_hardbreak_then_media_single_roundtrip() {
20155 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20158 {"type":"listItem","content":[
20159 {"type":"paragraph","content":[
20160 {"type":"text","text":"Item with image:"},
20161 {"type":"hardBreak"}
20162 ]},
20163 {"type":"mediaSingle","attrs":{"layout":"center","width":400,"widthType":"pixel"},
20164 "content":[{"type":"media","attrs":{
20165 "id":"aabbccdd-1234-5678-abcd-aabbccdd1234",
20166 "type":"file",
20167 "collection":"contentId-123456",
20168 "width":800,
20169 "height":600
20170 }}]}
20171 ]}
20172 ]}]}"#;
20173 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20174 let md = adf_to_markdown(&doc).unwrap();
20175 let rt = markdown_to_adf(&md).unwrap();
20176
20177 let item = &rt.content[0].content.as_ref().unwrap()[0];
20178 let children = item.content.as_ref().unwrap();
20179 assert_eq!(children.len(), 2, "expected 2 children in listItem");
20180 assert_eq!(children[0].node_type, "paragraph");
20181 assert_eq!(
20182 children[1].node_type, "mediaSingle",
20183 "expected mediaSingle, got {:?}",
20184 children[1].node_type
20185 );
20186 let media = &children[1].content.as_ref().unwrap()[0];
20187 let m_attrs = media.attrs.as_ref().unwrap();
20188 assert_eq!(m_attrs["id"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
20189 assert_eq!(m_attrs["collection"], "contentId-123456");
20190 assert_eq!(m_attrs["height"], 600);
20191 assert_eq!(m_attrs["width"], 800);
20192 }
20193
20194 #[test]
20195 fn issue_490_paragraph_with_hardbreak_then_media_single_ordered_list() {
20196 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20198 {"type":"listItem","content":[
20199 {"type":"paragraph","content":[
20200 {"type":"text","text":"Step with screenshot:"},
20201 {"type":"hardBreak"}
20202 ]},
20203 {"type":"mediaSingle","attrs":{"layout":"center"},
20204 "content":[{"type":"media","attrs":{
20205 "id":"ord-media-id","type":"file","collection":"col-ord","width":640,"height":480
20206 }}]}
20207 ]}
20208 ]}]}"#;
20209 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20210 let md = adf_to_markdown(&doc).unwrap();
20211 let rt = markdown_to_adf(&md).unwrap();
20212
20213 let item = &rt.content[0].content.as_ref().unwrap()[0];
20214 let children = item.content.as_ref().unwrap();
20215 assert_eq!(children.len(), 2, "expected 2 children in listItem");
20216 assert_eq!(children[0].node_type, "paragraph");
20217 assert_eq!(children[1].node_type, "mediaSingle");
20218 let media = &children[1].content.as_ref().unwrap()[0];
20219 assert_eq!(media.attrs.as_ref().unwrap()["id"], "ord-media-id");
20220 }
20221
20222 #[test]
20223 fn issue_490_hardbreak_continuation_does_not_swallow_media_line() {
20224 let md = "- Item with image:\\\n ![](){type=file id=test-490 collection=col height=100 width=200}\n";
20227 let doc = markdown_to_adf(md).unwrap();
20228
20229 let item = &doc.content[0].content.as_ref().unwrap()[0];
20230 let children = item.content.as_ref().unwrap();
20231 assert_eq!(children.len(), 2, "expected 2 children in listItem");
20232 assert_eq!(children[0].node_type, "paragraph");
20233 assert_eq!(
20234 children[1].node_type, "mediaSingle",
20235 "expected mediaSingle as second child, got {:?}",
20236 children[1].node_type
20237 );
20238 let media = &children[1].content.as_ref().unwrap()[0];
20239 assert_eq!(media.attrs.as_ref().unwrap()["id"], "test-490");
20240 }
20241
20242 #[test]
20243 fn issue_490_hardbreak_continuation_still_works_for_text() {
20244 let md = "- first line\\\n second line\n";
20246 let doc = markdown_to_adf(md).unwrap();
20247
20248 let item = &doc.content[0].content.as_ref().unwrap()[0];
20249 let children = item.content.as_ref().unwrap();
20250 assert_eq!(
20251 children.len(),
20252 1,
20253 "expected 1 child (paragraph) in listItem"
20254 );
20255 assert_eq!(children[0].node_type, "paragraph");
20256 let inlines = children[0].content.as_ref().unwrap();
20257 assert_eq!(inlines.len(), 3);
20259 assert_eq!(inlines[0].node_type, "text");
20260 assert_eq!(inlines[1].node_type, "hardBreak");
20261 assert_eq!(inlines[2].node_type, "text");
20262 }
20263
20264 #[test]
20265 fn issue_490_external_media_after_hardbreak_roundtrip() {
20266 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20268 {"type":"listItem","content":[
20269 {"type":"paragraph","content":[
20270 {"type":"text","text":"See image:"},
20271 {"type":"hardBreak"}
20272 ]},
20273 {"type":"mediaSingle","attrs":{"layout":"center"},
20274 "content":[{"type":"media","attrs":{
20275 "type":"external","url":"https://example.com/photo.png","alt":"photo"
20276 }}]}
20277 ]}
20278 ]}]}"#;
20279 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20280 let md = adf_to_markdown(&doc).unwrap();
20281 let rt = markdown_to_adf(&md).unwrap();
20282
20283 let item = &rt.content[0].content.as_ref().unwrap()[0];
20284 let children = item.content.as_ref().unwrap();
20285 assert_eq!(children.len(), 2);
20286 assert_eq!(children[0].node_type, "paragraph");
20287 assert_eq!(children[1].node_type, "mediaSingle");
20288 let media = &children[1].content.as_ref().unwrap()[0];
20289 let m_attrs = media.attrs.as_ref().unwrap();
20290 assert_eq!(m_attrs["url"], "https://example.com/photo.png");
20291 }
20292
20293 #[test]
20294 fn issue_490_multiple_hardbreaks_then_media_single() {
20295 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20297 {"type":"listItem","content":[
20298 {"type":"paragraph","content":[
20299 {"type":"text","text":"line one"},
20300 {"type":"hardBreak"},
20301 {"type":"text","text":"line two"},
20302 {"type":"hardBreak"}
20303 ]},
20304 {"type":"mediaSingle","attrs":{"layout":"center"},
20305 "content":[{"type":"media","attrs":{
20306 "type":"file","id":"multi-hb","collection":"col-m","width":320,"height":240
20307 }}]}
20308 ]}
20309 ]}]}"#;
20310 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20311 let md = adf_to_markdown(&doc).unwrap();
20312 let rt = markdown_to_adf(&md).unwrap();
20313
20314 let item = &rt.content[0].content.as_ref().unwrap()[0];
20315 let children = item.content.as_ref().unwrap();
20316 assert_eq!(children.len(), 2, "expected paragraph + mediaSingle");
20317 assert_eq!(children[0].node_type, "paragraph");
20318 assert_eq!(children[1].node_type, "mediaSingle");
20319 let media = &children[1].content.as_ref().unwrap()[0];
20320 assert_eq!(media.attrs.as_ref().unwrap()["id"], "multi-hb");
20321 }
20322
20323 #[test]
20326 fn issue_525_listitem_localid_with_mediasingle_roundtrip() {
20327 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"}]}]}]}]}]}]}"#;
20330 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20331 let md = adf_to_markdown(&doc).unwrap();
20332 let rt = markdown_to_adf(&md).unwrap();
20333
20334 let list = &rt.content[0];
20335 assert_eq!(list.node_type, "bulletList");
20336 let item = &list.content.as_ref().unwrap()[0];
20337 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20339 assert_eq!(
20340 item_attrs["localId"], "aabbccdd-1234-5678-abcd-000000000001",
20341 "listItem localId must survive round-trip"
20342 );
20343 let children = item.content.as_ref().unwrap();
20344 assert_eq!(
20345 children.len(),
20346 3,
20347 "expected mediaSingle + paragraph + bulletList"
20348 );
20349 assert_eq!(children[0].node_type, "mediaSingle");
20350 assert_eq!(children[1].node_type, "paragraph");
20351 assert_eq!(children[2].node_type, "bulletList");
20352 }
20353
20354 #[test]
20355 fn issue_525_listitem_localid_with_mediasingle_only() {
20356 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20358 {"type":"listItem","attrs":{"localId":"li-media-only"},"content":[
20359 {"type":"mediaSingle","attrs":{"layout":"center"},
20360 "content":[{"type":"media","attrs":{"type":"file","id":"m-001","collection":"c1","height":50,"width":100}}]}
20361 ]}
20362 ]}]}"#;
20363 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20364 let md = adf_to_markdown(&doc).unwrap();
20365 let rt = markdown_to_adf(&md).unwrap();
20366
20367 let item = &rt.content[0].content.as_ref().unwrap()[0];
20368 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20369 assert_eq!(
20370 item_attrs["localId"], "li-media-only",
20371 "listItem localId must survive when sole child is mediaSingle"
20372 );
20373 assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20374 }
20375
20376 #[test]
20377 fn issue_525_listitem_localid_with_external_media() {
20378 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20380 {"type":"listItem","attrs":{"localId":"li-ext-media"},"content":[
20381 {"type":"mediaSingle","attrs":{"layout":"center"},
20382 "content":[{"type":"media","attrs":{"type":"external","url":"https://example.com/img.png","alt":"photo"}}]}
20383 ]}
20384 ]}]}"#;
20385 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20386 let md = adf_to_markdown(&doc).unwrap();
20387 let rt = markdown_to_adf(&md).unwrap();
20388
20389 let item = &rt.content[0].content.as_ref().unwrap()[0];
20390 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20391 assert_eq!(
20392 item_attrs["localId"], "li-ext-media",
20393 "listItem localId must survive with external mediaSingle"
20394 );
20395 }
20396
20397 #[test]
20398 fn issue_525_listitem_localid_with_mediasingle_in_ordered_list() {
20399 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20401 {"type":"listItem","attrs":{"localId":"li-ord-media"},"content":[
20402 {"type":"mediaSingle","attrs":{"layout":"center","width":200,"widthType":"pixel"},
20403 "content":[{"type":"media","attrs":{"type":"file","id":"ord-m-001","collection":"col-ord","height":80,"width":160}}]},
20404 {"type":"paragraph","content":[{"type":"text","text":"ordered item text"}]}
20405 ]}
20406 ]}]}"#;
20407 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20408 let md = adf_to_markdown(&doc).unwrap();
20409 let rt = markdown_to_adf(&md).unwrap();
20410
20411 let item = &rt.content[0].content.as_ref().unwrap()[0];
20412 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20413 assert_eq!(
20414 item_attrs["localId"], "li-ord-media",
20415 "listItem localId must survive in ordered list with mediaSingle"
20416 );
20417 let children = item.content.as_ref().unwrap();
20418 assert_eq!(children[0].node_type, "mediaSingle");
20419 assert_eq!(children[1].node_type, "paragraph");
20420 }
20421
20422 #[test]
20423 fn issue_525_jfm_localid_on_mediasingle_line_parses_correctly() {
20424 let md = "- ![](){type=file id=test-525 collection=col height=100 width=200 mediaWidth=100 widthType=pixel} {localId=li-jfm-525}\n";
20427 let doc = markdown_to_adf(md).unwrap();
20428
20429 let item = &doc.content[0].content.as_ref().unwrap()[0];
20430 let item_attrs = item
20431 .attrs
20432 .as_ref()
20433 .expect("listItem attrs must be present from JFM");
20434 assert_eq!(item_attrs["localId"], "li-jfm-525");
20435 assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20436 }
20437
20438 #[test]
20439 fn issue_525_encoding_emits_localid_on_mediasingle_line() {
20440 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20443 {"type":"listItem","attrs":{"localId":"li-emit-check"},"content":[
20444 {"type":"mediaSingle","attrs":{"layout":"center"},
20445 "content":[{"type":"media","attrs":{"type":"file","id":"m-emit","collection":"c-emit","height":10,"width":20}}]}
20446 ]}
20447 ]}]}"#;
20448 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20449 let md = adf_to_markdown(&doc).unwrap();
20450 assert!(
20451 md.contains("{localId=li-emit-check}"),
20452 "expected localId in JFM output, got: {md}"
20453 );
20454 for line in md.lines() {
20456 if line.contains("![") {
20457 assert!(
20458 line.contains("localId=li-emit-check"),
20459 "localId must be on the same line as the image: {line}"
20460 );
20461 }
20462 }
20463 }
20464
20465 #[test]
20468 fn adf_placeholder_to_markdown() {
20469 let doc = AdfDocument {
20470 version: 1,
20471 doc_type: "doc".to_string(),
20472 content: vec![AdfNode::paragraph(vec![AdfNode::placeholder(
20473 "Type something here",
20474 )])],
20475 };
20476 let md = adf_to_markdown(&doc).unwrap();
20477 assert!(
20478 md.contains(":placeholder[Type something here]"),
20479 "expected :placeholder directive, got: {md}"
20480 );
20481 }
20482
20483 #[test]
20484 fn markdown_placeholder_to_adf() {
20485 let doc = markdown_to_adf("Before :placeholder[Enter name] after").unwrap();
20486 let content = doc.content[0].content.as_ref().unwrap();
20487 assert_eq!(content[1].node_type, "placeholder");
20488 let attrs = content[1].attrs.as_ref().unwrap();
20489 assert_eq!(attrs["text"], "Enter name");
20490 }
20491
20492 #[test]
20493 fn placeholder_round_trip() {
20494 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"placeholder","attrs":{"text":"Type something here"}}]}]}"#;
20495 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20496 let md = adf_to_markdown(&doc).unwrap();
20497 let rt = markdown_to_adf(&md).unwrap();
20498 let content = rt.content[0].content.as_ref().unwrap();
20499 assert_eq!(content.len(), 1);
20500 assert_eq!(content[0].node_type, "placeholder");
20501 let attrs = content[0].attrs.as_ref().unwrap();
20502 assert_eq!(attrs["text"], "Type something here");
20503 }
20504
20505 #[test]
20506 fn placeholder_empty_text() {
20507 let doc = AdfDocument {
20508 version: 1,
20509 doc_type: "doc".to_string(),
20510 content: vec![AdfNode::paragraph(vec![AdfNode::placeholder("")])],
20511 };
20512 let md = adf_to_markdown(&doc).unwrap();
20513 assert!(
20514 md.contains(":placeholder[]"),
20515 "expected empty placeholder directive, got: {md}"
20516 );
20517 let rt = markdown_to_adf(&md).unwrap();
20518 let content = rt.content[0].content.as_ref().unwrap();
20519 assert_eq!(content[0].node_type, "placeholder");
20520 assert_eq!(content[0].attrs.as_ref().unwrap()["text"], "");
20521 }
20522
20523 #[test]
20524 fn placeholder_with_surrounding_text() {
20525 let md = "Click :placeholder[here] to continue\n";
20526 let doc = markdown_to_adf(md).unwrap();
20527 let content = doc.content[0].content.as_ref().unwrap();
20528 assert_eq!(content[0].text.as_deref(), Some("Click "));
20529 assert_eq!(content[1].node_type, "placeholder");
20530 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "here");
20531 assert_eq!(content[2].text.as_deref(), Some(" to continue"));
20532 }
20533
20534 #[test]
20535 fn placeholder_missing_attrs() {
20536 let doc = AdfDocument {
20538 version: 1,
20539 doc_type: "doc".to_string(),
20540 content: vec![AdfNode::paragraph(vec![AdfNode {
20541 node_type: "placeholder".to_string(),
20542 attrs: None,
20543 content: None,
20544 text: None,
20545 marks: None,
20546 local_id: None,
20547 parameters: None,
20548 }])],
20549 };
20550 let md = adf_to_markdown(&doc).unwrap();
20551 assert!(!md.contains("placeholder"));
20553 }
20554
20555 #[test]
20557 fn mention_in_table_bullet_list_preserves_id_and_local_id() {
20558 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":" "}]}]}]}]}]}]}]}"#;
20559 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20560 let md = adf_to_markdown(&doc).unwrap();
20561 let rt = markdown_to_adf(&md).unwrap();
20562
20563 let cell = &rt.content[0].content.as_ref().unwrap()[0]
20565 .content
20566 .as_ref()
20567 .unwrap()[0];
20568 let list = &cell.content.as_ref().unwrap()[0];
20569 let list_item = &list.content.as_ref().unwrap()[0];
20570
20571 assert!(
20573 list_item
20574 .attrs
20575 .as_ref()
20576 .and_then(|a| a.get("localId"))
20577 .is_none(),
20578 "localId should stay on the mention, not the listItem"
20579 );
20580
20581 let para = &list_item.content.as_ref().unwrap()[0];
20582 let inlines = para.content.as_ref().unwrap();
20583
20584 assert_eq!(inlines.len(), 3, "expected 3 inline nodes, got {inlines:?}");
20586
20587 assert_eq!(inlines[0].node_type, "text");
20588 assert_eq!(inlines[0].text.as_deref(), Some("prefix text "));
20589
20590 assert_eq!(inlines[1].node_type, "mention");
20591 let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20592 assert_eq!(
20593 mention_attrs["id"], "aabbccdd11223344aabbccdd",
20594 "mention id must be preserved"
20595 );
20596 assert_eq!(
20597 mention_attrs["localId"], "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
20598 "mention localId must be preserved"
20599 );
20600 assert_eq!(mention_attrs["text"], "@Alice Example");
20601
20602 assert_eq!(inlines[2].node_type, "text");
20603 assert_eq!(inlines[2].text.as_deref(), Some(" "));
20604 }
20605
20606 #[test]
20607 fn mention_in_bullet_list_preserves_id_and_local_id() {
20608 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":" "}]}]}]}]}"#;
20610 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20611 let md = adf_to_markdown(&doc).unwrap();
20612 let rt = markdown_to_adf(&md).unwrap();
20613
20614 let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20615 assert!(
20616 list_item
20617 .attrs
20618 .as_ref()
20619 .and_then(|a| a.get("localId"))
20620 .is_none(),
20621 "localId should stay on the mention, not the listItem"
20622 );
20623
20624 let para = &list_item.content.as_ref().unwrap()[0];
20625 let inlines = para.content.as_ref().unwrap();
20626 assert_eq!(inlines[0].node_type, "mention");
20627 let mention_attrs = inlines[0].attrs.as_ref().unwrap();
20628 assert_eq!(mention_attrs["id"], "user123");
20629 assert_eq!(
20630 mention_attrs["localId"],
20631 "11111111-2222-3333-4444-555555555555"
20632 );
20633 }
20634
20635 #[test]
20636 fn mention_in_ordered_list_preserves_id_and_local_id() {
20637 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"}}]}]}]}]}"#;
20638 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20639 let md = adf_to_markdown(&doc).unwrap();
20640 let rt = markdown_to_adf(&md).unwrap();
20641
20642 let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20643 assert!(
20644 list_item
20645 .attrs
20646 .as_ref()
20647 .and_then(|a| a.get("localId"))
20648 .is_none(),
20649 "localId should stay on the mention, not the listItem"
20650 );
20651
20652 let para = &list_item.content.as_ref().unwrap()[0];
20653 let inlines = para.content.as_ref().unwrap();
20654 assert_eq!(inlines[1].node_type, "mention");
20655 let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20656 assert_eq!(mention_attrs["id"], "xyz");
20657 assert_eq!(mention_attrs["localId"], "aaaa-bbbb");
20658 }
20659
20660 #[test]
20661 fn list_item_own_local_id_with_mention_both_preserved() {
20662 let md = "- hello :mention[@Eve]{id=e1 localId=mention-lid} {localId=item-lid}\n";
20665 let doc = markdown_to_adf(md).unwrap();
20666 let list_item = &doc.content[0].content.as_ref().unwrap()[0];
20667
20668 let item_attrs = list_item.attrs.as_ref().unwrap();
20670 assert_eq!(item_attrs["localId"], "item-lid");
20671
20672 let para = &list_item.content.as_ref().unwrap()[0];
20674 let inlines = para.content.as_ref().unwrap();
20675 let mention = inlines.iter().find(|n| n.node_type == "mention").unwrap();
20676 let mention_attrs = mention.attrs.as_ref().unwrap();
20677 assert_eq!(mention_attrs["id"], "e1");
20678 assert_eq!(mention_attrs["localId"], "mention-lid");
20679 }
20680
20681 #[test]
20682 fn extract_trailing_local_id_ignores_directive_attrs() {
20683 let line = "text :mention[@X]{id=abc localId=uuid}";
20686 let (text, lid, plid) = extract_trailing_local_id(line);
20687 assert_eq!(text, line, "text should be unchanged");
20688 assert!(
20689 lid.is_none(),
20690 "should not extract localId from directive attrs"
20691 );
20692 assert!(plid.is_none());
20693 }
20694
20695 #[test]
20696 fn extract_trailing_local_id_matches_standalone_block() {
20697 let line = "some text {localId=abc-123}";
20699 let (text, lid, plid) = extract_trailing_local_id(line);
20700 assert_eq!(text, "some text");
20701 assert_eq!(lid.as_deref(), Some("abc-123"));
20702 assert!(plid.is_none());
20703 }
20704
20705 #[test]
20708 fn newline_in_text_node_roundtrips_in_bullet_list() {
20709 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"}]}]}]}]}"#;
20713 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20714 let md = adf_to_markdown(&doc).unwrap();
20715 let rt = markdown_to_adf(&md).unwrap();
20716
20717 assert_eq!(rt.content.len(), 1);
20719 let list = &rt.content[0];
20720 assert_eq!(list.node_type, "bulletList");
20721 let items = list.content.as_ref().unwrap();
20722 assert_eq!(items.len(), 1);
20723
20724 let item_content = items[0].content.as_ref().unwrap();
20726 assert_eq!(
20727 item_content.len(),
20728 1,
20729 "listItem should have exactly one paragraph"
20730 );
20731 assert_eq!(item_content[0].node_type, "paragraph");
20732
20733 let inlines = item_content[0].content.as_ref().unwrap();
20735 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20736 assert_eq!(
20737 types,
20738 vec!["text", "hardBreak", "text"],
20739 "embedded newline should stay in a single text node, not produce extra hardBreaks"
20740 );
20741 assert_eq!(
20742 inlines[2].text.as_deref(),
20743 Some("first command\nsecond command")
20744 );
20745 }
20746
20747 #[test]
20748 fn newline_in_text_node_roundtrips_in_ordered_list() {
20749 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"}]}]}]}]}"#;
20751 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20752 let md = adf_to_markdown(&doc).unwrap();
20753 let rt = markdown_to_adf(&md).unwrap();
20754
20755 let list = &rt.content[0];
20756 assert_eq!(list.node_type, "orderedList");
20757 let items = list.content.as_ref().unwrap();
20758 assert_eq!(items.len(), 1);
20759
20760 let item_content = items[0].content.as_ref().unwrap();
20761 assert_eq!(item_content.len(), 1);
20762 assert_eq!(item_content[0].node_type, "paragraph");
20763
20764 let inlines = item_content[0].content.as_ref().unwrap();
20765 assert_eq!(inlines.len(), 1);
20766 assert_eq!(inlines[0].node_type, "text");
20767 assert_eq!(inlines[0].text.as_deref(), Some("first\nsecond"));
20768 }
20769
20770 #[test]
20771 fn newline_in_text_node_roundtrips_in_paragraph() {
20772 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello\nworld"}]}]}"#;
20775 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20776 let md = adf_to_markdown(&doc).unwrap();
20777 assert!(
20778 md.contains("hello\\nworld"),
20779 "newline in text node should render as escaped \\n: {md:?}"
20780 );
20781
20782 let rt = markdown_to_adf(&md).unwrap();
20783 let inlines = rt.content[0].content.as_ref().unwrap();
20784 assert_eq!(inlines.len(), 1);
20785 assert_eq!(inlines[0].text.as_deref(), Some("hello\nworld"));
20786 }
20787
20788 #[test]
20789 fn multiple_newlines_in_text_node_roundtrip() {
20790 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a\nb\nc"}]}]}]}]}"#;
20792 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20793 let md = adf_to_markdown(&doc).unwrap();
20794 let rt = markdown_to_adf(&md).unwrap();
20795
20796 let item_content = rt.content[0].content.as_ref().unwrap()[0]
20797 .content
20798 .as_ref()
20799 .unwrap();
20800 assert_eq!(item_content.len(), 1);
20801
20802 let inlines = item_content[0].content.as_ref().unwrap();
20803 assert_eq!(inlines.len(), 1);
20804 assert_eq!(inlines[0].text.as_deref(), Some("a\nb\nc"));
20805 }
20806
20807 #[test]
20808 fn newline_in_marked_text_node_roundtrips() {
20809 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold\ntext","marks":[{"type":"strong"}]}]}]}"#;
20812 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20813 let md = adf_to_markdown(&doc).unwrap();
20814 assert!(
20815 md.contains("**bold\\ntext**"),
20816 "bold text with embedded newline should stay in one marked run: {md:?}"
20817 );
20818
20819 let rt = markdown_to_adf(&md).unwrap();
20820 let inlines = rt.content[0].content.as_ref().unwrap();
20821 assert_eq!(inlines.len(), 1);
20822 assert_eq!(inlines[0].text.as_deref(), Some("bold\ntext"));
20823 assert!(inlines[0]
20824 .marks
20825 .as_ref()
20826 .unwrap()
20827 .iter()
20828 .any(|m| m.mark_type == "strong"));
20829 }
20830
20831 #[test]
20832 fn trailing_newline_in_text_node_roundtrips() {
20833 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"trailing\n"}]}]}"#;
20836 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20837 let md = adf_to_markdown(&doc).unwrap();
20838 assert!(
20839 md.contains("trailing\\n"),
20840 "trailing newline should be escaped: {md:?}"
20841 );
20842
20843 let rt = markdown_to_adf(&md).unwrap();
20844 let inlines = rt.content[0].content.as_ref().unwrap();
20845 assert_eq!(inlines.len(), 1);
20846 assert_eq!(inlines[0].text.as_deref(), Some("trailing\n"));
20847 }
20848
20849 #[test]
20850 fn hardbreak_and_embedded_newline_are_distinct() {
20851 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"}]}]}"#;
20854 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20855 let md = adf_to_markdown(&doc).unwrap();
20856 let rt = markdown_to_adf(&md).unwrap();
20857
20858 let inlines = rt.content[0].content.as_ref().unwrap();
20859 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20860 assert_eq!(
20861 types,
20862 vec!["text", "hardBreak", "text", "hardBreak", "text"]
20863 );
20864 assert_eq!(inlines[0].text.as_deref(), Some("before"));
20865 assert_eq!(inlines[2].text.as_deref(), Some("mid\ndle"));
20866 assert_eq!(inlines[4].text.as_deref(), Some("after"));
20867 }
20868
20869 #[test]
20872 fn issue_472_bullet_list_trailing_hardbreak_roundtrips() {
20873 let adf_json = r#"{"version":1,"type":"doc","content":[
20876 {"type":"bulletList","content":[
20877 {"type":"listItem","content":[
20878 {"type":"paragraph","content":[
20879 {"type":"text","text":"First item"},
20880 {"type":"hardBreak"}
20881 ]}
20882 ]},
20883 {"type":"listItem","content":[
20884 {"type":"paragraph","content":[
20885 {"type":"text","text":"Second item"}
20886 ]}
20887 ]}
20888 ]}
20889 ]}"#;
20890 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20891 let md = adf_to_markdown(&doc).unwrap();
20892 let rt = markdown_to_adf(&md).unwrap();
20893
20894 assert_eq!(
20896 rt.content.len(),
20897 1,
20898 "Should be 1 block (bulletList), got {}",
20899 rt.content.len()
20900 );
20901 assert_eq!(rt.content[0].node_type, "bulletList");
20902 let items = rt.content[0].content.as_ref().unwrap();
20903 assert_eq!(
20904 items.len(),
20905 2,
20906 "Should have 2 listItems, got {}",
20907 items.len()
20908 );
20909
20910 let p1 = items[0].content.as_ref().unwrap()[0]
20912 .content
20913 .as_ref()
20914 .unwrap();
20915 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20916 assert_eq!(types1, vec!["text", "hardBreak"]);
20917 assert_eq!(p1[0].text.as_deref(), Some("First item"));
20918
20919 let p2 = items[1].content.as_ref().unwrap()[0]
20921 .content
20922 .as_ref()
20923 .unwrap();
20924 assert_eq!(p2[0].text.as_deref(), Some("Second item"));
20925 }
20926
20927 #[test]
20928 fn issue_472_ordered_list_trailing_hardbreak_roundtrips() {
20929 let adf_json = r#"{"version":1,"type":"doc","content":[
20931 {"type":"orderedList","attrs":{"order":1},"content":[
20932 {"type":"listItem","content":[
20933 {"type":"paragraph","content":[
20934 {"type":"text","text":"Alpha"},
20935 {"type":"hardBreak"}
20936 ]}
20937 ]},
20938 {"type":"listItem","content":[
20939 {"type":"paragraph","content":[
20940 {"type":"text","text":"Beta"}
20941 ]}
20942 ]}
20943 ]}
20944 ]}"#;
20945 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20946 let md = adf_to_markdown(&doc).unwrap();
20947 let rt = markdown_to_adf(&md).unwrap();
20948
20949 assert_eq!(rt.content.len(), 1);
20950 assert_eq!(rt.content[0].node_type, "orderedList");
20951 let items = rt.content[0].content.as_ref().unwrap();
20952 assert_eq!(items.len(), 2);
20953
20954 let p1 = items[0].content.as_ref().unwrap()[0]
20955 .content
20956 .as_ref()
20957 .unwrap();
20958 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20959 assert_eq!(types1, vec!["text", "hardBreak"]);
20960 assert_eq!(p1[0].text.as_deref(), Some("Alpha"));
20961 }
20962
20963 #[test]
20964 fn issue_472_trailing_hardbreak_jfm_no_blank_line() {
20965 let adf_json = r#"{"version":1,"type":"doc","content":[
20968 {"type":"bulletList","content":[
20969 {"type":"listItem","content":[
20970 {"type":"paragraph","content":[
20971 {"type":"text","text":"Hello"},
20972 {"type":"hardBreak"}
20973 ]}
20974 ]},
20975 {"type":"listItem","content":[
20976 {"type":"paragraph","content":[
20977 {"type":"text","text":"World"}
20978 ]}
20979 ]}
20980 ]}
20981 ]}"#;
20982 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20983 let md = adf_to_markdown(&doc).unwrap();
20984
20985 assert_eq!(md, "- Hello\\\n- World\n");
20987 }
20988
20989 #[test]
20990 fn issue_472_multiple_trailing_hardbreaks_roundtrip() {
20991 let adf_json = r#"{"version":1,"type":"doc","content":[
20993 {"type":"bulletList","content":[
20994 {"type":"listItem","content":[
20995 {"type":"paragraph","content":[
20996 {"type":"text","text":"Item"},
20997 {"type":"hardBreak"},
20998 {"type":"hardBreak"}
20999 ]}
21000 ]},
21001 {"type":"listItem","content":[
21002 {"type":"paragraph","content":[
21003 {"type":"text","text":"Next"}
21004 ]}
21005 ]}
21006 ]}
21007 ]}"#;
21008 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21009 let md = adf_to_markdown(&doc).unwrap();
21010 let rt = markdown_to_adf(&md).unwrap();
21011
21012 assert_eq!(rt.content.len(), 1);
21014 assert_eq!(rt.content[0].node_type, "bulletList");
21015 let items = rt.content[0].content.as_ref().unwrap();
21016 assert_eq!(items.len(), 2);
21017
21018 let p1 = items[0].content.as_ref().unwrap()[0]
21020 .content
21021 .as_ref()
21022 .unwrap();
21023 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21024 assert_eq!(types1, vec!["text", "hardBreak", "hardBreak"]);
21025 }
21026
21027 #[test]
21028 fn issue_472_hardbreak_mid_and_trailing_roundtrip() {
21029 let adf_json = r#"{"version":1,"type":"doc","content":[
21031 {"type":"bulletList","content":[
21032 {"type":"listItem","content":[
21033 {"type":"paragraph","content":[
21034 {"type":"text","text":"Line one"},
21035 {"type":"hardBreak"},
21036 {"type":"text","text":"Line two"},
21037 {"type":"hardBreak"}
21038 ]}
21039 ]},
21040 {"type":"listItem","content":[
21041 {"type":"paragraph","content":[
21042 {"type":"text","text":"Other item"}
21043 ]}
21044 ]}
21045 ]}
21046 ]}"#;
21047 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21048 let md = adf_to_markdown(&doc).unwrap();
21049 let rt = markdown_to_adf(&md).unwrap();
21050
21051 assert_eq!(rt.content.len(), 1);
21052 assert_eq!(rt.content[0].node_type, "bulletList");
21053 let items = rt.content[0].content.as_ref().unwrap();
21054 assert_eq!(items.len(), 2);
21055
21056 let p1 = items[0].content.as_ref().unwrap()[0]
21057 .content
21058 .as_ref()
21059 .unwrap();
21060 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21061 assert_eq!(types1, vec!["text", "hardBreak", "text", "hardBreak"]);
21062 assert_eq!(p1[0].text.as_deref(), Some("Line one"));
21063 assert_eq!(p1[2].text.as_deref(), Some("Line two"));
21064 }
21065
21066 #[test]
21067 fn issue_472_only_hardbreak_in_listitem_paragraph() {
21068 let adf_json = r#"{"version":1,"type":"doc","content":[
21070 {"type":"bulletList","content":[
21071 {"type":"listItem","content":[
21072 {"type":"paragraph","content":[
21073 {"type":"hardBreak"}
21074 ]}
21075 ]},
21076 {"type":"listItem","content":[
21077 {"type":"paragraph","content":[
21078 {"type":"text","text":"After"}
21079 ]}
21080 ]}
21081 ]}
21082 ]}"#;
21083 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21084 let md = adf_to_markdown(&doc).unwrap();
21085 let rt = markdown_to_adf(&md).unwrap();
21086
21087 assert_eq!(rt.content.len(), 1);
21089 assert_eq!(rt.content[0].node_type, "bulletList");
21090 let items = rt.content[0].content.as_ref().unwrap();
21091 assert_eq!(items.len(), 2);
21092 }
21093
21094 #[test]
21095 fn issue_472_three_items_middle_has_trailing_hardbreak() {
21096 let adf_json = r#"{"version":1,"type":"doc","content":[
21098 {"type":"bulletList","content":[
21099 {"type":"listItem","content":[
21100 {"type":"paragraph","content":[
21101 {"type":"text","text":"First"}
21102 ]}
21103 ]},
21104 {"type":"listItem","content":[
21105 {"type":"paragraph","content":[
21106 {"type":"text","text":"Second"},
21107 {"type":"hardBreak"}
21108 ]}
21109 ]},
21110 {"type":"listItem","content":[
21111 {"type":"paragraph","content":[
21112 {"type":"text","text":"Third"}
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, "bulletList");
21123 let items = rt.content[0].content.as_ref().unwrap();
21124 assert_eq!(items.len(), 3);
21125 assert_eq!(
21126 items[0].content.as_ref().unwrap()[0]
21127 .content
21128 .as_ref()
21129 .unwrap()[0]
21130 .text
21131 .as_deref(),
21132 Some("First")
21133 );
21134 assert_eq!(
21135 items[2].content.as_ref().unwrap()[0]
21136 .content
21137 .as_ref()
21138 .unwrap()[0]
21139 .text
21140 .as_deref(),
21141 Some("Third")
21142 );
21143 }
21144
21145 #[test]
21148 fn issue_494_space_after_hardbreak_roundtrip() {
21149 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21152 {"type":"text","text":"Some text"},
21153 {"type":"hardBreak"},
21154 {"type":"text","text":" "}
21155 ]}]}"#;
21156 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21157 let md = adf_to_markdown(&doc).unwrap();
21158 let rt = markdown_to_adf(&md).unwrap();
21159 let inlines = rt.content[0].content.as_ref().unwrap();
21160 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21161 assert_eq!(
21162 types,
21163 vec!["text", "hardBreak", "text"],
21164 "space-only text node after hardBreak should survive round-trip"
21165 );
21166 assert_eq!(inlines[2].text.as_deref(), Some(" "));
21167 }
21168
21169 #[test]
21170 fn issue_494_multiple_spaces_after_hardbreak_roundtrip() {
21171 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21173 {"type":"text","text":"Hello"},
21174 {"type":"hardBreak"},
21175 {"type":"text","text":" "}
21176 ]}]}"#;
21177 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21178 let md = adf_to_markdown(&doc).unwrap();
21179 let rt = markdown_to_adf(&md).unwrap();
21180 let inlines = rt.content[0].content.as_ref().unwrap();
21181 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21182 assert_eq!(
21183 types,
21184 vec!["text", "hardBreak", "text"],
21185 "multi-space text node after hardBreak should survive round-trip"
21186 );
21187 assert_eq!(inlines[2].text.as_deref(), Some(" "));
21188 }
21189
21190 #[test]
21191 fn issue_494_space_then_text_after_hardbreak_roundtrip() {
21192 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21195 {"type":"text","text":"Before"},
21196 {"type":"hardBreak"},
21197 {"type":"text","text":" After"}
21198 ]}]}"#;
21199 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21200 let md = adf_to_markdown(&doc).unwrap();
21201 let rt = markdown_to_adf(&md).unwrap();
21202 let inlines = rt.content[0].content.as_ref().unwrap();
21203 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21204 assert_eq!(types, vec!["text", "hardBreak", "text"]);
21205 assert_eq!(inlines[2].text.as_deref(), Some(" After"));
21206 }
21207
21208 #[test]
21209 fn issue_494_hardbreak_then_space_then_hardbreak_roundtrip() {
21210 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21212 {"type":"text","text":"A"},
21213 {"type":"hardBreak"},
21214 {"type":"text","text":" "},
21215 {"type":"hardBreak"},
21216 {"type":"text","text":"B"}
21217 ]}]}"#;
21218 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21219 let md = adf_to_markdown(&doc).unwrap();
21220 let rt = markdown_to_adf(&md).unwrap();
21221 let inlines = rt.content[0].content.as_ref().unwrap();
21222 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21223 assert_eq!(
21224 types,
21225 vec!["text", "hardBreak", "text", "hardBreak", "text"],
21226 "space between two hardBreaks should survive round-trip"
21227 );
21228 assert_eq!(inlines[2].text.as_deref(), Some(" "));
21229 assert_eq!(inlines[4].text.as_deref(), Some("B"));
21230 }
21231
21232 #[test]
21233 fn issue_494_trailing_space_hardbreak_style_not_confused() {
21234 let md = "first paragraph\n\nsecond paragraph\n";
21237 let doc = markdown_to_adf(md).unwrap();
21238 assert_eq!(
21239 doc.content.len(),
21240 2,
21241 "blank line should still separate paragraphs"
21242 );
21243 }
21244
21245 #[test]
21246 fn issue_494_space_after_trailing_space_hardbreak_roundtrip() {
21247 let md = "line one \n \n";
21250 let doc = markdown_to_adf(md).unwrap();
21254 let inlines = doc.content[0].content.as_ref().unwrap();
21255 let has_text_after_break = inlines.iter().any(|n| {
21256 n.node_type == "text"
21257 && n.text
21258 .as_deref()
21259 .is_some_and(|t| t.trim().is_empty() && !t.is_empty())
21260 });
21261 assert!(
21262 has_text_after_break || inlines.len() >= 2,
21263 "space-only line after trailing-space hardBreak should be preserved"
21264 );
21265 }
21266
21267 #[test]
21268 fn issue_494_space_after_hardbreak_in_list_item_roundtrip() {
21269 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21271 {"type":"listItem","content":[{"type":"paragraph","content":[
21272 {"type":"text","text":"item"},
21273 {"type":"hardBreak"},
21274 {"type":"text","text":" "}
21275 ]}]}
21276 ]}]}"#;
21277 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21278 let md = adf_to_markdown(&doc).unwrap();
21279 let rt = markdown_to_adf(&md).unwrap();
21280 let list = &rt.content[0];
21281 let item = &list.content.as_ref().unwrap()[0];
21282 let para = &item.content.as_ref().unwrap()[0];
21283 let inlines = para.content.as_ref().unwrap();
21284 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21285 assert_eq!(
21286 types,
21287 vec!["text", "hardBreak", "text"],
21288 "space after hardBreak in list item should survive round-trip"
21289 );
21290 assert_eq!(inlines[2].text.as_deref(), Some(" "));
21291 }
21292
21293 #[test]
21296 fn issue_510_trailing_double_space_paragraph_roundtrip() {
21297 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"}]}]}"#;
21300 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21301 let md = adf_to_markdown(&doc).unwrap();
21302 let rt = markdown_to_adf(&md).unwrap();
21303
21304 assert_eq!(
21306 rt.content.len(),
21307 2,
21308 "should produce two paragraphs, got: {}",
21309 rt.content.len()
21310 );
21311 assert_eq!(rt.content[0].node_type, "paragraph");
21312 assert_eq!(rt.content[1].node_type, "paragraph");
21313
21314 let p1 = rt.content[0].content.as_ref().unwrap();
21316 assert_eq!(
21317 p1[0].text.as_deref(),
21318 Some("first paragraph with trailing spaces "),
21319 "trailing spaces should be preserved in first paragraph"
21320 );
21321
21322 let p2 = rt.content[1].content.as_ref().unwrap();
21324 assert_eq!(p2[0].text.as_deref(), Some("second paragraph"));
21325
21326 let all_types: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21328 assert!(
21329 !all_types.contains(&"hardBreak"),
21330 "trailing spaces should not produce hardBreak, got: {all_types:?}"
21331 );
21332 }
21333
21334 #[test]
21335 fn issue_510_trailing_triple_space_roundtrip() {
21336 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"text "}]},{"type":"paragraph","content":[{"type":"text","text":"next"}]}]}"#;
21338 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21339 let md = adf_to_markdown(&doc).unwrap();
21340 let rt = markdown_to_adf(&md).unwrap();
21341
21342 assert_eq!(rt.content.len(), 2, "should still be two paragraphs");
21343 let p1 = rt.content[0].content.as_ref().unwrap();
21344 assert_eq!(
21345 p1[0].text.as_deref(),
21346 Some("text "),
21347 "three trailing spaces should be preserved"
21348 );
21349 }
21350
21351 #[test]
21352 fn issue_510_trailing_spaces_with_backslash_roundtrip() {
21353 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\ "}]}]}"#;
21355 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21356 let md = adf_to_markdown(&doc).unwrap();
21357 let rt = markdown_to_adf(&md).unwrap();
21358 let p = rt.content[0].content.as_ref().unwrap();
21359 assert_eq!(
21360 p[0].text.as_deref(),
21361 Some("end\\ "),
21362 "backslash + trailing spaces should both survive"
21363 );
21364 }
21365
21366 #[test]
21367 fn issue_510_jfm_contains_escaped_trailing_space() {
21368 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello "}]}]}"#;
21370 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21371 let md = adf_to_markdown(&doc).unwrap();
21372 assert!(
21373 md.contains(r"\ "),
21374 "JFM should contain backslash-space escape for trailing spaces, got: {md:?}"
21375 );
21376 for line in md.lines() {
21378 assert!(
21379 !line.ends_with(" "),
21380 "no JFM line should end with two plain spaces, got: {line:?}"
21381 );
21382 }
21383 }
21384
21385 #[test]
21386 fn issue_510_single_trailing_space_not_escaped() {
21387 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"word "}]}]}"#;
21389 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21390 let md = adf_to_markdown(&doc).unwrap();
21391 assert!(
21392 !md.contains('\\'),
21393 "single trailing space should not be escaped, got: {md:?}"
21394 );
21395 let rt = markdown_to_adf(&md).unwrap();
21396 let p = rt.content[0].content.as_ref().unwrap();
21397 assert_eq!(p[0].text.as_deref(), Some("word "));
21398 }
21399
21400 #[test]
21401 fn issue_510_trailing_spaces_in_heading_roundtrip() {
21402 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"heading "}]}]}"#;
21404 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21405 let md = adf_to_markdown(&doc).unwrap();
21406 let rt = markdown_to_adf(&md).unwrap();
21407 let h = rt.content[0].content.as_ref().unwrap();
21408 assert_eq!(
21409 h[0].text.as_deref(),
21410 Some("heading "),
21411 "trailing spaces in heading should be preserved"
21412 );
21413 }
21414
21415 #[test]
21416 fn issue_510_trailing_spaces_in_list_item_roundtrip() {
21417 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item "}]}]}]}]}"#;
21419 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21420 let md = adf_to_markdown(&doc).unwrap();
21421 let rt = markdown_to_adf(&md).unwrap();
21422 let list = &rt.content[0];
21423 let item = &list.content.as_ref().unwrap()[0];
21424 let para = &item.content.as_ref().unwrap()[0];
21425 let inlines = para.content.as_ref().unwrap();
21426 assert_eq!(
21427 inlines[0].text.as_deref(),
21428 Some("item "),
21429 "trailing spaces in list item should be preserved"
21430 );
21431 }
21432
21433 #[test]
21434 fn issue_510_trailing_spaces_with_bold_mark_roundtrip() {
21435 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold ","marks":[{"type":"strong"}]}]}]}"#;
21439 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21440 let md = adf_to_markdown(&doc).unwrap();
21441 let rt = markdown_to_adf(&md).unwrap();
21442 let p = rt.content[0].content.as_ref().unwrap();
21443 assert_eq!(
21444 p[0].text.as_deref(),
21445 Some("bold "),
21446 "trailing spaces in bold text should be preserved"
21447 );
21448 }
21449
21450 #[test]
21451 fn issue_510_hardbreak_between_paragraphs_still_works() {
21452 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"line one"},{"type":"hardBreak"},{"type":"text","text":"line two"}]}]}"#;
21454 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21455 let md = adf_to_markdown(&doc).unwrap();
21456 let rt = markdown_to_adf(&md).unwrap();
21457 let inlines = rt.content[0].content.as_ref().unwrap();
21458 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21459 assert_eq!(
21460 types,
21461 vec!["text", "hardBreak", "text"],
21462 "explicit hardBreak should still round-trip"
21463 );
21464 }
21465
21466 #[test]
21467 fn issue_510_all_spaces_text_node_roundtrip() {
21468 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":" "}]}]}"#;
21470 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21471 let md = adf_to_markdown(&doc).unwrap();
21472 let rt = markdown_to_adf(&md).unwrap();
21473 let p = rt.content[0].content.as_ref().unwrap();
21474 assert_eq!(
21475 p[0].text.as_deref(),
21476 Some(" "),
21477 "space-only text node should survive round-trip"
21478 );
21479 }
21480
21481 #[test]
21484 fn issue_522_listitem_hardbreak_then_two_paragraphs_roundtrips() {
21485 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"}]}]}]}]}"#;
21488 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21489 let md = adf_to_markdown(&doc).unwrap();
21490 let rt = markdown_to_adf(&md).unwrap();
21491
21492 let items = rt.content[0].content.as_ref().unwrap();
21493 assert_eq!(items.len(), 1);
21494 let children = items[0].content.as_ref().unwrap();
21495 assert_eq!(
21496 children.len(),
21497 3,
21498 "Expected 3 paragraphs in listItem, got {}",
21499 children.len()
21500 );
21501 assert_eq!(children[0].node_type, "paragraph");
21502 assert_eq!(children[1].node_type, "paragraph");
21503 assert_eq!(children[2].node_type, "paragraph");
21504
21505 let text1 = children[1].content.as_ref().unwrap()[0]
21507 .text
21508 .as_deref()
21509 .unwrap();
21510 assert_eq!(text1, "second paragraph");
21511 let text2 = children[2].content.as_ref().unwrap()[0]
21512 .text
21513 .as_deref()
21514 .unwrap();
21515 assert_eq!(text2, "third paragraph");
21516 }
21517
21518 #[test]
21519 fn issue_522_ordered_list_hardbreak_then_paragraphs_roundtrips() {
21520 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"}]}]}]}]}"#;
21522 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21523 let md = adf_to_markdown(&doc).unwrap();
21524 let rt = markdown_to_adf(&md).unwrap();
21525
21526 let items = rt.content[0].content.as_ref().unwrap();
21527 let children = items[0].content.as_ref().unwrap();
21528 assert_eq!(
21529 children.len(),
21530 3,
21531 "Expected 3 paragraphs in ordered listItem, got {}",
21532 children.len()
21533 );
21534 assert_eq!(children[1].node_type, "paragraph");
21535 assert_eq!(children[2].node_type, "paragraph");
21536 assert_eq!(
21537 children[1].content.as_ref().unwrap()[0]
21538 .text
21539 .as_deref()
21540 .unwrap(),
21541 "second para"
21542 );
21543 assert_eq!(
21544 children[2].content.as_ref().unwrap()[0]
21545 .text
21546 .as_deref()
21547 .unwrap(),
21548 "third para"
21549 );
21550 }
21551
21552 #[test]
21553 fn issue_522_two_paragraphs_without_hardbreak_roundtrips() {
21554 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"}]}]}]}]}"#;
21556 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21557 let md = adf_to_markdown(&doc).unwrap();
21558 let rt = markdown_to_adf(&md).unwrap();
21559
21560 let items = rt.content[0].content.as_ref().unwrap();
21561 let children = items[0].content.as_ref().unwrap();
21562 assert_eq!(
21563 children.len(),
21564 2,
21565 "Expected 2 paragraphs in listItem, got {}",
21566 children.len()
21567 );
21568 assert_eq!(children[0].node_type, "paragraph");
21569 assert_eq!(children[1].node_type, "paragraph");
21570 }
21571
21572 #[test]
21573 fn issue_522_paragraph_then_nested_list_no_spurious_blank() {
21574 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"}]}]}]}]}]}]}"#;
21577 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21578 let md = adf_to_markdown(&doc).unwrap();
21579 assert!(
21581 !md.contains(" \n -"),
21582 "No blank separator between paragraph and nested list"
21583 );
21584 let rt = markdown_to_adf(&md).unwrap();
21585
21586 let items = rt.content[0].content.as_ref().unwrap();
21587 let children = items[0].content.as_ref().unwrap();
21588 assert_eq!(children.len(), 2);
21589 assert_eq!(children[0].node_type, "paragraph");
21590 assert_eq!(children[1].node_type, "bulletList");
21591 }
21592
21593 #[test]
21594 fn issue_522_three_paragraphs_no_hardbreak_roundtrips() {
21595 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"}]}]}]}]}"#;
21597 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21598 let md = adf_to_markdown(&doc).unwrap();
21599 let rt = markdown_to_adf(&md).unwrap();
21600
21601 let items = rt.content[0].content.as_ref().unwrap();
21602 let children = items[0].content.as_ref().unwrap();
21603 assert_eq!(
21604 children.len(),
21605 3,
21606 "Expected 3 paragraphs, got {}",
21607 children.len()
21608 );
21609 for (i, child) in children.iter().enumerate() {
21610 assert_eq!(
21611 child.node_type, "paragraph",
21612 "Child {i} should be a paragraph"
21613 );
21614 }
21615 }
21616
21617 #[test]
21618 fn issue_522_multiple_list_items_each_with_paragraphs() {
21619 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"}]}]}]}]}"#;
21621 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21622 let md = adf_to_markdown(&doc).unwrap();
21623 let rt = markdown_to_adf(&md).unwrap();
21624
21625 let items = rt.content[0].content.as_ref().unwrap();
21626 assert_eq!(items.len(), 2, "Expected 2 list items");
21627
21628 let item1 = items[0].content.as_ref().unwrap();
21629 assert_eq!(item1.len(), 2, "Item 1 should have 2 paragraphs");
21630
21631 let item2 = items[1].content.as_ref().unwrap();
21632 assert_eq!(item2.len(), 2, "Item 2 should have 2 paragraphs");
21633 let item2_p1_inlines = item2[0].content.as_ref().unwrap();
21635 let types: Vec<&str> = item2_p1_inlines
21636 .iter()
21637 .map(|n| n.node_type.as_str())
21638 .collect();
21639 assert_eq!(types, vec!["text", "hardBreak", "text"]);
21640 }
21641
21642 #[test]
21643 fn issue_531_blockquote_hardbreak_then_two_paragraphs_roundtrips() {
21644 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"}]}]}]}"#;
21648 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21649 let md = adf_to_markdown(&doc).unwrap();
21650 let rt = markdown_to_adf(&md).unwrap();
21651
21652 let children = rt.content[0].content.as_ref().unwrap();
21653 assert_eq!(
21654 children.len(),
21655 3,
21656 "Expected 3 paragraphs in blockquote, got {}",
21657 children.len()
21658 );
21659 assert_eq!(children[0].node_type, "paragraph");
21660 assert_eq!(children[1].node_type, "paragraph");
21661 assert_eq!(children[2].node_type, "paragraph");
21662
21663 let text1 = children[1].content.as_ref().unwrap()[0]
21664 .text
21665 .as_deref()
21666 .unwrap();
21667 assert_eq!(text1, "second paragraph");
21668 let text2 = children[2].content.as_ref().unwrap()[0]
21669 .text
21670 .as_deref()
21671 .unwrap();
21672 assert_eq!(text2, "third paragraph");
21673 }
21674
21675 #[test]
21676 fn issue_531_blockquote_two_paragraphs_without_hardbreak_roundtrips() {
21677 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"}]}]}]}"#;
21679 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21680 let md = adf_to_markdown(&doc).unwrap();
21681 let rt = markdown_to_adf(&md).unwrap();
21682
21683 let children = rt.content[0].content.as_ref().unwrap();
21684 assert_eq!(
21685 children.len(),
21686 2,
21687 "Expected 2 paragraphs in blockquote, got {}",
21688 children.len()
21689 );
21690 assert_eq!(children[0].node_type, "paragraph");
21691 assert_eq!(children[1].node_type, "paragraph");
21692 }
21693
21694 #[test]
21695 fn issue_531_blockquote_three_paragraphs_no_hardbreak_roundtrips() {
21696 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"}]}]}]}"#;
21698 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21699 let md = adf_to_markdown(&doc).unwrap();
21700 let rt = markdown_to_adf(&md).unwrap();
21701
21702 let children = rt.content[0].content.as_ref().unwrap();
21703 assert_eq!(
21704 children.len(),
21705 3,
21706 "Expected 3 paragraphs in blockquote, got {}",
21707 children.len()
21708 );
21709 for child in children {
21710 assert_eq!(child.node_type, "paragraph");
21711 }
21712 }
21713
21714 #[test]
21715 fn issue_531_blockquote_paragraph_then_list_no_spurious_blank() {
21716 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"}]}]}]}]}]}"#;
21719 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21720 let md = adf_to_markdown(&doc).unwrap();
21721 let rt = markdown_to_adf(&md).unwrap();
21722
21723 let children = rt.content[0].content.as_ref().unwrap();
21724 assert_eq!(children[0].node_type, "paragraph");
21725 assert_eq!(children[1].node_type, "bulletList");
21726 }
21727
21728 #[test]
21729 fn issue_531_blockquote_single_paragraph_unchanged() {
21730 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"solo"}]}]}]}"#;
21732 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21733 let md = adf_to_markdown(&doc).unwrap();
21734 let rt = markdown_to_adf(&md).unwrap();
21735
21736 let children = rt.content[0].content.as_ref().unwrap();
21737 assert_eq!(children.len(), 1);
21738 assert_eq!(children[0].node_type, "paragraph");
21739 let text = children[0].content.as_ref().unwrap()[0]
21740 .text
21741 .as_deref()
21742 .unwrap();
21743 assert_eq!(text, "solo");
21744 }
21745
21746 fn assert_roundtrip_marks(adf_json: &str, expected_marks: &[&str]) {
21751 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21752 let md = adf_to_markdown(&doc).unwrap();
21753 let rt = markdown_to_adf(&md).unwrap();
21754 let node = &rt.content[0].content.as_ref().unwrap()[0];
21755 let mark_types: Vec<&str> = node
21756 .marks
21757 .as_ref()
21758 .expect("should have marks")
21759 .iter()
21760 .map(|m| m.mark_type.as_str())
21761 .collect();
21762 assert_eq!(
21763 mark_types, expected_marks,
21764 "mark order mismatch for md={md}"
21765 );
21766 }
21767
21768 #[test]
21769 fn issue_554_code_and_text_color_preserved() {
21770 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21771 {"type":"text","text":"x","marks":[
21772 {"type":"textColor","attrs":{"color":"#008000"}},
21773 {"type":"code"}
21774 ]}
21775 ]}]}"##;
21776 assert_roundtrip_marks(adf_json, &["textColor", "code"]);
21777 }
21778
21779 #[test]
21780 fn issue_554_code_and_bg_color_preserved() {
21781 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21782 {"type":"text","text":"x","marks":[
21783 {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21784 {"type":"code"}
21785 ]}
21786 ]}]}"##;
21787 assert_roundtrip_marks(adf_json, &["backgroundColor", "code"]);
21788 }
21789
21790 #[test]
21791 fn issue_554_code_and_subsup_preserved() {
21792 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21793 {"type":"text","text":"x","marks":[
21794 {"type":"subsup","attrs":{"type":"sub"}},
21795 {"type":"code"}
21796 ]}
21797 ]}]}"#;
21798 assert_roundtrip_marks(adf_json, &["subsup", "code"]);
21799 }
21800
21801 #[test]
21802 fn issue_554_code_and_underline_preserved() {
21803 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21804 {"type":"text","text":"x","marks":[
21805 {"type":"underline"},
21806 {"type":"code"}
21807 ]}
21808 ]}]}"#;
21809 assert_roundtrip_marks(adf_json, &["underline", "code"]);
21810 }
21811
21812 #[test]
21813 fn issue_554_code_textcolor_and_underline_preserved() {
21814 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21815 {"type":"text","text":"x","marks":[
21816 {"type":"textColor","attrs":{"color":"#008000"}},
21817 {"type":"underline"},
21818 {"type":"code"}
21819 ]}
21820 ]}]}"##;
21821 assert_roundtrip_marks(adf_json, &["textColor", "underline", "code"]);
21822 }
21823
21824 #[test]
21825 fn issue_554_textcolor_and_underline_preserved() {
21826 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21827 {"type":"text","text":"x","marks":[
21828 {"type":"textColor","attrs":{"color":"#008000"}},
21829 {"type":"underline"}
21830 ]}
21831 ]}]}"##;
21832 assert_roundtrip_marks(adf_json, &["textColor", "underline"]);
21833 }
21834
21835 #[test]
21836 fn issue_554_underline_and_textcolor_preserved_order_swapped() {
21837 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21838 {"type":"text","text":"x","marks":[
21839 {"type":"underline"},
21840 {"type":"textColor","attrs":{"color":"#008000"}}
21841 ]}
21842 ]}]}"##;
21843 assert_roundtrip_marks(adf_json, &["underline", "textColor"]);
21845 }
21846
21847 #[test]
21848 fn issue_554_textcolor_and_annotation_preserved() {
21849 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21850 {"type":"text","text":"x","marks":[
21851 {"type":"textColor","attrs":{"color":"#008000"}},
21852 {"type":"annotation","attrs":{"id":"abc-123","annotationType":"inlineComment"}}
21853 ]}
21854 ]}]}"##;
21855 assert_roundtrip_marks(adf_json, &["textColor", "annotation"]);
21856 }
21857
21858 #[test]
21859 fn issue_554_bgcolor_and_underline_preserved() {
21860 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21861 {"type":"text","text":"x","marks":[
21862 {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21863 {"type":"underline"}
21864 ]}
21865 ]}]}"##;
21866 assert_roundtrip_marks(adf_json, &["backgroundColor", "underline"]);
21867 }
21868
21869 #[test]
21870 fn issue_554_subsup_and_underline_preserved() {
21871 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21872 {"type":"text","text":"x","marks":[
21873 {"type":"subsup","attrs":{"type":"sub"}},
21874 {"type":"underline"}
21875 ]}
21876 ]}]}"#;
21877 assert_roundtrip_marks(adf_json, &["subsup", "underline"]);
21878 }
21879
21880 #[test]
21881 fn issue_554_exact_reproducer_full_match() {
21882 let adf_json = r##"{
21885 "version": 1,
21886 "type": "doc",
21887 "content": [
21888 {
21889 "type": "paragraph",
21890 "content": [
21891 {"type":"text","text":"Status: ","marks":[{"type":"strong"}]},
21892 {"type":"text","text":"Approved","marks":[
21893 {"type":"textColor","attrs":{"color":"#008000"}}
21894 ]},
21895 {"type":"text","text":" — ready to proceed"}
21896 ]
21897 }
21898 ]
21899 }"##;
21900 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21901 let md = adf_to_markdown(&doc).unwrap();
21902 assert!(
21903 md.contains(":span[Approved]{color=#008000}"),
21904 "JFM should contain green span: {md}"
21905 );
21906 let rt = markdown_to_adf(&md).unwrap();
21907 let approved = rt.content[0]
21909 .content
21910 .as_ref()
21911 .unwrap()
21912 .iter()
21913 .find(|n| n.text.as_deref() == Some("Approved"))
21914 .expect("Approved text node");
21915 let marks = approved.marks.as_ref().expect("should have marks");
21916 let color_mark = marks
21917 .iter()
21918 .find(|m| m.mark_type == "textColor")
21919 .expect("textColor mark must be preserved");
21920 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
21921 }
21922
21923 #[test]
21924 fn issue_554_textcolor_with_code_renders_span_around_code() {
21925 let doc = AdfDocument {
21928 version: 1,
21929 doc_type: "doc".to_string(),
21930 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
21931 "fn main",
21932 vec![
21933 AdfMark::text_color("#008000"),
21934 AdfMark {
21935 mark_type: "code".to_string(),
21936 attrs: None,
21937 },
21938 ],
21939 )])],
21940 };
21941 let md = adf_to_markdown(&doc).unwrap();
21942 assert!(
21943 md.contains(":span[`fn main`]{color=#008000}"),
21944 "expected span-wrapped code, got: {md}"
21945 );
21946 }
21947
21948 #[test]
21949 fn issue_554_underline_with_code_renders_bracketed_around_code() {
21950 let doc = AdfDocument {
21951 version: 1,
21952 doc_type: "doc".to_string(),
21953 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
21954 "fn main",
21955 vec![
21956 AdfMark::underline(),
21957 AdfMark {
21958 mark_type: "code".to_string(),
21959 attrs: None,
21960 },
21961 ],
21962 )])],
21963 };
21964 let md = adf_to_markdown(&doc).unwrap();
21965 assert!(
21966 md.contains("[`fn main`]{underline}"),
21967 "expected bracketed-span around code, got: {md}"
21968 );
21969 }
21970
21971 #[test]
21974 fn issue_554_underscore_adjacent_to_textcolor_span_roundtrip() {
21975 let adf_json = r##"{
21980 "version": 1,
21981 "type": "doc",
21982 "content": [
21983 {
21984 "type": "paragraph",
21985 "content": [
21986 {"type":"text","text":"_ "},
21987 {"type":"text","text":"_Action:*","marks":[
21988 {"type":"textColor","attrs":{"color":"#008000"}}
21989 ]},
21990 {"type":"text","text":" Complete the setup process."}
21991 ]
21992 }
21993 ]
21994 }"##;
21995 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21996 let md = adf_to_markdown(&doc).unwrap();
21997 assert!(
22000 md.contains(r"\_ ") && md.contains(r":span[\_Action"),
22001 "underscores at node boundaries should be escaped: {md}"
22002 );
22003 let rt = markdown_to_adf(&md).unwrap();
22004 let para_content = rt.content[0].content.as_ref().unwrap();
22005 let colored = para_content
22007 .iter()
22008 .find(|n| {
22009 n.marks
22010 .as_deref()
22011 .is_some_and(|ms| ms.iter().any(|m| m.mark_type == "textColor"))
22012 })
22013 .expect("textColor node must be preserved");
22014 assert_eq!(colored.text.as_deref(), Some("_Action:*"));
22015 let color_mark = colored
22016 .marks
22017 .as_ref()
22018 .unwrap()
22019 .iter()
22020 .find(|m| m.mark_type == "textColor")
22021 .unwrap();
22022 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
22023 for n in para_content {
22025 if let Some(ms) = n.marks.as_deref() {
22026 assert!(
22027 !ms.iter().any(|m| m.mark_type == "em"),
22028 "no em mark should appear, got marks {:?}",
22029 ms.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
22030 );
22031 }
22032 }
22033 }
22034
22035 #[test]
22036 fn issue_554_underscore_intraword_left_unescaped() {
22037 let doc = AdfDocument {
22041 version: 1,
22042 doc_type: "doc".to_string(),
22043 content: vec![AdfNode::paragraph(vec![AdfNode::text(
22044 "call do_something_useful now",
22045 )])],
22046 };
22047 let md = adf_to_markdown(&doc).unwrap();
22048 assert!(
22049 md.contains("do_something_useful") && !md.contains(r"do\_something\_useful"),
22050 "intraword underscores should not be escaped: {md}"
22051 );
22052 }
22053
22054 #[test]
22055 fn issue_554_code_underline_then_textcolor_bracketed_outer() {
22056 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22059 {"type":"text","text":"x","marks":[
22060 {"type":"underline"},
22061 {"type":"textColor","attrs":{"color":"#008000"}},
22062 {"type":"code"}
22063 ]}
22064 ]}]}"##;
22065 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22066 let md = adf_to_markdown(&doc).unwrap();
22067 assert!(
22069 md.starts_with('[') && md.contains("underline}"),
22070 "bracketed-span should wrap the span, got: {md}"
22071 );
22072 let rt = markdown_to_adf(&md).unwrap();
22073 let node = &rt.content[0].content.as_ref().unwrap()[0];
22074 let mark_types: Vec<&str> = node
22075 .marks
22076 .as_ref()
22077 .unwrap()
22078 .iter()
22079 .map(|m| m.mark_type.as_str())
22080 .collect();
22081 assert_eq!(mark_types, vec!["underline", "textColor", "code"]);
22082 }
22083
22084 #[test]
22085 fn issue_554_textcolor_underline_link_all_preserved() {
22086 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22090 {"type":"text","text":"linked","marks":[
22091 {"type":"textColor","attrs":{"color":"#008000"}},
22092 {"type":"underline"},
22093 {"type":"link","attrs":{"href":"https://example.com"}}
22094 ]}
22095 ]}]}"##;
22096 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22097 let md = adf_to_markdown(&doc).unwrap();
22098 let rt = markdown_to_adf(&md).unwrap();
22099 let node = &rt.content[0].content.as_ref().unwrap()[0];
22100 let mark_types: Vec<&str> = node
22101 .marks
22102 .as_ref()
22103 .unwrap()
22104 .iter()
22105 .map(|m| m.mark_type.as_str())
22106 .collect();
22107 assert_eq!(mark_types, vec!["textColor", "underline", "link"]);
22108 }
22109
22110 #[test]
22111 fn issue_554_underline_textcolor_link_bracketed_outer_link_last() {
22112 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22115 {"type":"text","text":"linked","marks":[
22116 {"type":"underline"},
22117 {"type":"textColor","attrs":{"color":"#008000"}},
22118 {"type":"link","attrs":{"href":"https://example.com"}}
22119 ]}
22120 ]}]}"##;
22121 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22122 let md = adf_to_markdown(&doc).unwrap();
22123 let rt = markdown_to_adf(&md).unwrap();
22124 let node = &rt.content[0].content.as_ref().unwrap()[0];
22125 let mark_types: Vec<&str> = node
22126 .marks
22127 .as_ref()
22128 .unwrap()
22129 .iter()
22130 .map(|m| m.mark_type.as_str())
22131 .collect();
22132 assert_eq!(mark_types, vec!["underline", "textColor", "link"]);
22133 }
22134
22135 #[test]
22136 fn issue_554_link_underline_textcolor_link_outer() {
22137 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22141 {"type":"text","text":"linked","marks":[
22142 {"type":"link","attrs":{"href":"https://example.com"}},
22143 {"type":"underline"},
22144 {"type":"textColor","attrs":{"color":"#008000"}}
22145 ]}
22146 ]}]}"##;
22147 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22148 let md = adf_to_markdown(&doc).unwrap();
22149 assert!(
22150 md.starts_with('[') && md.contains("](https://example.com)"),
22151 "link should be outermost, got: {md}"
22152 );
22153 let rt = markdown_to_adf(&md).unwrap();
22154 let node = &rt.content[0].content.as_ref().unwrap()[0];
22155 let mark_types: Vec<&str> = node
22156 .marks
22157 .as_ref()
22158 .unwrap()
22159 .iter()
22160 .map(|m| m.mark_type.as_str())
22161 .collect();
22162 assert_eq!(mark_types, vec!["link", "underline", "textColor"]);
22163 }
22164
22165 #[test]
22166 fn issue_554_trailing_underscore_then_leading_underscore_round_trip() {
22167 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22171 {"type":"text","text":"end_"},
22172 {"type":"text","text":"_start"}
22173 ]}]}"#;
22174 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22175 let md = adf_to_markdown(&doc).unwrap();
22176 let rt = markdown_to_adf(&md).unwrap();
22177 let combined: String = rt.content[0]
22179 .content
22180 .as_ref()
22181 .unwrap()
22182 .iter()
22183 .filter_map(|n| n.text.as_deref())
22184 .collect();
22185 assert_eq!(combined, "end__start");
22186 for n in rt.content[0].content.as_ref().unwrap() {
22188 if let Some(ms) = n.marks.as_deref() {
22189 assert!(!ms.iter().any(|m| m.mark_type == "em"));
22190 }
22191 }
22192 }
22193
22194 #[test]
22202 fn blockquote_nesting_beyond_cap_errors() {
22203 let md = format!("{}x", "> ".repeat(200));
22204 let err = markdown_to_adf(&md).unwrap_err();
22205 assert!(err.to_string().contains("maximum depth"));
22206 }
22207
22208 #[test]
22209 fn list_nesting_beyond_cap_errors() {
22210 let mut md = String::new();
22211 for i in 0..200 {
22212 md.push_str(&" ".repeat(i));
22213 md.push_str("- x\n");
22214 }
22215 let err = markdown_to_adf(&md).unwrap_err();
22216 assert!(err.to_string().contains("maximum depth"));
22217 }
22218
22219 #[test]
22220 fn container_directive_nesting_beyond_cap_errors() {
22221 let mut md = String::new();
22222 for _ in 0..200 {
22223 md.push_str(":::panel{type=info}\n");
22224 }
22225 md.push_str("body\n");
22226 for _ in 0..200 {
22227 md.push_str(":::\n");
22228 }
22229 let err = markdown_to_adf(&md).unwrap_err();
22230 assert!(err.to_string().contains("maximum depth"));
22231 }
22232
22233 #[test]
22234 fn directive_table_cell_nesting_beyond_cap_errors() {
22235 let mut md = String::new();
22236 for _ in 0..200 {
22237 md.push_str("::::table\n:::tr\n:::td\n");
22238 }
22239 md.push_str("cell\n");
22240 for _ in 0..200 {
22241 md.push_str(":::\n:::\n::::\n");
22242 }
22243 let err = markdown_to_adf(&md).unwrap_err();
22244 assert!(err.to_string().contains("maximum depth"));
22245 }
22246
22247 #[test]
22248 fn layout_column_nesting_beyond_cap_errors() {
22249 let mut md = String::new();
22250 for _ in 0..200 {
22251 md.push_str("::::layout\n:::column{width=100}\n");
22252 }
22253 md.push_str("x\n");
22254 for _ in 0..200 {
22255 md.push_str(":::\n::::\n");
22256 }
22257 let err = markdown_to_adf(&md).unwrap_err();
22258 assert!(err.to_string().contains("maximum depth"));
22259 }
22260
22261 #[test]
22262 fn blockquote_nesting_at_cap_boundary() {
22263 let at_cap = format!("{}x", "> ".repeat(MAX_NESTING_DEPTH));
22266 assert!(markdown_to_adf(&at_cap).is_ok());
22267 let past_cap = format!("{}x", "> ".repeat(MAX_NESTING_DEPTH + 1));
22268 let err = markdown_to_adf(&past_cap).unwrap_err();
22269 assert!(err.to_string().contains("maximum depth"));
22270 }
22271
22272 #[test]
22273 fn list_nesting_at_cap_succeeds() {
22274 let mut md = String::new();
22278 for i in 0..MAX_NESTING_DEPTH {
22279 md.push_str(&" ".repeat(i));
22280 md.push_str("- x\n");
22281 }
22282 assert!(markdown_to_adf(&md).is_ok());
22283 }
22284
22285 #[test]
22286 fn deep_but_reasonable_nesting_succeeds() {
22287 let md = format!("{}deep", "> ".repeat(20));
22288 let doc = markdown_to_adf(&md).unwrap();
22289 let mut node = &doc.content[0];
22291 for _ in 0..20 {
22292 assert_eq!(node.node_type, "blockquote");
22293 node = &node.content.as_ref().unwrap()[0];
22294 }
22295 assert_eq!(node.node_type, "paragraph");
22296 }
22297
22298 #[test]
22299 fn deeply_nested_emphasis_degrades_without_overflow() {
22300 let md = format!("{}x{}", "*_".repeat(400), "_*".repeat(400));
22301 let doc = markdown_to_adf(&md).unwrap();
22302 assert_eq!(doc.content[0].node_type, "paragraph");
22303 }
22304
22305 #[test]
22306 fn deeply_nested_links_degrade_without_overflow() {
22307 let mut md = String::from("x");
22308 for _ in 0..400 {
22309 md = format!("[{md}](https://example.com)");
22310 }
22311 let doc = markdown_to_adf(&md).unwrap();
22312 assert_eq!(doc.content[0].node_type, "paragraph");
22313 }
22314
22315 #[test]
22316 fn deeply_nested_bracketed_spans_degrade_without_overflow() {
22317 let mut md = String::from("x");
22318 for _ in 0..400 {
22319 md = format!("[{md}]{{underline}}");
22320 }
22321 let doc = markdown_to_adf(&md).unwrap();
22322 assert_eq!(doc.content[0].node_type, "paragraph");
22323 }
22324}