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 _ => return None, };
2705
2706 Some((node, d.end_pos))
2707}
2708
2709fn try_parse_bare_url(text: &str, i: usize) -> Option<(usize, &str)> {
2712 let rest = &text[i..];
2713 if !rest.starts_with("http://") && !rest.starts_with("https://") {
2714 return None;
2715 }
2716 let end = rest
2718 .find(|c: char| c.is_whitespace() || c == ')' || c == ']' || c == '>')
2719 .unwrap_or(rest.len());
2720 let url = rest[..end].trim_end_matches(['.', ',', ';', '!', '?']);
2722 if url.len() <= "https://".len() {
2723 return None; }
2725 Some((i + url.len(), url))
2726}
2727
2728fn try_parse_emoji_shortcode(text: &str, i: usize) -> Option<(usize, &str)> {
2731 let rest = &text[i..];
2732 if !rest.starts_with(':') {
2733 return None;
2734 }
2735 let after = &rest[1..];
2736 let name_end =
2737 after.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')?;
2738 if name_end == 0 {
2739 return None;
2740 }
2741 if after.as_bytes().get(name_end) != Some(&b':') {
2742 return None;
2743 }
2744 let name = &after[..name_end];
2745 Some((i + 1 + name_end + 1, name))
2746}
2747
2748fn parse_emoji_with_attrs(text: &str, shortcode_end: usize, short_name: &str) -> (usize, AdfNode) {
2751 let mut chain_end = shortcode_end;
2756 while let Some((next_end, _)) = try_parse_emoji_shortcode(text, chain_end) {
2757 chain_end = next_end;
2758 }
2759 if chain_end > shortcode_end {
2760 if let Some((attr_end, attrs)) = parse_attrs(text, chain_end) {
2761 return (attr_end, build_emoji_node(&attrs, short_name));
2762 }
2763 }
2764
2765 if let Some((attr_end, attrs)) = parse_attrs(text, shortcode_end) {
2766 (attr_end, build_emoji_node(&attrs, short_name))
2767 } else {
2768 (shortcode_end, AdfNode::emoji(&format!(":{short_name}:")))
2769 }
2770}
2771
2772fn build_emoji_node(attrs: &Attrs, short_name: &str) -> AdfNode {
2775 let resolved_name = attrs
2776 .get("shortName")
2777 .map_or_else(|| format!(":{short_name}:"), str::to_string);
2778 let mut emoji_attrs = serde_json::json!({ "shortName": resolved_name });
2779 if let Some(id) = attrs.get("id") {
2780 emoji_attrs["id"] = serde_json::Value::String(id.to_string());
2781 }
2782 if let Some(t) = attrs.get("text") {
2783 emoji_attrs["text"] = serde_json::Value::String(t.to_string());
2784 }
2785 if let Some(lid) = attrs.get("localId") {
2786 emoji_attrs["localId"] = serde_json::Value::String(lid.to_string());
2787 }
2788 AdfNode {
2789 node_type: "emoji".to_string(),
2790 attrs: Some(emoji_attrs),
2791 content: None,
2792 text: None,
2793 marks: None,
2794 local_id: None,
2795 parameters: None,
2796 }
2797}
2798
2799fn find_closing_paren(s: &str, open: usize) -> Option<usize> {
2804 let mut depth: usize = 0;
2805 for (j, ch) in s[open..].char_indices() {
2806 match ch {
2807 '(' => depth += 1,
2808 ')' => {
2809 depth -= 1;
2810 if depth == 0 {
2811 return Some(open + j);
2812 }
2813 }
2814 _ => {}
2815 }
2816 }
2817 None
2818}
2819
2820fn try_parse_link(text: &str, i: usize) -> Option<(usize, &str, &str)> {
2826 let rest = &text[i..];
2827 if !rest.starts_with('[') {
2828 return None;
2829 }
2830
2831 let mut depth: usize = 0;
2833 let mut text_end = None;
2834 let bytes = rest.as_bytes();
2835 for (j, ch) in rest.char_indices() {
2836 match ch {
2837 '\\' if j + 1 < bytes.len() && (bytes[j + 1] == b'[' || bytes[j + 1] == b']') => {
2838 }
2840 '[' if j == 0 || bytes[j - 1] != b'\\' => depth += 1,
2841 ']' if j == 0 || bytes[j - 1] != b'\\' => {
2842 depth -= 1;
2843 if depth == 0 {
2844 text_end = Some(j);
2845 break;
2846 }
2847 }
2848 _ => {}
2849 }
2850 }
2851
2852 let text_end = text_end?;
2853 let link_text = &rest[1..text_end];
2854 let after_bracket = &rest[text_end + 1..];
2856 if !after_bracket.starts_with('(') {
2857 return None;
2858 }
2859 let url_start = text_end + 1; let url_end = find_closing_paren(rest, url_start)?;
2861 let href = &rest[url_start + 1..url_end];
2862
2863 Some((i + url_end + 1, link_text, href))
2864}
2865
2866#[derive(Debug, Clone, Default)]
2870pub struct RenderOptions {
2871 pub strip_local_ids: bool,
2873}
2874
2875pub fn adf_to_markdown(doc: &AdfDocument) -> Result<String> {
2877 adf_to_markdown_with_options(doc, &RenderOptions::default())
2878}
2879
2880pub fn adf_to_markdown_with_options(doc: &AdfDocument, opts: &RenderOptions) -> Result<String> {
2882 let mut output = String::new();
2883
2884 for (i, node) in doc.content.iter().enumerate() {
2885 if i > 0 {
2886 output.push('\n');
2887 }
2888 render_block_node(node, &mut output, opts);
2889 }
2890
2891 Ok(output)
2892}
2893
2894#[must_use]
2903pub fn adf_to_plain_text(doc: &AdfDocument) -> String {
2904 let mut out = String::new();
2905 for node in &doc.content {
2906 collect_plain_text(node, &mut out);
2907 if !out.is_empty() && !out.ends_with(' ') {
2908 out.push(' ');
2909 }
2910 }
2911 out.truncate(out.trim_end().len());
2912 out
2913}
2914
2915fn collect_plain_text(node: &AdfNode, out: &mut String) {
2916 if let Some(text) = &node.text {
2917 out.push_str(text);
2918 }
2919 if let Some(children) = &node.content {
2920 for child in children {
2921 collect_plain_text(child, out);
2922 }
2923 }
2924}
2925
2926fn pass_through_local_id(dir_attrs: &Option<crate::atlassian::attrs::Attrs>, node: &mut AdfNode) {
2930 if let Some(ref attrs) = dir_attrs {
2931 if let Some(local_id) = attrs.get("localId") {
2932 if let Some(ref mut node_attrs) = node.attrs {
2933 node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2934 } else {
2935 node.attrs = Some(serde_json::json!({"localId": local_id}));
2936 }
2937 }
2938 }
2939}
2940
2941fn pass_through_expand_params(
2944 dir_attrs: &Option<crate::atlassian::attrs::Attrs>,
2945 node: &mut AdfNode,
2946) {
2947 if let Some(ref attrs) = dir_attrs {
2948 if let Some(local_id) = attrs.get("localId") {
2949 node.local_id = Some(local_id.to_string());
2950 }
2951 if let Some(params_str) = attrs.get("params") {
2952 if let Ok(params) = serde_json::from_str(params_str) {
2953 node.parameters = Some(params);
2954 }
2955 }
2956 }
2957}
2958
2959fn extract_trailing_local_id(text: &str) -> (&str, Option<String>, Option<String>) {
2969 let trimmed = text.trim_end();
2970 if !trimmed.ends_with('}') {
2971 return (text, None, None);
2972 }
2973 if let Some(brace_pos) = trimmed.rfind('{') {
2978 if brace_pos > 0 && !trimmed.as_bytes()[brace_pos - 1].is_ascii_whitespace() {
2979 return (text, None, None);
2980 }
2981 let attr_str = &trimmed[brace_pos..];
2982 if let Some((_, attrs)) = parse_attrs(attr_str, 0) {
2983 let local_id = attrs.get("localId").map(str::to_string);
2984 let para_local_id = attrs.get("paraLocalId").map(str::to_string);
2985 if local_id.is_some() || para_local_id.is_some() {
2986 let before = trimmed[..brace_pos]
2987 .strip_suffix(' ')
2988 .unwrap_or(&trimmed[..brace_pos]);
2989 return (before, local_id, para_local_id);
2990 }
2991 }
2992 }
2993 (text, None, None)
2994}
2995
2996fn parse_list_item_first_line(
3006 item_text: &str,
3007 sub_lines: Vec<String>,
3008 local_id: Option<String>,
3009 para_local_id: Option<String>,
3010 depth: usize,
3011) -> Result<AdfNode> {
3012 if item_text.starts_with("```") {
3013 let mut all_lines = vec![item_text.to_string()];
3015 all_lines.extend(sub_lines);
3016 let combined = all_lines.join("\n");
3017 let nested = MarkdownParser::with_depth(&combined, depth).parse_blocks()?;
3018 Ok(list_item_with_local_id(nested, local_id, para_local_id))
3019 } else if let Some(media) = try_parse_media_single_from_line(item_text) {
3020 if sub_lines.is_empty() {
3022 Ok(list_item_with_local_id(
3023 vec![media],
3024 local_id,
3025 para_local_id,
3026 ))
3027 } else {
3028 let sub_text = sub_lines.join("\n");
3029 let mut nested = MarkdownParser::with_depth(&sub_text, depth).parse_blocks()?;
3030 let mut content = vec![media];
3031 content.append(&mut nested);
3032 Ok(list_item_with_local_id(content, local_id, para_local_id))
3033 }
3034 } else {
3035 let first_node = AdfNode::paragraph(parse_inline(item_text));
3036 if sub_lines.is_empty() {
3037 Ok(list_item_with_local_id(
3038 vec![first_node],
3039 local_id,
3040 para_local_id,
3041 ))
3042 } else {
3043 let sub_text = sub_lines.join("\n");
3044 let mut nested = MarkdownParser::with_depth(&sub_text, depth).parse_blocks()?;
3045 let mut content = vec![first_node];
3046 content.append(&mut nested);
3047 Ok(list_item_with_local_id(content, local_id, para_local_id))
3048 }
3049 }
3050}
3051
3052fn list_item_with_local_id(
3053 mut content: Vec<AdfNode>,
3054 local_id: Option<String>,
3055 para_local_id: Option<String>,
3056) -> AdfNode {
3057 if let Some(id) = ¶_local_id {
3058 if let Some(first) = content.first_mut() {
3059 if first.node_type == "paragraph" {
3060 let node_attrs = first.attrs.get_or_insert_with(|| serde_json::json!({}));
3061 node_attrs["localId"] = serde_json::Value::String(id.clone());
3062 }
3063 }
3064 }
3065 let mut item = AdfNode::list_item(content);
3066 if let Some(id) = local_id {
3067 item.attrs = Some(serde_json::json!({"localId": id}));
3068 }
3069 item
3070}
3071
3072fn maybe_push_local_id(attrs: &serde_json::Value, parts: &mut Vec<String>, opts: &RenderOptions) {
3073 if opts.strip_local_ids {
3074 return;
3075 }
3076 if let Some(local_id) = attrs.get("localId").and_then(serde_json::Value::as_str) {
3077 if !local_id.is_empty() && local_id != "00000000-0000-0000-0000-000000000000" {
3078 parts.push(format_kv("localId", local_id));
3079 }
3080 }
3081}
3082
3083fn render_block_children(children: &[AdfNode], output: &mut String, opts: &RenderOptions) {
3085 for (i, child) in children.iter().enumerate() {
3086 if i > 0 {
3087 output.push('\n');
3088 }
3089 render_block_node(child, output, opts);
3090 }
3091}
3092
3093fn fmt_f64_attr(v: f64) -> String {
3096 if v.fract() == 0.0 {
3097 format!("{}", v as i64)
3098 } else {
3099 v.to_string()
3100 }
3101}
3102
3103fn parse_numeric_attr(s: &str) -> Option<serde_json::Value> {
3110 if s.contains('.') || s.contains('e') || s.contains('E') {
3111 s.parse::<f64>().ok().map(serde_json::Value::from)
3112 } else {
3113 s.parse::<i64>().ok().map(serde_json::Value::from)
3114 }
3115}
3116
3117fn fmt_numeric_attr(v: &serde_json::Value) -> Option<String> {
3125 if let Some(n) = v.as_i64() {
3126 return Some(n.to_string());
3127 }
3128 if let Some(n) = v.as_u64() {
3129 return Some(n.to_string());
3130 }
3131 if let Some(n) = v.as_f64() {
3132 if n.fract() == 0.0 && n.is_finite() {
3133 return Some(format!("{n:.1}"));
3134 }
3135 return Some(n.to_string());
3136 }
3137 None
3138}
3139
3140fn render_block_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3142 match node.node_type.as_str() {
3143 "paragraph" => {
3144 let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3145 let dir_attrs = {
3147 let mut parts = Vec::new();
3148 if let Some(ref attrs) = node.attrs {
3149 maybe_push_local_id(attrs, &mut parts, opts);
3150 }
3151 if parts.is_empty() {
3152 String::new()
3153 } else {
3154 format!("{{{}}}", parts.join(" "))
3155 }
3156 };
3157 if is_empty {
3158 output.push_str(&format!("::paragraph{dir_attrs}\n"));
3159 } else {
3160 let mut buf = String::new();
3162 render_inline_content(node, &mut buf, opts);
3163 if buf.trim().is_empty() && !buf.is_empty() {
3164 output.push_str(&format!("::paragraph[{buf}]{dir_attrs}\n"));
3167 } else {
3168 let mut is_first_line = true;
3173 for line in buf.split('\n') {
3174 if is_first_line {
3175 if is_list_start(line) {
3176 output.push_str(&escape_list_marker(line));
3177 } else {
3178 output.push_str(line);
3179 }
3180 is_first_line = false;
3181 } else {
3182 output.push('\n');
3183 if !line.is_empty() {
3184 output.push_str(" ");
3185 }
3186 output.push_str(line);
3187 }
3188 }
3189 output.push('\n');
3190 }
3191 }
3192 }
3193 "heading" => {
3194 let level = node
3195 .attrs
3196 .as_ref()
3197 .and_then(|a| a.get("level"))
3198 .and_then(serde_json::Value::as_u64)
3199 .unwrap_or(1);
3200 for _ in 0..level {
3201 output.push('#');
3202 }
3203 output.push(' ');
3204 let mut buf = String::new();
3205 render_inline_content(node, &mut buf, opts);
3206 let mut is_first_line = true;
3209 for line in buf.split('\n') {
3210 if is_first_line {
3211 output.push_str(line);
3212 is_first_line = false;
3213 } else {
3214 output.push('\n');
3215 if !line.is_empty() {
3216 output.push_str(" ");
3217 }
3218 output.push_str(line);
3219 }
3220 }
3221 output.push('\n');
3222 }
3223 "codeBlock" => {
3224 let language_value = node.attrs.as_ref().and_then(|a| a.get("language"));
3225 let language = language_value
3226 .and_then(serde_json::Value::as_str)
3227 .unwrap_or("");
3228 output.push_str("```");
3229 if language.is_empty() && language_value.is_some() {
3230 output.push_str("\"\"");
3233 } else {
3234 output.push_str(language);
3235 }
3236 output.push('\n');
3237 if let Some(ref content) = node.content {
3238 for child in content {
3239 if let Some(ref text) = child.text {
3240 output.push_str(text);
3241 }
3242 }
3243 }
3244 output.push_str("\n```\n");
3245 }
3246 "blockquote" => {
3247 if let Some(ref content) = node.content {
3248 for (i, child) in content.iter().enumerate() {
3249 if i > 0
3253 && child.node_type == "paragraph"
3254 && content[i - 1].node_type == "paragraph"
3255 {
3256 output.push_str(">\n");
3257 }
3258 let mut inner = String::new();
3259 render_block_node(child, &mut inner, opts);
3260 for line in inner.lines() {
3261 output.push_str("> ");
3262 output.push_str(line);
3263 output.push('\n');
3264 }
3265 }
3266 }
3267 }
3268 "bulletList" => {
3269 if let Some(ref items) = node.content {
3270 for item in items {
3271 output.push_str("- ");
3272 let content_start = output.len();
3273 render_list_item_content(item, output, opts);
3274 if starts_with_task_marker(&output[content_start..]) {
3280 output.insert(content_start, '\\');
3281 }
3282 }
3283 }
3284 }
3285 "orderedList" => {
3286 let start = node
3287 .attrs
3288 .as_ref()
3289 .and_then(|a| a.get("order"))
3290 .and_then(serde_json::Value::as_u64)
3291 .unwrap_or(1);
3292 if let Some(ref items) = node.content {
3293 for (i, item) in items.iter().enumerate() {
3294 let num = start + i as u64;
3295 output.push_str(&format!("{num}. "));
3296 render_list_item_content(item, output, opts);
3297 }
3298 }
3299 }
3300 "taskList" => {
3301 if let Some(ref items) = node.content {
3302 for item in items {
3303 if item.node_type == "taskList" {
3304 let mut nested = String::new();
3308 render_block_node(item, &mut nested, opts);
3309 for line in nested.lines() {
3310 output.push_str(" ");
3311 output.push_str(line);
3312 output.push('\n');
3313 }
3314 } else {
3315 let state = item
3316 .attrs
3317 .as_ref()
3318 .and_then(|a| a.get("state"))
3319 .and_then(serde_json::Value::as_str)
3320 .unwrap_or("TODO");
3321 if state == "DONE" {
3322 output.push_str("- [x] ");
3323 } else {
3324 output.push_str("- [ ] ");
3325 }
3326 render_list_item_content(item, output, opts);
3327 }
3328 }
3329 }
3330 }
3331 "rule" => {
3332 output.push_str("---\n");
3333 }
3334 "table" => {
3335 render_table(node, output, opts);
3336 }
3337 "mediaSingle" => {
3338 if let Some(ref content) = node.content {
3339 for child in content {
3340 if child.node_type == "media" {
3341 render_media(child, node.attrs.as_ref(), output, opts);
3342 }
3343 }
3344 for child in content {
3345 if child.node_type == "caption" {
3346 let mut cap_parts = Vec::new();
3347 if let Some(ref attrs) = child.attrs {
3348 maybe_push_local_id(attrs, &mut cap_parts, opts);
3349 }
3350 if cap_parts.is_empty() {
3351 output.push_str(":::caption\n");
3352 } else {
3353 output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3354 }
3355 if let Some(ref caption_content) = child.content {
3356 for inline in caption_content {
3357 render_inline_node(inline, output, opts);
3358 }
3359 output.push('\n');
3360 }
3361 output.push_str(":::\n");
3362 }
3363 }
3364 }
3365 }
3366 "blockCard" => {
3367 if let Some(ref attrs) = node.attrs {
3368 let url = attrs
3369 .get("url")
3370 .and_then(serde_json::Value::as_str)
3371 .unwrap_or("");
3372 let mut attr_parts = Vec::new();
3373 if url_safe_in_bracket_content(url) {
3374 output.push_str(&format!("::card[{url}]"));
3375 } else {
3376 output.push_str("::card[]");
3378 let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
3379 attr_parts.push(format!("url=\"{escaped}\""));
3380 }
3381 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3382 attr_parts.push(format!("layout={layout}"));
3383 }
3384 if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
3385 attr_parts.push(format!("width={width}"));
3386 }
3387 if !attr_parts.is_empty() {
3388 output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3389 }
3390 output.push('\n');
3391 }
3392 }
3393 "embedCard" => {
3394 if let Some(ref attrs) = node.attrs {
3395 let url = attrs
3396 .get("url")
3397 .and_then(serde_json::Value::as_str)
3398 .unwrap_or("");
3399 output.push_str(&format!("::embed[{url}]"));
3400 let mut attr_parts = Vec::new();
3401 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3402 attr_parts.push(format!("layout={layout}"));
3403 }
3404 if let Some(h) = attrs
3405 .get("originalHeight")
3406 .and_then(serde_json::Value::as_f64)
3407 {
3408 attr_parts.push(format!("originalHeight={}", fmt_f64_attr(h)));
3409 }
3410 if let Some(w) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3411 attr_parts.push(format!("width={}", fmt_f64_attr(w)));
3412 }
3413 if !attr_parts.is_empty() {
3414 output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3415 }
3416 output.push('\n');
3417 }
3418 }
3419 "extension" => {
3420 if let Some(ref attrs) = node.attrs {
3421 let ext_type = attrs
3422 .get("extensionType")
3423 .and_then(serde_json::Value::as_str)
3424 .unwrap_or("");
3425 let ext_key = attrs
3426 .get("extensionKey")
3427 .and_then(serde_json::Value::as_str)
3428 .unwrap_or("");
3429 let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3430 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3431 attr_parts.push(format!("layout={layout}"));
3432 }
3433 if let Some(params) = attrs.get("parameters") {
3434 if let Ok(json_str) = serde_json::to_string(params) {
3435 attr_parts.push(format!("params='{json_str}'"));
3436 }
3437 }
3438 maybe_push_local_id(attrs, &mut attr_parts, opts);
3439 output.push_str(&format!("::extension{{{}}}\n", attr_parts.join(" ")));
3440 }
3441 }
3442 "panel" => {
3443 let panel_type = node
3444 .attrs
3445 .as_ref()
3446 .and_then(|a| a.get("panelType"))
3447 .and_then(serde_json::Value::as_str)
3448 .unwrap_or("info");
3449 let mut attr_parts = vec![format!("type={panel_type}")];
3450 if let Some(ref attrs) = node.attrs {
3451 if let Some(icon) = attrs.get("panelIcon").and_then(serde_json::Value::as_str) {
3452 attr_parts.push(format!("icon=\"{icon}\""));
3453 }
3454 if let Some(color) = attrs.get("panelColor").and_then(serde_json::Value::as_str) {
3455 attr_parts.push(format!("color=\"{color}\""));
3456 }
3457 }
3458 output.push_str(&format!(":::panel{{{}}}\n", attr_parts.join(" ")));
3459 if let Some(ref content) = node.content {
3460 render_block_children(content, output, opts);
3461 }
3462 output.push_str(":::\n");
3463 }
3464 "expand" | "nestedExpand" => {
3465 let directive_name = if node.node_type == "nestedExpand" {
3466 "nested-expand"
3467 } else {
3468 "expand"
3469 };
3470 let mut attr_parts = Vec::new();
3471 if let Some(t) = node
3472 .attrs
3473 .as_ref()
3474 .and_then(|a| a.get("title"))
3475 .and_then(serde_json::Value::as_str)
3476 {
3477 attr_parts.push(format!("title=\"{t}\""));
3478 }
3479 if let Some(ref lid) = node.local_id {
3481 if !opts.strip_local_ids && lid != "00000000-0000-0000-0000-000000000000" {
3482 attr_parts.push(format!("localId={lid}"));
3483 }
3484 } else if let Some(ref attrs) = node.attrs {
3485 maybe_push_local_id(attrs, &mut attr_parts, opts);
3486 }
3487 if let Some(ref params) = node.parameters {
3489 if let Ok(json_str) = serde_json::to_string(params) {
3490 attr_parts.push(format!("params='{json_str}'"));
3491 }
3492 }
3493 if attr_parts.is_empty() {
3494 output.push_str(&format!(":::{directive_name}\n"));
3495 } else {
3496 output.push_str(&format!(
3497 ":::{directive_name}{{{}}}\n",
3498 attr_parts.join(" ")
3499 ));
3500 }
3501 if let Some(ref content) = node.content {
3502 render_block_children(content, output, opts);
3503 }
3504 output.push_str(":::\n");
3505 }
3506 "layoutSection" => {
3507 output.push_str("::::layout\n");
3508 if let Some(ref content) = node.content {
3509 for child in content {
3510 if child.node_type == "layoutColumn" {
3511 let width_str = child
3512 .attrs
3513 .as_ref()
3514 .and_then(|a| a.get("width"))
3515 .and_then(fmt_numeric_attr)
3516 .unwrap_or_else(|| "50".to_string());
3517 let mut parts = vec![format!("width={width_str}")];
3518 if let Some(ref attrs) = child.attrs {
3519 maybe_push_local_id(attrs, &mut parts, opts);
3520 }
3521 output.push_str(&format!(":::column{{{}}}\n", parts.join(" ")));
3522 if let Some(ref col_content) = child.content {
3523 render_block_children(col_content, output, opts);
3524 }
3525 output.push_str(":::\n");
3526 }
3527 }
3528 }
3529 output.push_str("::::\n");
3530 }
3531 "decisionList" => {
3532 output.push_str(":::decisions\n");
3533 if let Some(ref content) = node.content {
3534 for item in content {
3535 output.push_str("- <> ");
3536 render_list_item_content(item, output, opts);
3537 }
3538 }
3539 output.push_str(":::\n");
3540 }
3541 "bodiedExtension" => {
3542 if let Some(ref attrs) = node.attrs {
3543 let ext_type = attrs
3544 .get("extensionType")
3545 .and_then(serde_json::Value::as_str)
3546 .unwrap_or("");
3547 let ext_key = attrs
3548 .get("extensionKey")
3549 .and_then(serde_json::Value::as_str)
3550 .unwrap_or("");
3551 let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3552 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3553 attr_parts.push(format!("layout={layout}"));
3554 }
3555 if let Some(params) = attrs.get("parameters") {
3556 if let Ok(json_str) = serde_json::to_string(params) {
3557 attr_parts.push(format!("params='{json_str}'"));
3558 }
3559 }
3560 maybe_push_local_id(attrs, &mut attr_parts, opts);
3561 output.push_str(&format!(":::extension{{{}}}\n", attr_parts.join(" ")));
3562 if let Some(ref content) = node.content {
3563 render_block_children(content, output, opts);
3564 }
3565 output.push_str(":::\n");
3566 }
3567 }
3568 _ => {
3569 if let Ok(json) = serde_json::to_string_pretty(node) {
3571 output.push_str("```adf-unsupported\n");
3572 output.push_str(&json);
3573 output.push_str("\n```\n");
3574 }
3575 }
3576 }
3577
3578 let mut parts = Vec::new();
3580 if let Some(ref marks) = node.marks {
3581 for mark in marks {
3582 match mark.mark_type.as_str() {
3583 "alignment" => {
3584 if let Some(align) = mark
3585 .attrs
3586 .as_ref()
3587 .and_then(|a| a.get("align"))
3588 .and_then(serde_json::Value::as_str)
3589 {
3590 parts.push(format!("align={align}"));
3591 }
3592 }
3593 "indentation" => {
3594 if let Some(level) = mark
3595 .attrs
3596 .as_ref()
3597 .and_then(|a| a.get("level"))
3598 .and_then(serde_json::Value::as_u64)
3599 {
3600 parts.push(format!("indent={level}"));
3601 }
3602 }
3603 "breakout" => {
3604 if let Some(mode) = mark
3605 .attrs
3606 .as_ref()
3607 .and_then(|a| a.get("mode"))
3608 .and_then(serde_json::Value::as_str)
3609 {
3610 parts.push(format!("breakout={mode}"));
3611 }
3612 if let Some(width) = mark
3613 .attrs
3614 .as_ref()
3615 .and_then(|a| a.get("width"))
3616 .and_then(serde_json::Value::as_u64)
3617 {
3618 parts.push(format!("breakoutWidth={width}"));
3619 }
3620 }
3621 _ => {}
3622 }
3623 }
3624 }
3625 let para_used_directive = node.node_type == "paragraph" && {
3629 let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3630 if is_empty {
3631 true
3632 } else {
3633 let mut buf = String::new();
3634 render_inline_content(node, &mut buf, opts);
3635 buf.trim().is_empty() && !buf.is_empty()
3636 }
3637 };
3638 if !matches!(node.node_type.as_str(), "expand" | "nestedExpand") && !para_used_directive {
3639 if let Some(ref attrs) = node.attrs {
3640 maybe_push_local_id(attrs, &mut parts, opts);
3641 }
3642 }
3643 if node.node_type == "orderedList" {
3648 if let Some(ref attrs) = node.attrs {
3649 if attrs.get("order").and_then(serde_json::Value::as_u64) == Some(1) {
3650 parts.push("order=1".to_string());
3651 }
3652 }
3653 }
3654 if !parts.is_empty() {
3655 output.push_str(&format!("{{{}}}\n", parts.join(" ")));
3656 }
3657}
3658
3659fn render_list_item_content(item: &AdfNode, output: &mut String, opts: &RenderOptions) {
3667 let Some(ref content) = item.content else {
3668 let bare = AdfNode::text("");
3670 emit_list_item_local_ids(item, &bare, output, opts);
3671 output.push('\n');
3672 return;
3673 };
3674 if content.is_empty() {
3675 let bare = AdfNode::text("");
3676 emit_list_item_local_ids(item, &bare, output, opts);
3677 output.push('\n');
3678 return;
3679 }
3680 let first = &content[0];
3681 let rest_start;
3682 if first.node_type == "paragraph" {
3683 let mut buf = String::new();
3684 render_inline_content(first, &mut buf, opts);
3685 let buf = buf.trim_end_matches('\n');
3691 let mut is_first_line = true;
3694 for line in buf.split('\n') {
3695 if is_first_line {
3696 output.push_str(line);
3697 is_first_line = false;
3698 } else {
3699 output.push('\n');
3700 if !line.is_empty() {
3701 output.push_str(" ");
3702 }
3703 output.push_str(line);
3704 }
3705 }
3706 emit_list_item_local_ids(item, first, output, opts);
3708 output.push('\n');
3709 rest_start = 1;
3710 } else if is_inline_node_type(&first.node_type) {
3711 rest_start = content
3713 .iter()
3714 .position(|c| !is_inline_node_type(&c.node_type))
3715 .unwrap_or(content.len());
3716 let mut buf = String::new();
3717 for child in &content[..rest_start] {
3718 render_inline_node(child, &mut buf, opts);
3719 }
3720 let buf = buf.trim_end_matches('\n');
3723 let mut is_first_line = true;
3724 for line in buf.split('\n') {
3725 if is_first_line {
3726 output.push_str(line);
3727 is_first_line = false;
3728 } else {
3729 output.push('\n');
3730 if !line.is_empty() {
3731 output.push_str(" ");
3732 }
3733 output.push_str(line);
3734 }
3735 }
3736 let bare = AdfNode::text("");
3738 emit_list_item_local_ids(item, &bare, output, opts);
3739 output.push('\n');
3740 } else if first.node_type == "taskItem" {
3743 let bare = AdfNode::text("");
3748 emit_list_item_local_ids(item, &bare, output, opts);
3749 output.push('\n');
3750 for child in content {
3751 if child.node_type == "taskItem" {
3752 let state = child
3753 .attrs
3754 .as_ref()
3755 .and_then(|a| a.get("state"))
3756 .and_then(serde_json::Value::as_str)
3757 .unwrap_or("TODO");
3758 let marker = if state == "DONE" { "- [x] " } else { "- [ ] " };
3759 output.push_str(" ");
3760 output.push_str(marker);
3761 render_list_item_content(child, output, opts);
3762 } else {
3763 let mut nested = String::new();
3764 render_block_node(child, &mut nested, opts);
3765 for line in nested.lines() {
3766 output.push_str(" ");
3767 output.push_str(line);
3768 output.push('\n');
3769 }
3770 }
3771 }
3772 return;
3773 } else {
3774 let mut buf = String::new();
3780 render_block_node(first, &mut buf, opts);
3781 let bare = AdfNode::text("");
3782 let mut is_first = true;
3783 for line in buf.lines() {
3784 if is_first {
3785 output.push_str(line);
3786 emit_list_item_local_ids(item, &bare, output, opts);
3787 output.push('\n');
3788 is_first = false;
3789 } else {
3790 output.push_str(" ");
3791 output.push_str(line);
3792 output.push('\n');
3793 }
3794 }
3795 rest_start = 1;
3796 }
3797 let rest = &content[rest_start..];
3798 for (i, child) in rest.iter().enumerate() {
3799 if child.node_type == "paragraph" {
3803 let prev_is_para = if i == 0 {
3804 first.node_type == "paragraph"
3807 } else {
3808 rest[i - 1].node_type == "paragraph"
3809 };
3810 if prev_is_para {
3811 output.push_str(" \n");
3812 }
3813 }
3814 let mut nested = String::new();
3815 render_block_node(child, &mut nested, opts);
3816 for line in nested.lines() {
3817 output.push_str(" ");
3818 output.push_str(line);
3819 output.push('\n');
3820 }
3821 }
3822}
3823
3824fn is_inline_node_type(node_type: &str) -> bool {
3826 matches!(
3827 node_type,
3828 "text"
3829 | "hardBreak"
3830 | "inlineCard"
3831 | "emoji"
3832 | "mention"
3833 | "status"
3834 | "date"
3835 | "placeholder"
3836 | "mediaInline"
3837 )
3838}
3839
3840fn emit_list_item_local_ids(
3843 item: &AdfNode,
3844 paragraph: &AdfNode,
3845 output: &mut String,
3846 opts: &RenderOptions,
3847) {
3848 if opts.strip_local_ids {
3849 return;
3850 }
3851 let mut parts = Vec::new();
3852 if let Some(ref attrs) = item.attrs {
3853 maybe_push_local_id(attrs, &mut parts, opts);
3854 }
3855 if paragraph.node_type == "paragraph" {
3856 let has_real_id = paragraph
3857 .attrs
3858 .as_ref()
3859 .and_then(|a| a.get("localId"))
3860 .and_then(serde_json::Value::as_str)
3861 .filter(|id| !id.is_empty() && *id != "00000000-0000-0000-0000-000000000000");
3862 if let Some(local_id) = has_real_id {
3863 parts.push(format!("paraLocalId={local_id}"));
3864 } else if item.node_type == "taskItem" {
3865 parts.push("paraLocalId=_".to_string());
3869 }
3870 }
3871 if !parts.is_empty() {
3872 output.push_str(&format!(" {{{}}}", parts.join(" ")));
3873 }
3874}
3875
3876fn render_table(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3878 let Some(ref rows) = node.content else {
3879 return;
3880 };
3881
3882 if table_qualifies_for_pipe_syntax(rows) {
3883 render_pipe_table(node, rows, output, opts);
3884 } else {
3885 render_directive_table(node, rows, output, opts);
3886 }
3887}
3888
3889fn table_qualifies_for_pipe_syntax(rows: &[AdfNode]) -> bool {
3896 if rows.iter().any(|n| n.node_type == "caption") {
3898 return false;
3899 }
3900 let mut first_row_has_header = false;
3901 for (row_idx, row) in rows.iter().enumerate() {
3902 let Some(ref cells) = row.content else {
3903 continue;
3904 };
3905 for cell in cells {
3906 if row_idx > 0 && cell.node_type == "tableHeader" {
3908 return false;
3909 }
3910 if row_idx == 0 && cell.node_type == "tableHeader" {
3911 first_row_has_header = true;
3912 }
3913 let Some(ref content) = cell.content else {
3915 continue;
3916 };
3917 if content.len() != 1 || content[0].node_type != "paragraph" {
3918 return false;
3919 }
3920 if cell_contains_hard_break(&content[0]) {
3923 return false;
3924 }
3925 if cell.marks.as_ref().is_some_and(|m| !m.is_empty()) {
3928 return false;
3929 }
3930 if content[0]
3933 .attrs
3934 .as_ref()
3935 .and_then(|a| a.get("localId"))
3936 .is_some()
3937 {
3938 return false;
3939 }
3940 }
3941 }
3942 first_row_has_header
3945}
3946
3947fn cell_contains_hard_break(paragraph: &AdfNode) -> bool {
3949 paragraph
3950 .content
3951 .as_ref()
3952 .is_some_and(|nodes| nodes.iter().any(|n| n.node_type == "hardBreak"))
3953}
3954
3955fn render_pipe_table(node: &AdfNode, rows: &[AdfNode], output: &mut String, opts: &RenderOptions) {
3957 for (row_idx, row) in rows.iter().enumerate() {
3958 let Some(ref cells) = row.content else {
3959 continue;
3960 };
3961
3962 output.push('|');
3963 for cell in cells {
3964 output.push(' ');
3965 let mut cell_buf = String::new();
3966 render_cell_attrs_prefix(cell, &mut cell_buf);
3967 render_inline_content_from_first_paragraph(cell, &mut cell_buf, opts);
3968 output.push_str(&escape_pipes_in_cell(&cell_buf));
3969 output.push_str(" |");
3970 }
3971 output.push('\n');
3972
3973 if row_idx == 0 {
3975 output.push('|');
3976 for cell in cells {
3977 let align = get_cell_paragraph_alignment(cell);
3978 match align {
3979 Some("center") => output.push_str(" :---: |"),
3980 Some("end") => output.push_str(" ---: |"),
3981 _ => output.push_str(" --- |"),
3982 }
3983 }
3984 output.push('\n');
3985 }
3986 }
3987
3988 render_table_level_attrs(node, output, opts);
3990}
3991
3992fn render_directive_table(
3994 node: &AdfNode,
3995 rows: &[AdfNode],
3996 output: &mut String,
3997 opts: &RenderOptions,
3998) {
3999 let mut attr_parts = Vec::new();
4001 if let Some(ref attrs) = node.attrs {
4002 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
4003 attr_parts.push(format!("layout={layout}"));
4004 }
4005 if let Some(numbered) = attrs
4006 .get("isNumberColumnEnabled")
4007 .and_then(serde_json::Value::as_bool)
4008 {
4009 if numbered {
4010 attr_parts.push("numbered".to_string());
4011 } else {
4012 attr_parts.push("numbered=false".to_string());
4013 }
4014 }
4015 if let Some(tw) = attrs.get("width").and_then(serde_json::Value::as_f64) {
4016 let tw_str = if tw.fract() == 0.0 {
4017 (tw as u64).to_string()
4018 } else {
4019 tw.to_string()
4020 };
4021 attr_parts.push(format!("width={tw_str}"));
4022 }
4023 maybe_push_local_id(attrs, &mut attr_parts, opts);
4024 }
4025 if attr_parts.is_empty() {
4026 output.push_str("::::table\n");
4027 } else {
4028 output.push_str(&format!("::::table{{{}}}\n", attr_parts.join(" ")));
4029 }
4030
4031 for row in rows {
4032 if row.node_type == "caption" {
4033 let mut cap_parts = Vec::new();
4034 if let Some(ref attrs) = row.attrs {
4035 maybe_push_local_id(attrs, &mut cap_parts, opts);
4036 }
4037 if cap_parts.is_empty() {
4038 output.push_str(":::caption\n");
4039 } else {
4040 output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
4041 }
4042 if let Some(ref content) = row.content {
4043 for child in content {
4044 render_inline_node(child, output, opts);
4045 }
4046 output.push('\n');
4047 }
4048 output.push_str(":::\n");
4049 continue;
4050 }
4051 let Some(ref cells) = row.content else {
4052 continue;
4053 };
4054 let mut tr_attrs = Vec::new();
4056 if let Some(ref attrs) = row.attrs {
4057 maybe_push_local_id(attrs, &mut tr_attrs, opts);
4058 }
4059 if tr_attrs.is_empty() {
4060 output.push_str(":::tr\n");
4061 } else {
4062 output.push_str(&format!(":::tr{{{}}}\n", tr_attrs.join(" ")));
4063 }
4064 for cell in cells {
4065 let directive_name = if cell.node_type == "tableHeader" {
4066 "th"
4067 } else {
4068 "td"
4069 };
4070 let mut cell_attr_str = build_cell_attrs_string(cell);
4071 if let Some(ref attrs) = cell.attrs {
4073 let mut lid_parts = Vec::new();
4074 maybe_push_local_id(attrs, &mut lid_parts, opts);
4075 if !lid_parts.is_empty() {
4076 if !cell_attr_str.is_empty() {
4077 cell_attr_str.push(' ');
4078 }
4079 cell_attr_str.push_str(&lid_parts.join(" "));
4080 }
4081 }
4082 if let Some(ref marks) = cell.marks {
4084 for mark in marks {
4085 if mark.mark_type == "border" {
4086 if let Some(ref attrs) = mark.attrs {
4087 if let Some(color) =
4088 attrs.get("color").and_then(serde_json::Value::as_str)
4089 {
4090 if !cell_attr_str.is_empty() {
4091 cell_attr_str.push(' ');
4092 }
4093 cell_attr_str.push_str(&format!("border-color={color}"));
4094 }
4095 if let Some(size) =
4096 attrs.get("size").and_then(serde_json::Value::as_u64)
4097 {
4098 if !cell_attr_str.is_empty() {
4099 cell_attr_str.push(' ');
4100 }
4101 cell_attr_str.push_str(&format!("border-size={size}"));
4102 }
4103 }
4104 }
4105 }
4106 }
4107 let has_marks = cell.marks.as_ref().is_some_and(|m| !m.is_empty());
4108 if cell_attr_str.is_empty() && cell.attrs.is_none() && !has_marks {
4109 output.push_str(&format!(":::{directive_name}\n"));
4110 } else {
4111 output.push_str(&format!(":::{directive_name}{{{cell_attr_str}}}\n"));
4112 }
4113 if let Some(ref content) = cell.content {
4114 render_block_children(content, output, opts);
4115 }
4116 output.push_str(":::\n");
4117 }
4118 output.push_str(":::\n");
4119 }
4120
4121 output.push_str("::::\n");
4122}
4123
4124fn needs_attr_quoting(value: &str) -> bool {
4128 value.contains(|c: char| c.is_whitespace() || c == '}' || c == '(' || c == ')' || c == ',')
4129}
4130
4131fn build_cell_attrs_string(cell: &AdfNode) -> String {
4133 let Some(ref attrs) = cell.attrs else {
4134 return String::new();
4135 };
4136 let mut parts = Vec::new();
4137 if let Some(colspan) = attrs.get("colspan").and_then(serde_json::Value::as_u64) {
4138 parts.push(format!("colspan={colspan}"));
4139 }
4140 if let Some(rowspan) = attrs.get("rowspan").and_then(serde_json::Value::as_u64) {
4141 parts.push(format!("rowspan={rowspan}"));
4142 }
4143 if let Some(bg) = attrs.get("background").and_then(serde_json::Value::as_str) {
4144 if needs_attr_quoting(bg) {
4145 let escaped = bg.replace('\\', "\\\\").replace('"', "\\\"");
4146 parts.push(format!("bg=\"{escaped}\""));
4147 } else {
4148 parts.push(format!("bg={bg}"));
4149 }
4150 }
4151 if let Some(colwidth) = attrs.get("colwidth").and_then(serde_json::Value::as_array) {
4152 let widths: Vec<String> = colwidth
4153 .iter()
4154 .filter_map(|v| {
4155 if let Some(n) = v.as_u64() {
4158 Some(n.to_string())
4159 } else if let Some(n) = v.as_f64() {
4160 if n.fract() == 0.0 {
4161 format!("{n:.1}")
4162 } else {
4163 n.to_string()
4164 }
4165 .into()
4166 } else {
4167 None
4168 }
4169 })
4170 .collect();
4171 if !widths.is_empty() {
4172 parts.push(format!("colwidth={}", widths.join(",")));
4173 }
4174 }
4175 parts.join(" ")
4176}
4177
4178fn render_cell_attrs_prefix(cell: &AdfNode, output: &mut String) {
4180 let Some(ref _attrs) = cell.attrs else {
4181 return;
4182 };
4183 let attr_str = build_cell_attrs_string(cell);
4184 if attr_str.is_empty() {
4185 output.push_str("{} ");
4186 } else {
4187 output.push_str(&format!("{{{attr_str}}} "));
4188 }
4189}
4190
4191fn get_cell_paragraph_alignment(cell: &AdfNode) -> Option<&str> {
4193 let content = cell.content.as_ref()?;
4194 let para = content.first()?;
4195 let marks = para.marks.as_ref()?;
4196 marks.iter().find_map(|m| {
4197 if m.mark_type == "alignment" {
4198 m.attrs
4199 .as_ref()
4200 .and_then(|a| a.get("align"))
4201 .and_then(serde_json::Value::as_str)
4202 } else {
4203 None
4204 }
4205 })
4206}
4207
4208fn render_table_level_attrs(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4210 if let Some(ref attrs) = node.attrs {
4211 let mut parts = Vec::new();
4212 if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
4213 parts.push(format!("layout={layout}"));
4214 }
4215 if let Some(numbered) = attrs
4216 .get("isNumberColumnEnabled")
4217 .and_then(serde_json::Value::as_bool)
4218 {
4219 if numbered {
4220 parts.push("numbered".to_string());
4221 } else {
4222 parts.push("numbered=false".to_string());
4223 }
4224 }
4225 if let Some(tw_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4226 parts.push(format!("width={tw_str}"));
4227 }
4228 maybe_push_local_id(attrs, &mut parts, opts);
4229 if !parts.is_empty() {
4230 output.push_str(&format!("{{{}}}\n", parts.join(" ")));
4231 }
4232 }
4233}
4234
4235fn render_inline_content_from_first_paragraph(
4237 cell: &AdfNode,
4238 output: &mut String,
4239 opts: &RenderOptions,
4240) {
4241 if let Some(ref content) = cell.content {
4242 if let Some(first) = content.first() {
4243 if first.node_type == "paragraph" {
4244 render_inline_content(first, output, opts);
4245 }
4246 }
4247 }
4248}
4249
4250fn push_border_mark_attrs(marks: &Option<Vec<AdfMark>>, parts: &mut Vec<String>) {
4252 if let Some(ref marks) = marks {
4253 for mark in marks {
4254 if mark.mark_type == "border" {
4255 if let Some(ref attrs) = mark.attrs {
4256 if let Some(color) = attrs.get("color").and_then(serde_json::Value::as_str) {
4257 parts.push(format!("border-color={color}"));
4258 }
4259 if let Some(size) = attrs.get("size").and_then(serde_json::Value::as_u64) {
4260 parts.push(format!("border-size={size}"));
4261 }
4262 }
4263 }
4264 }
4265 }
4266}
4267
4268fn render_media(
4270 node: &AdfNode,
4271 parent_attrs: Option<&serde_json::Value>,
4272 output: &mut String,
4273 opts: &RenderOptions,
4274) {
4275 if let Some(ref attrs) = node.attrs {
4276 let media_type = attrs
4277 .get("type")
4278 .and_then(serde_json::Value::as_str)
4279 .unwrap_or("external");
4280 let alt = attrs
4281 .get("alt")
4282 .and_then(serde_json::Value::as_str)
4283 .unwrap_or("");
4284
4285 if media_type == "file" {
4286 output.push_str(&format!("![{alt}]()"));
4288 let mut parts = vec!["type=file".to_string()];
4289 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4290 parts.push(format_kv("id", id));
4291 }
4292 if let Some(collection) = attrs.get("collection").and_then(serde_json::Value::as_str) {
4293 parts.push(format_kv("collection", collection));
4294 }
4295 if let Some(occurrence_key) = attrs
4296 .get("occurrenceKey")
4297 .and_then(serde_json::Value::as_str)
4298 {
4299 parts.push(format_kv("occurrenceKey", occurrence_key));
4300 }
4301 if let Some(height_str) = attrs.get("height").and_then(fmt_numeric_attr) {
4302 parts.push(format!("height={height_str}"));
4303 }
4304 if let Some(width_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4305 parts.push(format!("width={width_str}"));
4306 }
4307 maybe_push_local_id(attrs, &mut parts, opts);
4308 if let Some(p_attrs) = parent_attrs {
4310 if let Some(layout) = p_attrs.get("layout").and_then(serde_json::Value::as_str) {
4311 if layout != "center" {
4312 parts.push(format!("layout={layout}"));
4313 }
4314 }
4315 if let Some(ms_width_str) = p_attrs.get("width").and_then(fmt_numeric_attr) {
4316 parts.push(format!("mediaWidth={ms_width_str}"));
4317 }
4318 if let Some(wt) = p_attrs.get("widthType").and_then(serde_json::Value::as_str) {
4319 parts.push(format!("widthType={wt}"));
4320 }
4321 if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4322 parts.push(format!("mode={mode}"));
4323 }
4324 }
4325 push_border_mark_attrs(&node.marks, &mut parts);
4326 output.push_str(&format!("{{{}}}", parts.join(" ")));
4327 } else {
4328 let url = attrs
4330 .get("url")
4331 .and_then(serde_json::Value::as_str)
4332 .unwrap_or("");
4333 output.push_str(&format!(""));
4334
4335 {
4337 let mut parts = Vec::new();
4338 if let Some(p_attrs) = parent_attrs {
4339 let layout = p_attrs.get("layout").and_then(serde_json::Value::as_str);
4340 let width_str = p_attrs.get("width").and_then(fmt_numeric_attr);
4341 let width_type = p_attrs.get("widthType").and_then(serde_json::Value::as_str);
4342 if let Some(l) = layout {
4343 if l != "center" {
4344 parts.push(format!("layout={l}"));
4345 }
4346 }
4347 if let Some(w) = width_str {
4348 parts.push(format!("width={w}"));
4349 }
4350 if let Some(wt) = width_type {
4351 parts.push(format!("widthType={wt}"));
4352 }
4353 if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4354 parts.push(format!("mode={mode}"));
4355 }
4356 }
4357 maybe_push_local_id(attrs, &mut parts, opts);
4358 push_border_mark_attrs(&node.marks, &mut parts);
4359 if !parts.is_empty() {
4360 output.push_str(&format!("{{{}}}", parts.join(" ")));
4361 }
4362 }
4363 }
4364
4365 output.push('\n');
4366 }
4367}
4368
4369fn render_inline_content(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4371 if let Some(ref content) = node.content {
4372 for child in content {
4373 render_inline_node(child, output, opts);
4374 }
4375 }
4376}
4377
4378fn render_inline_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4380 match node.node_type.as_str() {
4381 "text" => {
4382 let text = node.text.as_deref().unwrap_or("");
4383 let marks = node.marks.as_deref().unwrap_or(&[]);
4384 let has_code = marks.iter().any(|m| m.mark_type == "code");
4385 let owned;
4390 let text = if !has_code {
4391 owned = text.replace('\\', "\\\\");
4392 owned.as_str()
4393 } else {
4394 text
4395 };
4396 let owned_nl;
4401 let text = if text.contains('\n') {
4402 owned_nl = text.replace('\n', "\\n");
4403 owned_nl.as_str()
4404 } else {
4405 text
4406 };
4407 let owned_ts;
4413 let text = if !has_code && text.ends_with(" ") {
4414 let mut s = text.to_string();
4415 s.insert(s.len() - 1, '\\');
4417 owned_ts = s;
4418 owned_ts.as_str()
4419 } else {
4420 text
4421 };
4422 render_marked_text(text, marks, output);
4423 }
4424 "hardBreak" => {
4425 output.push_str("\\\n");
4426 }
4427 other => {
4428 let mut body = String::new();
4432 render_non_text_inline_body(other, node, &mut body, opts);
4433
4434 let annotations: Vec<&AdfMark> = node
4435 .marks
4436 .as_deref()
4437 .unwrap_or(&[])
4438 .iter()
4439 .filter(|m| m.mark_type == "annotation")
4440 .collect();
4441
4442 if annotations.is_empty() {
4443 output.push_str(&body);
4444 } else {
4445 let mut attr_parts = Vec::new();
4446 for ann in &annotations {
4447 if let Some(ref attrs) = ann.attrs {
4448 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4449 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4450 attr_parts.push(format!("annotation-id=\"{escaped}\""));
4451 }
4452 if let Some(at) = attrs
4453 .get("annotationType")
4454 .and_then(serde_json::Value::as_str)
4455 {
4456 attr_parts.push(format!("annotation-type={at}"));
4457 }
4458 }
4459 }
4460 output.push('[');
4461 output.push_str(&body);
4462 output.push_str("]{");
4463 output.push_str(&attr_parts.join(" "));
4464 output.push('}');
4465 }
4466 }
4467 }
4468}
4469
4470fn render_non_text_inline_body(
4472 node_type: &str,
4473 node: &AdfNode,
4474 output: &mut String,
4475 opts: &RenderOptions,
4476) {
4477 match node_type {
4478 "inlineCard" => {
4479 if let Some(ref attrs) = node.attrs {
4480 if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4481 let mut attr_parts = Vec::new();
4482 if url_safe_in_bracket_content(url) {
4483 output.push_str(":card[");
4484 output.push_str(url);
4485 output.push(']');
4486 } else {
4487 output.push_str(":card[]");
4491 let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
4492 attr_parts.push(format!("url=\"{escaped}\""));
4493 }
4494 maybe_push_local_id(attrs, &mut attr_parts, opts);
4495 if !attr_parts.is_empty() {
4496 output.push('{');
4497 output.push_str(&attr_parts.join(" "));
4498 output.push('}');
4499 }
4500 }
4501 }
4502 }
4503 "emoji" => {
4504 if let Some(ref attrs) = node.attrs {
4505 if let Some(short_name) = attrs.get("shortName").and_then(serde_json::Value::as_str)
4506 {
4507 output.push(':');
4508 let name = short_name.strip_prefix(':').unwrap_or(short_name);
4509 let name = name.strip_suffix(':').unwrap_or(name);
4510 output.push_str(name);
4511 output.push(':');
4512
4513 let mut parts = Vec::new();
4514 let escaped_sn = short_name.replace('\\', "\\\\").replace('"', "\\\"");
4515 parts.push(format!("shortName=\"{escaped_sn}\""));
4516 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4517 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4518 parts.push(format!("id=\"{escaped}\""));
4519 }
4520 if let Some(text) = attrs.get("text").and_then(serde_json::Value::as_str) {
4521 let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
4522 parts.push(format!("text=\"{escaped}\""));
4523 }
4524 maybe_push_local_id(attrs, &mut parts, opts);
4525 output.push('{');
4526 output.push_str(&parts.join(" "));
4527 output.push('}');
4528 }
4529 }
4530 }
4531 "status" => {
4532 if let Some(ref attrs) = node.attrs {
4533 let text = attrs
4534 .get("text")
4535 .and_then(serde_json::Value::as_str)
4536 .unwrap_or("");
4537 let color = attrs
4538 .get("color")
4539 .and_then(serde_json::Value::as_str)
4540 .unwrap_or("neutral");
4541 let mut attr_parts = vec![format!("color={color}")];
4542 if let Some(style) = attrs.get("style").and_then(serde_json::Value::as_str) {
4543 attr_parts.push(format!("style={style}"));
4544 }
4545 maybe_push_local_id(attrs, &mut attr_parts, opts);
4546 output.push_str(&format!(":status[{text}]{{{}}}", attr_parts.join(" ")));
4547 }
4548 }
4549 "date" => {
4550 if let Some(ref attrs) = node.attrs {
4551 if let Some(timestamp) = attrs.get("timestamp").and_then(serde_json::Value::as_str)
4552 {
4553 let display = epoch_ms_to_iso_date(timestamp);
4554 let mut attr_parts = vec![format!("timestamp={timestamp}")];
4555 maybe_push_local_id(attrs, &mut attr_parts, opts);
4556 output.push_str(&format!(":date[{display}]{{{}}}", attr_parts.join(" ")));
4557 }
4558 }
4559 }
4560 "mention" => {
4561 if let Some(ref attrs) = node.attrs {
4562 let id = attrs
4563 .get("id")
4564 .and_then(serde_json::Value::as_str)
4565 .unwrap_or("");
4566 let text = attrs
4567 .get("text")
4568 .and_then(serde_json::Value::as_str)
4569 .unwrap_or("");
4570 let mut attr_parts = vec![format!("id={id}")];
4571 if let Some(ut) = attrs.get("userType").and_then(serde_json::Value::as_str) {
4572 attr_parts.push(format!("userType={ut}"));
4573 }
4574 if let Some(al) = attrs.get("accessLevel").and_then(serde_json::Value::as_str) {
4575 attr_parts.push(format!("accessLevel={al}"));
4576 }
4577 maybe_push_local_id(attrs, &mut attr_parts, opts);
4578 output.push_str(&format!(":mention[{text}]{{{}}}", attr_parts.join(" ")));
4579 }
4580 }
4581 "placeholder" => {
4582 if let Some(ref attrs) = node.attrs {
4583 let text = attrs
4584 .get("text")
4585 .and_then(serde_json::Value::as_str)
4586 .unwrap_or("");
4587 output.push_str(&format!(":placeholder[{text}]"));
4588 }
4589 }
4590 "inlineExtension" => {
4591 if let Some(ref attrs) = node.attrs {
4592 let ext_type = attrs
4593 .get("extensionType")
4594 .and_then(serde_json::Value::as_str)
4595 .unwrap_or("");
4596 let ext_key = attrs
4597 .get("extensionKey")
4598 .and_then(serde_json::Value::as_str)
4599 .unwrap_or("");
4600 let fallback = node.text.as_deref().unwrap_or("");
4601 output.push_str(&format!(
4602 ":extension[{fallback}]{{type={ext_type} key={ext_key}}}"
4603 ));
4604 }
4605 }
4606 "mediaInline" => {
4607 if let Some(ref attrs) = node.attrs {
4608 let mut attr_parts = Vec::new();
4609 if let Some(media_type) = attrs.get("type").and_then(serde_json::Value::as_str) {
4610 attr_parts.push(format_kv("type", media_type));
4611 }
4612 if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4613 attr_parts.push(format_kv("id", id));
4614 }
4615 if let Some(collection) =
4616 attrs.get("collection").and_then(serde_json::Value::as_str)
4617 {
4618 attr_parts.push(format_kv("collection", collection));
4619 }
4620 if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4621 attr_parts.push(format_kv("url", url));
4622 }
4623 if let Some(alt) = attrs.get("alt").and_then(serde_json::Value::as_str) {
4624 attr_parts.push(format_kv("alt", alt));
4625 }
4626 if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
4627 attr_parts.push(format!("width={width}"));
4628 }
4629 if let Some(height) = attrs.get("height").and_then(serde_json::Value::as_u64) {
4630 attr_parts.push(format!("height={height}"));
4631 }
4632 maybe_push_local_id(attrs, &mut attr_parts, opts);
4633 output.push_str(&format!(":media-inline[]{{{}}}", attr_parts.join(" ")));
4634 }
4635 }
4636 _ => {
4637 output.push_str(&format!("<!-- unsupported inline: {} -->", node.node_type));
4638 }
4639 }
4640}
4641
4642fn render_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4655 if marks.iter().any(|m| m.mark_type == "code") {
4656 render_code_marked_text(text, marks, output);
4657 return;
4658 }
4659
4660 let has_link = marks.iter().any(|m| m.mark_type == "link");
4661 let has_strong = marks.iter().any(|m| m.mark_type == "strong");
4662 let has_em = marks.iter().any(|m| m.mark_type == "em");
4663
4664 if marks.len() == 2 && marks[0].mark_type == "strong" && marks[1].mark_type == "em" {
4668 let escaped = escape_emphasis_markers(text);
4669 let escaped = escape_emoji_shortcodes(&escaped);
4670 let escaped = escape_backticks(&escaped);
4671 let escaped = escape_bare_urls(&escaped);
4672 output.push_str("***");
4673 output.push_str(&escaped);
4674 output.push_str("***");
4675 return;
4676 }
4677
4678 let em_delim = if has_strong && has_em { "_" } else { "*" };
4682
4683 let escaped = if em_delim == "_" {
4686 escape_emphasis_markers_with_underscore(text)
4687 } else {
4688 escape_emphasis_markers(text)
4689 };
4690 let escaped = escape_emoji_shortcodes(&escaped);
4691 let escaped = escape_backticks(&escaped);
4692 let escaped = escape_bare_urls(&escaped);
4700 let escaped = if has_link {
4701 escape_link_brackets(&escaped)
4702 } else {
4703 escaped
4704 };
4705
4706 let mut wrappers: Vec<(String, String)> = Vec::new();
4712 let mut i = 0;
4713 while i < marks.len() {
4714 match marks[i].mark_type.as_str() {
4715 "em" => {
4716 wrappers.push((em_delim.to_string(), em_delim.to_string()));
4717 i += 1;
4718 }
4719 "strong" => {
4720 wrappers.push(("**".to_string(), "**".to_string()));
4721 i += 1;
4722 }
4723 "strike" => {
4724 wrappers.push(("~~".to_string(), "~~".to_string()));
4725 i += 1;
4726 }
4727 "link" => {
4728 let href = link_href(&marks[i]);
4729 wrappers.push(("[".to_string(), format!("]({href})")));
4730 i += 1;
4731 }
4732 "textColor" | "backgroundColor" | "subsup" => {
4733 let start = i;
4734 while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4735 i += 1;
4736 }
4737 emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4738 }
4739 "underline" | "annotation" => {
4740 let start = i;
4741 while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4742 i += 1;
4743 }
4744 emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4745 }
4746 _ => {
4747 i += 1;
4748 }
4749 }
4750 }
4751
4752 let mut result = escaped;
4754 for (open, close) in wrappers.iter().rev() {
4755 result.insert_str(0, open);
4756 result.push_str(close);
4757 }
4758 output.push_str(&result);
4759}
4760
4761fn render_code_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4769 let link_mark = marks.iter().find(|m| m.mark_type == "link");
4770
4771 let mut code_str = String::new();
4772 if let Some(link_mark) = link_mark {
4773 let href = link_href(link_mark);
4774 code_str.push('[');
4775 render_inline_code(text, &mut code_str);
4776 code_str.push_str("](");
4777 code_str.push_str(href);
4778 code_str.push(')');
4779 } else {
4780 render_inline_code(text, &mut code_str);
4781 }
4782
4783 let mut wrappers: Vec<(String, String)> = Vec::new();
4786 let mut i = 0;
4787 while i < marks.len() {
4788 match marks[i].mark_type.as_str() {
4789 "textColor" | "backgroundColor" | "subsup" => {
4790 let start = i;
4791 while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4792 i += 1;
4793 }
4794 emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4795 }
4796 "underline" | "annotation" => {
4797 let start = i;
4798 while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4799 i += 1;
4800 }
4801 emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4802 }
4803 _ => {
4804 i += 1;
4805 }
4806 }
4807 }
4808
4809 let mut result = code_str;
4811 for (open, close) in wrappers.iter().rev() {
4812 result.insert_str(0, open);
4813 result.push_str(close);
4814 }
4815 output.push_str(&result);
4816}
4817
4818fn collect_span_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4820 match mark.mark_type.as_str() {
4821 "textColor" => {
4822 if let Some(c) = mark
4823 .attrs
4824 .as_ref()
4825 .and_then(|a| a.get("color"))
4826 .and_then(serde_json::Value::as_str)
4827 {
4828 attrs.push(format!("color={c}"));
4829 }
4830 }
4831 "backgroundColor" => {
4832 if let Some(c) = mark
4833 .attrs
4834 .as_ref()
4835 .and_then(|a| a.get("color"))
4836 .and_then(serde_json::Value::as_str)
4837 {
4838 attrs.push(format!("bg={c}"));
4839 }
4840 }
4841 "subsup" => {
4842 if let Some(kind) = mark
4843 .attrs
4844 .as_ref()
4845 .and_then(|a| a.get("type"))
4846 .and_then(serde_json::Value::as_str)
4847 {
4848 attrs.push(kind.to_string());
4849 }
4850 }
4851 _ => {}
4852 }
4853}
4854
4855fn collect_bracketed_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4857 match mark.mark_type.as_str() {
4858 "underline" => attrs.push("underline".to_string()),
4859 "annotation" => {
4860 if let Some(ref a) = mark.attrs {
4861 if let Some(id) = a.get("id").and_then(serde_json::Value::as_str) {
4862 let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4863 attrs.push(format!("annotation-id=\"{escaped}\""));
4864 }
4865 if let Some(at) = a.get("annotationType").and_then(serde_json::Value::as_str) {
4866 attrs.push(format!("annotation-type={at}"));
4867 }
4868 }
4869 }
4870 _ => {}
4871 }
4872}
4873
4874fn is_span_attr_mark(mark_type: &str) -> bool {
4875 matches!(mark_type, "textColor" | "backgroundColor" | "subsup")
4876}
4877
4878fn is_bracketed_span_mark(mark_type: &str) -> bool {
4879 matches!(mark_type, "underline" | "annotation")
4880}
4881
4882fn span_attr_order(mark_type: &str) -> u8 {
4886 match mark_type {
4887 "textColor" => 0,
4888 "backgroundColor" => 1,
4889 "subsup" => 2,
4890 _ => u8::MAX,
4891 }
4892}
4893
4894fn span_run_is_canonical(run: &[AdfMark]) -> bool {
4899 let mut prev = 0;
4900 for m in run {
4901 let order = span_attr_order(&m.mark_type);
4902 if order == u8::MAX || order < prev {
4903 return false;
4904 }
4905 prev = order;
4906 }
4907 true
4908}
4909
4910fn bracketed_run_is_canonical(run: &[AdfMark]) -> bool {
4915 let mut seen_annotation = false;
4916 for m in run {
4917 match m.mark_type.as_str() {
4918 "underline" => {
4919 if seen_annotation {
4920 return false;
4921 }
4922 }
4923 "annotation" => seen_annotation = true,
4924 _ => return false,
4925 }
4926 }
4927 true
4928}
4929
4930fn emit_span_attr_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4934 if span_run_is_canonical(run) {
4935 let mut attrs = Vec::new();
4936 for m in run {
4937 collect_span_attr(m, &mut attrs);
4938 }
4939 wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4940 return;
4941 }
4942 for m in run {
4943 let mut attrs = Vec::new();
4944 collect_span_attr(m, &mut attrs);
4945 wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4946 }
4947}
4948
4949fn emit_bracketed_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4953 if bracketed_run_is_canonical(run) {
4954 let mut attrs = Vec::new();
4955 for m in run {
4956 collect_bracketed_attr(m, &mut attrs);
4957 }
4958 wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4959 return;
4960 }
4961 for m in run {
4962 let mut attrs = Vec::new();
4963 collect_bracketed_attr(m, &mut attrs);
4964 wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4965 }
4966}
4967
4968fn link_href(mark: &AdfMark) -> &str {
4970 mark.attrs
4971 .as_ref()
4972 .and_then(|a| a.get("href"))
4973 .and_then(serde_json::Value::as_str)
4974 .unwrap_or("")
4975}
4976
4977#[cfg(test)]
4978#[allow(
4979 clippy::unwrap_used,
4980 clippy::expect_used,
4981 clippy::needless_update,
4982 clippy::needless_collect,
4983 duplicate_macro_attributes
4984)]
4985mod tests {
4986 use super::*;
4987
4988 #[test]
4991 fn adf_to_plain_text_single_paragraph() {
4992 let doc = markdown_to_adf("Hello world").unwrap();
4993 assert_eq!(adf_to_plain_text(&doc), "Hello world");
4994 }
4995
4996 #[test]
4997 fn adf_to_plain_text_multiple_paragraphs_space_separated() {
4998 let doc = markdown_to_adf("Alpha\n\nBeta").unwrap();
4999 let plain = adf_to_plain_text(&doc);
5000 assert!(plain.contains("Alpha"));
5002 assert!(plain.contains("Beta"));
5003 assert_eq!(plain, "Alpha Beta");
5004 }
5005
5006 #[test]
5007 fn adf_to_plain_text_drops_marks_but_keeps_text() {
5008 let doc = markdown_to_adf("Hello **bold** world").unwrap();
5009 assert_eq!(adf_to_plain_text(&doc), "Hello bold world");
5010 }
5011
5012 #[test]
5013 fn adf_to_plain_text_empty_doc() {
5014 let doc = AdfDocument::new();
5015 assert_eq!(adf_to_plain_text(&doc), "");
5016 }
5017
5018 #[test]
5019 fn adf_to_plain_text_leading_empty_block_emits_no_extra_space() {
5020 let doc = AdfDocument {
5023 version: 1,
5024 doc_type: "doc".to_string(),
5025 content: vec![
5026 AdfNode {
5027 node_type: "paragraph".to_string(),
5028 attrs: None,
5029 content: Some(vec![]),
5030 text: None,
5031 marks: None,
5032 local_id: None,
5033 parameters: None,
5034 },
5035 AdfNode {
5036 node_type: "paragraph".to_string(),
5037 attrs: None,
5038 content: Some(vec![AdfNode::text("Hello")]),
5039 text: None,
5040 marks: None,
5041 local_id: None,
5042 parameters: None,
5043 },
5044 ],
5045 };
5046 assert_eq!(adf_to_plain_text(&doc), "Hello");
5047 }
5048
5049 #[test]
5052 fn paragraph() {
5053 let doc = markdown_to_adf("Hello world").unwrap();
5054 assert_eq!(doc.content.len(), 1);
5055 assert_eq!(doc.content[0].node_type, "paragraph");
5056 }
5057
5058 #[test]
5059 fn heading_levels() {
5060 for level in 1..=6 {
5061 let hashes = "#".repeat(level);
5062 let md = format!("{hashes} Title");
5063 let doc = markdown_to_adf(&md).unwrap();
5064 assert_eq!(doc.content[0].node_type, "heading");
5065 let attrs = doc.content[0].attrs.as_ref().unwrap();
5066 assert_eq!(attrs["level"], level as u64);
5067 }
5068 }
5069
5070 #[test]
5073 fn heading_inline_code_mark_stripped() {
5074 let doc = markdown_to_adf("### `GET /api`").unwrap();
5077 let heading = &doc.content[0];
5078 assert_eq!(heading.node_type, "heading");
5079 let content = heading.content.as_ref().unwrap();
5080 assert_eq!(content.len(), 1);
5081 assert_eq!(content[0].text.as_deref(), Some("GET /api"));
5082 assert!(
5083 content[0].marks.is_none(),
5084 "expected no marks, got: {:?}",
5085 content[0].marks
5086 );
5087 }
5088
5089 #[test]
5090 fn heading_code_strip_preserves_sibling_strong() {
5091 let doc = markdown_to_adf("### **`x`**").unwrap();
5093 let content = doc.content[0].content.as_ref().unwrap();
5094 let marks = content[0].marks.as_ref().unwrap();
5095 assert!(marks.iter().any(|m| m.mark_type == "strong"));
5096 assert!(!marks.iter().any(|m| m.mark_type == "code"));
5097 }
5098
5099 #[test]
5100 fn heading_code_strip_preserves_link() {
5101 let doc = markdown_to_adf("### [`x`](https://e.com)").unwrap();
5103 let content = doc.content[0].content.as_ref().unwrap();
5104 let marks = content[0].marks.as_ref().unwrap();
5105 assert!(marks.iter().any(|m| m.mark_type == "link"));
5106 assert!(!marks.iter().any(|m| m.mark_type == "code"));
5107 }
5108
5109 #[test]
5110 fn heading_with_code_now_passes_validation() {
5111 let doc = markdown_to_adf("### `GET /api`").unwrap();
5114 let violations = crate::atlassian::adf_schema::validate_document(&doc);
5115 assert!(violations.is_empty(), "got: {violations:?}");
5116 }
5117
5118 #[test]
5119 fn paragraph_inline_code_mark_preserved() {
5120 let doc = markdown_to_adf("`x`").unwrap();
5123 let content = doc.content[0].content.as_ref().unwrap();
5124 let marks = content[0].marks.as_ref().unwrap();
5125 assert!(marks.iter().any(|m| m.mark_type == "code"));
5126 }
5127
5128 #[test]
5129 fn code_block() {
5130 let md = "```rust\nfn main() {}\n```";
5131 let doc = markdown_to_adf(md).unwrap();
5132 assert_eq!(doc.content[0].node_type, "codeBlock");
5133 let attrs = doc.content[0].attrs.as_ref().unwrap();
5134 assert_eq!(attrs["language"], "rust");
5135 }
5136
5137 #[test]
5138 fn code_block_no_language() {
5139 let md = "```\nsome code\n```";
5140 let doc = markdown_to_adf(md).unwrap();
5141 assert_eq!(doc.content[0].node_type, "codeBlock");
5142 assert!(doc.content[0].attrs.is_none());
5143 }
5144
5145 #[test]
5146 fn code_block_empty_language() {
5147 let md = "```\"\"\nsome code\n```";
5148 let doc = markdown_to_adf(md).unwrap();
5149 assert_eq!(doc.content[0].node_type, "codeBlock");
5150 let attrs = doc.content[0].attrs.as_ref().unwrap();
5151 assert_eq!(attrs["language"], "");
5152 }
5153
5154 #[test]
5155 fn horizontal_rule() {
5156 let doc = markdown_to_adf("---").unwrap();
5157 assert_eq!(doc.content[0].node_type, "rule");
5158 }
5159
5160 #[test]
5161 fn horizontal_rule_stars() {
5162 let doc = markdown_to_adf("***").unwrap();
5163 assert_eq!(doc.content[0].node_type, "rule");
5164 }
5165
5166 #[test]
5167 fn blockquote() {
5168 let md = "> This is a quote\n> Second line";
5169 let doc = markdown_to_adf(md).unwrap();
5170 assert_eq!(doc.content[0].node_type, "blockquote");
5171 }
5172
5173 #[test]
5174 fn bullet_list() {
5175 let md = "- Item 1\n- Item 2\n- Item 3";
5176 let doc = markdown_to_adf(md).unwrap();
5177 assert_eq!(doc.content[0].node_type, "bulletList");
5178 let items = doc.content[0].content.as_ref().unwrap();
5179 assert_eq!(items.len(), 3);
5180 }
5181
5182 #[test]
5183 fn ordered_list() {
5184 let md = "1. First\n2. Second\n3. Third";
5185 let doc = markdown_to_adf(md).unwrap();
5186 assert_eq!(doc.content[0].node_type, "orderedList");
5187 let items = doc.content[0].content.as_ref().unwrap();
5188 assert_eq!(items.len(), 3);
5189 }
5190
5191 #[test]
5192 fn task_list() {
5193 let md = "- [ ] Todo item\n- [x] Done item";
5194 let doc = markdown_to_adf(md).unwrap();
5195 assert_eq!(doc.content[0].node_type, "taskList");
5196 let items = doc.content[0].content.as_ref().unwrap();
5197 assert_eq!(items.len(), 2);
5198 assert_eq!(items[0].node_type, "taskItem");
5199 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5200 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5201 }
5202
5203 #[test]
5204 fn task_list_uppercase_x() {
5205 let md = "- [X] Done item";
5206 let doc = markdown_to_adf(md).unwrap();
5207 assert_eq!(doc.content[0].node_type, "taskList");
5208 let item = &doc.content[0].content.as_ref().unwrap()[0];
5209 assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
5210 }
5211
5212 #[test]
5215 fn task_list_empty_todo_no_trailing_space() {
5216 let md = "- [ ]";
5217 let doc = markdown_to_adf(md).unwrap();
5218 assert_eq!(doc.content[0].node_type, "taskList");
5219 let items = doc.content[0].content.as_ref().unwrap();
5220 assert_eq!(items.len(), 1);
5221 assert_eq!(items[0].node_type, "taskItem");
5222 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5223 assert!(items[0].content.is_none());
5224 }
5225
5226 #[test]
5228 fn task_list_empty_done_no_trailing_space() {
5229 let md = "- [x]\n- [X]";
5230 let doc = markdown_to_adf(md).unwrap();
5231 assert_eq!(doc.content[0].node_type, "taskList");
5232 let items = doc.content[0].content.as_ref().unwrap();
5233 assert_eq!(items.len(), 2);
5234 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
5235 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5236 }
5237
5238 #[test]
5241 fn task_list_body_has_no_leading_space() {
5242 let md = "- [ ] Buy groceries";
5243 let doc = markdown_to_adf(md).unwrap();
5244 let item = &doc.content[0].content.as_ref().unwrap()[0];
5245 let text = item.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5246 assert_eq!(text, "Buy groceries");
5247 }
5248
5249 #[test]
5253 fn round_trip_empty_task_items_stripped_trailing_spaces() {
5254 let json = r#"{
5255 "version": 1,
5256 "type": "doc",
5257 "content": [{
5258 "type": "taskList",
5259 "attrs": {"localId": "abc"},
5260 "content": [
5261 {"type": "taskItem", "attrs": {"localId": "def", "state": "TODO"}},
5262 {"type": "taskItem", "attrs": {"localId": "ghi", "state": "DONE"}}
5263 ]
5264 }]
5265 }"#;
5266 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5267 let md = adf_to_markdown(&doc).unwrap();
5268 let stripped: String = md.lines().map(str::trim_end).collect::<Vec<_>>().join("\n");
5269 let parsed = markdown_to_adf(&stripped).unwrap();
5270 assert_eq!(parsed.content[0].node_type, "taskList");
5271 let items = parsed.content[0].content.as_ref().unwrap();
5272 assert_eq!(items.len(), 2);
5273 assert_eq!(items[0].node_type, "taskItem");
5274 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5275 assert_eq!(items[1].node_type, "taskItem");
5276 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5277 }
5278
5279 #[test]
5280 fn try_parse_task_marker_accepts_bare_checkbox() {
5281 assert_eq!(try_parse_task_marker("[ ]"), Some(("TODO", "")));
5282 assert_eq!(try_parse_task_marker("[x]"), Some(("DONE", "")));
5283 assert_eq!(try_parse_task_marker("[X]"), Some(("DONE", "")));
5284 assert_eq!(try_parse_task_marker("[ ] foo"), Some(("TODO", "foo")));
5285 assert_eq!(try_parse_task_marker("[x] foo"), Some(("DONE", "foo")));
5286 assert_eq!(try_parse_task_marker("[ ]foo"), None);
5287 assert_eq!(try_parse_task_marker("[x]foo"), None);
5288 assert_eq!(try_parse_task_marker("[y] foo"), None);
5289 }
5290
5291 #[test]
5292 fn starts_with_task_marker_matches_parser() {
5293 assert!(starts_with_task_marker("[ ]"));
5296 assert!(starts_with_task_marker("[x]"));
5297 assert!(starts_with_task_marker("[X]"));
5298 assert!(starts_with_task_marker("[ ] foo"));
5299 assert!(starts_with_task_marker("[x] foo\n"));
5300 assert!(starts_with_task_marker("[ ]\n"));
5301 assert!(!starts_with_task_marker("[ ]foo"));
5303 assert!(!starts_with_task_marker("[y] foo"));
5304 assert!(!starts_with_task_marker("foo [ ] bar"));
5305 assert!(!starts_with_task_marker(""));
5306 }
5307
5308 #[test]
5312 fn round_trip_bullet_list_with_literal_checkbox_text() {
5313 let json = r#"{
5314 "version": 1,
5315 "type": "doc",
5316 "content": [{
5317 "type": "bulletList",
5318 "content": [{
5319 "type": "listItem",
5320 "content": [{
5321 "type": "paragraph",
5322 "content": [
5323 {"type": "text", "text": "[ ] Review the "},
5324 {"type": "text", "text": "config.yaml", "marks": [{"type": "code"}]},
5325 {"type": "text", "text": " file"}
5326 ]
5327 }]
5328 }]
5329 }]
5330 }"#;
5331 let original: AdfDocument = serde_json::from_str(json).unwrap();
5332 let md = adf_to_markdown(&original).unwrap();
5333 assert!(
5335 md.contains(r"- \[ ] Review the "),
5336 "rendered markdown: {md:?}"
5337 );
5338 let parsed = markdown_to_adf(&md).unwrap();
5339 assert_eq!(parsed.content[0].node_type, "bulletList");
5340 let item = &parsed.content[0].content.as_ref().unwrap()[0];
5341 assert_eq!(item.node_type, "listItem");
5342 let para = &item.content.as_ref().unwrap()[0];
5343 assert_eq!(para.node_type, "paragraph");
5344 let text_nodes = para.content.as_ref().unwrap();
5345 assert_eq!(text_nodes[0].text.as_deref().unwrap(), "[ ] Review the ");
5346 assert_eq!(text_nodes[1].text.as_deref().unwrap(), "config.yaml");
5347 assert_eq!(text_nodes[2].text.as_deref().unwrap(), " file");
5348 }
5349
5350 #[test]
5352 fn round_trip_bullet_list_with_literal_done_checkbox_text() {
5353 let json = r#"{
5354 "version": 1,
5355 "type": "doc",
5356 "content": [{
5357 "type": "bulletList",
5358 "content": [{
5359 "type": "listItem",
5360 "content": [{
5361 "type": "paragraph",
5362 "content": [{"type": "text", "text": "[x] not actually done"}]
5363 }]
5364 }]
5365 }]
5366 }"#;
5367 let original: AdfDocument = serde_json::from_str(json).unwrap();
5368 let md = adf_to_markdown(&original).unwrap();
5369 assert!(md.contains(r"- \[x] "), "rendered markdown: {md:?}");
5370 let parsed = markdown_to_adf(&md).unwrap();
5371 assert_eq!(parsed.content[0].node_type, "bulletList");
5372 let item = &parsed.content[0].content.as_ref().unwrap()[0];
5373 let para = &item.content.as_ref().unwrap()[0];
5374 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5375 assert_eq!(text, "[x] not actually done");
5376 }
5377
5378 #[test]
5380 fn round_trip_bullet_list_with_bare_literal_checkbox() {
5381 let json = r#"{
5382 "version": 1,
5383 "type": "doc",
5384 "content": [{
5385 "type": "bulletList",
5386 "content": [{
5387 "type": "listItem",
5388 "content": [{
5389 "type": "paragraph",
5390 "content": [{"type": "text", "text": "[ ]"}]
5391 }]
5392 }]
5393 }]
5394 }"#;
5395 let original: AdfDocument = serde_json::from_str(json).unwrap();
5396 let md = adf_to_markdown(&original).unwrap();
5397 let parsed = markdown_to_adf(&md).unwrap();
5398 assert_eq!(parsed.content[0].node_type, "bulletList");
5399 let item = &parsed.content[0].content.as_ref().unwrap()[0];
5400 let para = &item.content.as_ref().unwrap()[0];
5401 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5402 assert_eq!(text, "[ ]");
5403 }
5404
5405 #[test]
5408 fn bullet_list_non_task_bracket_text_not_escaped() {
5409 let json = r#"{
5410 "version": 1,
5411 "type": "doc",
5412 "content": [{
5413 "type": "bulletList",
5414 "content": [{
5415 "type": "listItem",
5416 "content": [{
5417 "type": "paragraph",
5418 "content": [{"type": "text", "text": "[?] unsure"}]
5419 }]
5420 }]
5421 }]
5422 }"#;
5423 let original: AdfDocument = serde_json::from_str(json).unwrap();
5424 let md = adf_to_markdown(&original).unwrap();
5425 assert!(!md.contains(r"\["), "should not escape: {md:?}");
5426 assert!(md.contains("- [?] unsure"), "rendered: {md:?}");
5427 }
5428
5429 #[test]
5432 fn round_trip_nested_bullet_list_with_literal_checkbox_text() {
5433 let json = r#"{
5434 "version": 1,
5435 "type": "doc",
5436 "content": [{
5437 "type": "bulletList",
5438 "content": [{
5439 "type": "listItem",
5440 "content": [
5441 {"type": "paragraph", "content": [{"type": "text", "text": "outer"}]},
5442 {"type": "bulletList", "content": [{
5443 "type": "listItem",
5444 "content": [{
5445 "type": "paragraph",
5446 "content": [{"type": "text", "text": "[ ] inner literal"}]
5447 }]
5448 }]}
5449 ]
5450 }]
5451 }]
5452 }"#;
5453 let original: AdfDocument = serde_json::from_str(json).unwrap();
5454 let md = adf_to_markdown(&original).unwrap();
5455 let parsed = markdown_to_adf(&md).unwrap();
5456 let outer = &parsed.content[0];
5457 assert_eq!(outer.node_type, "bulletList");
5458 let outer_item = &outer.content.as_ref().unwrap()[0];
5459 let inner_list = &outer_item.content.as_ref().unwrap()[1];
5460 assert_eq!(inner_list.node_type, "bulletList");
5461 let inner_item = &inner_list.content.as_ref().unwrap()[0];
5462 assert_eq!(inner_item.node_type, "listItem");
5463 let para = &inner_item.content.as_ref().unwrap()[0];
5464 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5465 assert_eq!(text, "[ ] inner literal");
5466 }
5467
5468 #[test]
5469 fn adf_task_list_to_markdown() {
5470 let doc = AdfDocument {
5471 version: 1,
5472 doc_type: "doc".to_string(),
5473 content: vec![AdfNode::task_list(vec![
5474 AdfNode::task_item(
5475 "TODO",
5476 vec![AdfNode::paragraph(vec![AdfNode::text("Todo")])],
5477 ),
5478 AdfNode::task_item(
5479 "DONE",
5480 vec![AdfNode::paragraph(vec![AdfNode::text("Done")])],
5481 ),
5482 ])],
5483 };
5484 let md = adf_to_markdown(&doc).unwrap();
5485 assert!(md.contains("- [ ] Todo"));
5486 assert!(md.contains("- [x] Done"));
5487 }
5488
5489 #[test]
5490 fn round_trip_task_list() {
5491 let md = "- [ ] Todo item\n- [x] Done item\n";
5492 let doc = markdown_to_adf(md).unwrap();
5493 let result = adf_to_markdown(&doc).unwrap();
5494 assert!(result.contains("- [ ] Todo item"));
5495 assert!(result.contains("- [x] Done item"));
5496 }
5497
5498 #[test]
5500 fn adf_task_item_unwrapped_inline_content() {
5501 let json = r#"{
5503 "version": 1,
5504 "type": "doc",
5505 "content": [{
5506 "type": "taskList",
5507 "attrs": {"localId": "list-001"},
5508 "content": [{
5509 "type": "taskItem",
5510 "attrs": {"localId": "task-001", "state": "TODO"},
5511 "content": [{"type": "text", "text": "Do something"}]
5512 }]
5513 }]
5514 }"#;
5515 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5516 let md = adf_to_markdown(&doc).unwrap();
5517 assert!(md.contains("- [ ] Do something"), "got: {md}");
5518 assert!(!md.contains("adf-unsupported"), "got: {md}");
5519 }
5520
5521 #[test]
5523 fn adf_task_list_multiple_unwrapped_items() {
5524 let json = r#"{
5525 "version": 1,
5526 "type": "doc",
5527 "content": [{
5528 "type": "taskList",
5529 "attrs": {"localId": "list-001"},
5530 "content": [
5531 {
5532 "type": "taskItem",
5533 "attrs": {"localId": "task-001", "state": "TODO"},
5534 "content": [{"type": "text", "text": "First task"}]
5535 },
5536 {
5537 "type": "taskItem",
5538 "attrs": {"localId": "task-002", "state": "DONE"},
5539 "content": [{"type": "text", "text": "Second task"}]
5540 }
5541 ]
5542 }]
5543 }"#;
5544 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5545 let md = adf_to_markdown(&doc).unwrap();
5546 assert!(md.contains("- [ ] First task"), "got: {md}");
5547 assert!(md.contains("- [x] Second task"), "got: {md}");
5548 assert!(!md.contains("adf-unsupported"), "got: {md}");
5549 }
5550
5551 #[test]
5553 fn adf_task_item_unwrapped_inline_with_marks() {
5554 let json = r#"{
5555 "version": 1,
5556 "type": "doc",
5557 "content": [{
5558 "type": "taskList",
5559 "attrs": {"localId": "list-001"},
5560 "content": [{
5561 "type": "taskItem",
5562 "attrs": {"localId": "task-001", "state": "TODO"},
5563 "content": [
5564 {"type": "text", "text": "Buy "},
5565 {"type": "text", "text": "groceries", "marks": [{"type": "strong"}]},
5566 {"type": "text", "text": " today"}
5567 ]
5568 }]
5569 }]
5570 }"#;
5571 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5572 let md = adf_to_markdown(&doc).unwrap();
5573 assert!(md.contains("- [ ] Buy **groceries** today"), "got: {md}");
5574 }
5575
5576 #[test]
5578 fn adf_task_item_unwrapped_preserves_local_id() {
5579 let json = r#"{
5580 "version": 1,
5581 "type": "doc",
5582 "content": [{
5583 "type": "taskList",
5584 "attrs": {"localId": "list-001"},
5585 "content": [{
5586 "type": "taskItem",
5587 "attrs": {"localId": "task-001", "state": "TODO"},
5588 "content": [{"type": "text", "text": "Do something"}]
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("{localId=task-001}"), "got: {md}");
5595 assert!(md.contains("{localId=list-001}"), "got: {md}");
5596 }
5597
5598 #[test]
5600 fn round_trip_task_list_unwrapped_inline() {
5601 let json = r#"{
5602 "version": 1,
5603 "type": "doc",
5604 "content": [{
5605 "type": "taskList",
5606 "attrs": {"localId": "list-001"},
5607 "content": [
5608 {
5609 "type": "taskItem",
5610 "attrs": {"localId": "task-001", "state": "TODO"},
5611 "content": [{"type": "text", "text": "Do something"}]
5612 },
5613 {
5614 "type": "taskItem",
5615 "attrs": {"localId": "task-002", "state": "DONE"},
5616 "content": [{"type": "text", "text": "Already done"}]
5617 }
5618 ]
5619 }]
5620 }"#;
5621 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5622 let md = adf_to_markdown(&doc).unwrap();
5623
5624 let doc2 = markdown_to_adf(&md).unwrap();
5626 assert_eq!(doc2.content[0].node_type, "taskList");
5627
5628 let items = doc2.content[0].content.as_ref().unwrap();
5629 assert_eq!(items.len(), 2);
5630 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5631 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5632
5633 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5635 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "task-002");
5636 assert_eq!(
5637 doc2.content[0].attrs.as_ref().unwrap()["localId"],
5638 "list-001"
5639 );
5640 }
5641
5642 #[test]
5644 fn adf_task_item_unwrapped_inline_then_block() {
5645 let json = r#"{
5646 "version": 1,
5647 "type": "doc",
5648 "content": [{
5649 "type": "taskList",
5650 "attrs": {"localId": "list-001"},
5651 "content": [{
5652 "type": "taskItem",
5653 "attrs": {"localId": "task-001", "state": "TODO"},
5654 "content": [
5655 {"type": "text", "text": "Parent task"},
5656 {
5657 "type": "bulletList",
5658 "content": [{
5659 "type": "listItem",
5660 "content": [{
5661 "type": "paragraph",
5662 "content": [{"type": "text", "text": "sub-item"}]
5663 }]
5664 }]
5665 }
5666 ]
5667 }]
5668 }]
5669 }"#;
5670 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5671 let md = adf_to_markdown(&doc).unwrap();
5672 assert!(md.contains("- [ ] Parent task"), "got: {md}");
5673 assert!(md.contains(" - sub-item"), "got: {md}");
5674 assert!(!md.contains("adf-unsupported"), "got: {md}");
5675 }
5676
5677 #[test]
5679 fn adf_task_item_empty_content() {
5680 let json = r#"{
5681 "version": 1,
5682 "type": "doc",
5683 "content": [{
5684 "type": "taskList",
5685 "attrs": {"localId": "list-001"},
5686 "content": [{
5687 "type": "taskItem",
5688 "attrs": {"localId": "task-001", "state": "TODO"},
5689 "content": []
5690 }]
5691 }]
5692 }"#;
5693 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5694 let md = adf_to_markdown(&doc).unwrap();
5695 assert!(md.contains("- [ ] "), "got: {md}");
5696 assert!(!md.contains("adf-unsupported"), "got: {md}");
5697 }
5698
5699 #[test]
5702 fn adf_nested_task_item_renders_without_corruption() {
5703 let json = r#"{
5704 "type": "doc",
5705 "version": 1,
5706 "content": [{
5707 "type": "taskList",
5708 "attrs": {"localId": ""},
5709 "content": [
5710 {
5711 "type": "taskItem",
5712 "attrs": {"localId": "aabbccdd-1234-5678-abcd-aabbccdd1234", "state": "TODO"},
5713 "content": [{"type": "text", "text": "Normal task"}]
5714 },
5715 {
5716 "type": "taskItem",
5717 "attrs": {"localId": ""},
5718 "content": [
5719 {
5720 "type": "taskItem",
5721 "attrs": {"localId": "bbccddee-2345-6789-bcde-bbccddee2345", "state": "TODO"},
5722 "content": [{"type": "text", "text": "Nested task one"}]
5723 },
5724 {
5725 "type": "taskItem",
5726 "attrs": {"localId": "ccddee11-3456-7890-cdef-ccddee113456", "state": "DONE"},
5727 "content": [{"type": "text", "text": "Nested task two"}]
5728 }
5729 ]
5730 }
5731 ]
5732 }]
5733 }"#;
5734 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5735 let md = adf_to_markdown(&doc).unwrap();
5736 assert!(md.contains("- [ ] Normal task"), "got: {md}");
5738 assert!(!md.contains("adf-unsupported"), "got: {md}");
5740 assert!(md.contains(" - [ ] Nested task one"), "got: {md}");
5741 assert!(md.contains(" - [x] Nested task two"), "got: {md}");
5742 }
5743
5744 #[test]
5746 fn round_trip_nested_task_item() {
5747 let json = r#"{
5748 "type": "doc",
5749 "version": 1,
5750 "content": [{
5751 "type": "taskList",
5752 "attrs": {"localId": ""},
5753 "content": [
5754 {
5755 "type": "taskItem",
5756 "attrs": {"localId": "task-001", "state": "TODO"},
5757 "content": [{"type": "text", "text": "Normal task"}]
5758 },
5759 {
5760 "type": "taskItem",
5761 "attrs": {"localId": ""},
5762 "content": [
5763 {
5764 "type": "taskItem",
5765 "attrs": {"localId": "task-002", "state": "TODO"},
5766 "content": [{"type": "text", "text": "Nested one"}]
5767 },
5768 {
5769 "type": "taskItem",
5770 "attrs": {"localId": "task-003", "state": "DONE"},
5771 "content": [{"type": "text", "text": "Nested two"}]
5772 }
5773 ]
5774 }
5775 ]
5776 }]
5777 }"#;
5778 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5779 let md = adf_to_markdown(&doc).unwrap();
5780 let doc2 = markdown_to_adf(&md).unwrap();
5781
5782 assert_eq!(doc2.content[0].node_type, "taskList");
5784 let items = doc2.content[0].content.as_ref().unwrap();
5785 assert_eq!(items.len(), 2, "expected 2 top-level items, got: {items:?}");
5786
5787 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5789 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5790 let first_content = items[0].content.as_ref().unwrap();
5791 assert_eq!(first_content[0].text.as_deref(), Some("Normal task"));
5792
5793 let container = &items[1];
5795 assert_eq!(container.node_type, "taskItem");
5796 let c_attrs = container.attrs.as_ref().unwrap();
5797 assert!(
5798 c_attrs.get("state").is_none(),
5799 "container should have no state attr, got: {c_attrs:?}"
5800 );
5801
5802 let container_content = container.content.as_ref().unwrap();
5804 assert_eq!(
5805 container_content.len(),
5806 2,
5807 "expected 2 bare taskItem children"
5808 );
5809 assert_eq!(container_content[0].node_type, "taskItem");
5810 assert_eq!(
5811 container_content[0].attrs.as_ref().unwrap()["state"],
5812 "TODO"
5813 );
5814 assert_eq!(
5815 container_content[0].attrs.as_ref().unwrap()["localId"],
5816 "task-002"
5817 );
5818 assert_eq!(container_content[1].node_type, "taskItem");
5819 assert_eq!(
5820 container_content[1].attrs.as_ref().unwrap()["state"],
5821 "DONE"
5822 );
5823 assert_eq!(
5824 container_content[1].attrs.as_ref().unwrap()["localId"],
5825 "task-003"
5826 );
5827 }
5828
5829 #[test]
5831 fn adf_nested_task_item_preserves_local_ids() {
5832 let json = r#"{
5833 "type": "doc",
5834 "version": 1,
5835 "content": [{
5836 "type": "taskList",
5837 "attrs": {"localId": "list-001"},
5838 "content": [{
5839 "type": "taskItem",
5840 "attrs": {"localId": "container-001", "state": "TODO"},
5841 "content": [{
5842 "type": "taskItem",
5843 "attrs": {"localId": "child-001", "state": "DONE"},
5844 "content": [{"type": "text", "text": "Nested child"}]
5845 }]
5846 }]
5847 }]
5848 }"#;
5849 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5850 let md = adf_to_markdown(&doc).unwrap();
5851 assert!(
5853 md.contains("localId=container-001"),
5854 "container localId missing: {md}"
5855 );
5856 assert!(
5858 md.contains("localId=child-001"),
5859 "child localId missing: {md}"
5860 );
5861 assert!(!md.contains("adf-unsupported"), "got: {md}");
5862 }
5863
5864 #[test]
5867 fn adf_nested_task_item_mixed_with_block_node() {
5868 let json = r#"{
5869 "type": "doc",
5870 "version": 1,
5871 "content": [{
5872 "type": "taskList",
5873 "attrs": {"localId": ""},
5874 "content": [{
5875 "type": "taskItem",
5876 "attrs": {"localId": "", "state": "TODO"},
5877 "content": [
5878 {
5879 "type": "taskItem",
5880 "attrs": {"localId": "", "state": "TODO"},
5881 "content": [{"type": "text", "text": "A nested task"}]
5882 },
5883 {
5884 "type": "paragraph",
5885 "content": [{"type": "text", "text": "Stray paragraph"}]
5886 }
5887 ]
5888 }]
5889 }]
5890 }"#;
5891 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5892 let md = adf_to_markdown(&doc).unwrap();
5893 assert!(md.contains(" - [ ] A nested task"), "got: {md}");
5894 assert!(md.contains(" Stray paragraph"), "got: {md}");
5895 assert!(!md.contains("adf-unsupported"), "got: {md}");
5896 }
5897
5898 #[test]
5902 fn task_item_with_text_and_nested_sub_content() {
5903 let md = "- [ ] Parent task\n - [ ] Sub task\n";
5904 let doc = markdown_to_adf(md).unwrap();
5905 assert_eq!(doc.content[0].node_type, "taskList");
5906 let items = doc.content[0].content.as_ref().unwrap();
5907 assert_eq!(items.len(), 2, "got: {items:?}");
5910 let parent = &items[0];
5911 assert_eq!(parent.attrs.as_ref().unwrap()["state"], "TODO");
5912 let parent_content = parent.content.as_ref().unwrap();
5913 assert_eq!(parent_content[0].text.as_deref(), Some("Parent task"));
5914 assert_eq!(items[1].node_type, "taskList");
5916 let nested = items[1].content.as_ref().unwrap();
5917 assert_eq!(nested.len(), 1);
5918 assert_eq!(nested[0].attrs.as_ref().unwrap()["state"], "TODO");
5919 }
5920
5921 #[test]
5925 fn task_item_empty_with_non_tasklist_sub_content() {
5926 let md = "- [ ] \n Some paragraph text\n";
5927 let doc = markdown_to_adf(md).unwrap();
5928 assert_eq!(doc.content[0].node_type, "taskList");
5929 let items = doc.content[0].content.as_ref().unwrap();
5930 assert_eq!(items.len(), 1);
5931 let item = &items[0];
5932 assert_eq!(item.attrs.as_ref().unwrap()["state"], "TODO");
5933 let content = item.content.as_ref().unwrap();
5934 assert_eq!(content[0].node_type, "paragraph");
5936 }
5937
5938 #[test]
5940 fn adf_nested_task_item_single_child() {
5941 let json = r#"{
5942 "type": "doc",
5943 "version": 1,
5944 "content": [{
5945 "type": "taskList",
5946 "attrs": {"localId": ""},
5947 "content": [{
5948 "type": "taskItem",
5949 "attrs": {"localId": "", "state": "TODO"},
5950 "content": [{
5951 "type": "taskItem",
5952 "attrs": {"localId": "", "state": "DONE"},
5953 "content": [{"type": "text", "text": "Only child"}]
5954 }]
5955 }]
5956 }]
5957 }"#;
5958 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5959 let md = adf_to_markdown(&doc).unwrap();
5960 assert!(md.contains(" - [x] Only child"), "got: {md}");
5961 assert!(!md.contains("adf-unsupported"), "got: {md}");
5962 }
5963
5964 #[test]
5967 fn adf_nested_tasklist_sibling_renders_indented() {
5968 let json = r#"{
5969 "version": 1,
5970 "type": "doc",
5971 "content": [{
5972 "type": "taskList",
5973 "attrs": {"localId": ""},
5974 "content": [
5975 {
5976 "type": "taskItem",
5977 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000001", "state": "TODO"},
5978 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
5979 },
5980 {
5981 "type": "taskList",
5982 "attrs": {"localId": ""},
5983 "content": [{
5984 "type": "taskItem",
5985 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000002", "state": "TODO"},
5986 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
5987 }]
5988 },
5989 {
5990 "type": "taskItem",
5991 "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000003", "state": "TODO"},
5992 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
5993 }
5994 ]
5995 }]
5996 }"#;
5997 let doc: AdfDocument = serde_json::from_str(json).unwrap();
5998 let md = adf_to_markdown(&doc).unwrap();
5999 assert!(md.contains("- [ ] parent task one"), "got: {md}");
6001 assert!(md.contains(" - [ ] nested sub-task"), "got: {md}");
6002 assert!(md.contains("- [ ] parent task two"), "got: {md}");
6003 }
6004
6005 #[test]
6007 fn round_trip_nested_tasklist_preserves_type() {
6008 let json = r#"{
6009 "version": 1,
6010 "type": "doc",
6011 "content": [{
6012 "type": "taskList",
6013 "attrs": {"localId": ""},
6014 "content": [
6015 {
6016 "type": "taskItem",
6017 "attrs": {"localId": "", "state": "TODO"},
6018 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
6019 },
6020 {
6021 "type": "taskList",
6022 "attrs": {"localId": ""},
6023 "content": [{
6024 "type": "taskItem",
6025 "attrs": {"localId": "", "state": "TODO"},
6026 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
6027 }]
6028 },
6029 {
6030 "type": "taskItem",
6031 "attrs": {"localId": "", "state": "TODO"},
6032 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
6033 }
6034 ]
6035 }]
6036 }"#;
6037 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6038 let md = adf_to_markdown(&doc).unwrap();
6039 let rt_doc = markdown_to_adf(&md).unwrap();
6040 assert_eq!(rt_doc.content[0].node_type, "taskList");
6042 let items = rt_doc.content[0].content.as_ref().unwrap();
6043 assert_eq!(items.len(), 3, "got: {items:?}");
6046 assert_eq!(items[0].node_type, "taskItem");
6047 assert_eq!(
6048 items[1].node_type, "taskList",
6049 "nested taskList should survive round-trip"
6050 );
6051 assert_eq!(items[2].node_type, "taskItem");
6052 let nested_items = items[1].content.as_ref().unwrap();
6053 assert_eq!(nested_items[0].attrs.as_ref().unwrap()["state"], "TODO");
6054 }
6055
6056 #[test]
6058 fn adf_nested_tasklist_done_state() {
6059 let json = r#"{
6060 "version": 1,
6061 "type": "doc",
6062 "content": [{
6063 "type": "taskList",
6064 "attrs": {"localId": ""},
6065 "content": [
6066 {
6067 "type": "taskItem",
6068 "attrs": {"localId": "", "state": "TODO"},
6069 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
6070 },
6071 {
6072 "type": "taskList",
6073 "attrs": {"localId": ""},
6074 "content": [{
6075 "type": "taskItem",
6076 "attrs": {"localId": "", "state": "DONE"},
6077 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "done child"}]}]
6078 }]
6079 }
6080 ]
6081 }]
6082 }"#;
6083 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6084 let md = adf_to_markdown(&doc).unwrap();
6085 assert!(md.contains(" - [x] done child"), "got: {md}");
6086 let rt_doc = markdown_to_adf(&md).unwrap();
6088 let items = rt_doc.content[0].content.as_ref().unwrap();
6089 assert_eq!(
6090 items[1].node_type, "taskList",
6091 "nested taskList should survive round-trip"
6092 );
6093 let nested_item = &items[1].content.as_ref().unwrap()[0];
6094 assert_eq!(nested_item.attrs.as_ref().unwrap()["state"], "DONE");
6095 }
6096
6097 #[test]
6099 fn adf_multiple_nested_tasklists() {
6100 let json = r#"{
6101 "version": 1,
6102 "type": "doc",
6103 "content": [{
6104 "type": "taskList",
6105 "attrs": {"localId": ""},
6106 "content": [
6107 {
6108 "type": "taskItem",
6109 "attrs": {"localId": "", "state": "TODO"},
6110 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "first parent"}]}]
6111 },
6112 {
6113 "type": "taskList",
6114 "attrs": {"localId": ""},
6115 "content": [{
6116 "type": "taskItem",
6117 "attrs": {"localId": "", "state": "TODO"},
6118 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child A"}]}]
6119 }]
6120 },
6121 {
6122 "type": "taskItem",
6123 "attrs": {"localId": "", "state": "TODO"},
6124 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "second parent"}]}]
6125 },
6126 {
6127 "type": "taskList",
6128 "attrs": {"localId": ""},
6129 "content": [{
6130 "type": "taskItem",
6131 "attrs": {"localId": "", "state": "DONE"},
6132 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child B"}]}]
6133 }]
6134 }
6135 ]
6136 }]
6137 }"#;
6138 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6139 let md = adf_to_markdown(&doc).unwrap();
6140 assert!(md.contains("- [ ] first parent"), "got: {md}");
6141 assert!(md.contains(" - [ ] child A"), "got: {md}");
6142 assert!(md.contains("- [ ] second parent"), "got: {md}");
6143 assert!(md.contains(" - [x] child B"), "got: {md}");
6144 }
6145
6146 #[test]
6149 fn round_trip_nested_tasklist_stable() {
6150 let json = r#"{
6151 "version": 1,
6152 "type": "doc",
6153 "content": [{
6154 "type": "taskList",
6155 "attrs": {"localId": ""},
6156 "content": [
6157 {
6158 "type": "taskItem",
6159 "attrs": {"localId": "", "state": "TODO"},
6160 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
6161 },
6162 {
6163 "type": "taskList",
6164 "attrs": {"localId": ""},
6165 "content": [{
6166 "type": "taskItem",
6167 "attrs": {"localId": "", "state": "TODO"},
6168 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child"}]}]
6169 }]
6170 }
6171 ]
6172 }]
6173 }"#;
6174 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6175 let md1 = adf_to_markdown(&doc).unwrap();
6177 let rt1 = markdown_to_adf(&md1).unwrap();
6178 let md2 = adf_to_markdown(&rt1).unwrap();
6180 let rt2 = markdown_to_adf(&md2).unwrap();
6181 assert_eq!(md1, md2, "markdown should be stable across round-trips");
6183 let rt1_json = serde_json::to_string(&rt1).unwrap();
6185 let rt2_json = serde_json::to_string(&rt2).unwrap();
6186 assert_eq!(
6187 rt1_json, rt2_json,
6188 "ADF should be stable across round-trips"
6189 );
6190 }
6191
6192 #[test]
6197 fn task_item_mixed_sub_content_splits_siblings() {
6198 let md = "- [ ] Parent task\n - [ ] Sub task\n Some paragraph\n";
6199 let doc = markdown_to_adf(md).unwrap();
6200 let items = doc.content[0].content.as_ref().unwrap();
6201 assert_eq!(items.len(), 2, "got: {items:?}");
6203 assert_eq!(items[0].node_type, "taskItem");
6204 let parent_content = items[0].content.as_ref().unwrap();
6205 assert!(
6207 parent_content.iter().any(|n| n.node_type == "paragraph"),
6208 "non-taskList sub-content should stay as child: {parent_content:?}"
6209 );
6210 assert_eq!(items[1].node_type, "taskList");
6212 }
6213
6214 #[test]
6218 fn empty_task_item_mixed_sub_content_none_arm() {
6219 let md = "- [ ] \n Some paragraph\n - [ ] Sub task\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!(
6227 parent_content.iter().any(|n| n.node_type == "paragraph"),
6228 "paragraph should be assigned to taskItem: {parent_content:?}"
6229 );
6230 assert_eq!(items[1].node_type, "taskList");
6231 }
6232
6233 #[test]
6238 fn task_item_text_with_non_tasklist_sub_content_only() {
6239 let md = "- [ ] My task\n Extra paragraph content\n";
6240 let doc = markdown_to_adf(md).unwrap();
6241 let items = doc.content[0].content.as_ref().unwrap();
6242 assert_eq!(items.len(), 1, "got: {items:?}");
6244 assert_eq!(items[0].node_type, "taskItem");
6245 let content = items[0].content.as_ref().unwrap();
6246 assert!(
6248 content.iter().any(|n| n.node_type == "paragraph"),
6249 "paragraph sub-content should be a child of taskItem: {content:?}"
6250 );
6251 }
6252
6253 #[test]
6256 fn adf_list_item_leading_block_node() {
6257 let json = r#"{
6258 "version": 1,
6259 "type": "doc",
6260 "content": [{
6261 "type": "bulletList",
6262 "content": [{
6263 "type": "listItem",
6264 "content": [{
6265 "type": "codeBlock",
6266 "attrs": {"language": "rust"},
6267 "content": [{"type": "text", "text": "let x = 1;"}]
6268 }]
6269 }]
6270 }]
6271 }"#;
6272 let doc: AdfDocument = serde_json::from_str(json).unwrap();
6273 let md = adf_to_markdown(&doc).unwrap();
6274 assert!(md.contains("```rust"), "got: {md}");
6275 assert!(md.contains("let x = 1;"), "got: {md}");
6276 for line in md.lines() {
6279 if line.starts_with("- ") {
6280 continue; }
6282 if line.trim().is_empty() {
6283 continue;
6284 }
6285 assert!(
6286 line.starts_with(" "),
6287 "continuation line not indented: {line:?}"
6288 );
6289 }
6290 }
6291
6292 #[test]
6295 fn code_block_in_list_item_backtick_roundtrip() {
6296 let json = r#"{
6297 "version": 1,
6298 "type": "doc",
6299 "content": [{
6300 "type": "bulletList",
6301 "content": [{
6302 "type": "listItem",
6303 "content": [{
6304 "type": "codeBlock",
6305 "attrs": {"language": ""},
6306 "content": [{"type": "text", "text": "error: some value with a backtick ` at end"}]
6307 }]
6308 }]
6309 }]
6310 }"#;
6311 let original: AdfDocument = serde_json::from_str(json).unwrap();
6312 let md = adf_to_markdown(&original).unwrap();
6313 let roundtripped = markdown_to_adf(&md).unwrap();
6314 let list = &roundtripped.content[0];
6315 assert_eq!(list.node_type, "bulletList", "top node: {}", list.node_type);
6316 let item = &list.content.as_ref().unwrap()[0];
6317 let first_child = &item.content.as_ref().unwrap()[0];
6318 assert_eq!(
6319 first_child.node_type, "codeBlock",
6320 "expected codeBlock, got: {}",
6321 first_child.node_type
6322 );
6323 let text = first_child.content.as_ref().unwrap()[0]
6324 .text
6325 .as_deref()
6326 .unwrap();
6327 assert_eq!(text, "error: some value with a backtick ` at end");
6328 }
6329
6330 #[test]
6332 fn code_block_with_language_in_list_item_roundtrip() {
6333 let json = r#"{
6334 "version": 1,
6335 "type": "doc",
6336 "content": [{
6337 "type": "bulletList",
6338 "content": [{
6339 "type": "listItem",
6340 "content": [{
6341 "type": "codeBlock",
6342 "attrs": {"language": "rust"},
6343 "content": [{"type": "text", "text": "fn main() {\n println!(\"hello\");\n}"}]
6344 }]
6345 }]
6346 }]
6347 }"#;
6348 let original: AdfDocument = serde_json::from_str(json).unwrap();
6349 let md = adf_to_markdown(&original).unwrap();
6350 let roundtripped = markdown_to_adf(&md).unwrap();
6351 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6352 let code = &item.content.as_ref().unwrap()[0];
6353 assert_eq!(code.node_type, "codeBlock");
6354 let lang = code
6355 .attrs
6356 .as_ref()
6357 .and_then(|a| a.get("language"))
6358 .and_then(serde_json::Value::as_str)
6359 .unwrap_or("");
6360 assert_eq!(lang, "rust");
6361 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6362 assert!(text.contains("println!"), "code content: {text}");
6363 }
6364
6365 #[test]
6367 fn code_block_in_ordered_list_item_roundtrip() {
6368 let json = r#"{
6369 "version": 1,
6370 "type": "doc",
6371 "content": [{
6372 "type": "orderedList",
6373 "attrs": {"order": 1},
6374 "content": [{
6375 "type": "listItem",
6376 "content": [{
6377 "type": "codeBlock",
6378 "attrs": {"language": ""},
6379 "content": [{"type": "text", "text": "backtick ` here"}]
6380 }]
6381 }]
6382 }]
6383 }"#;
6384 let original: AdfDocument = serde_json::from_str(json).unwrap();
6385 let md = adf_to_markdown(&original).unwrap();
6386 let roundtripped = markdown_to_adf(&md).unwrap();
6387 let list = &roundtripped.content[0];
6388 assert_eq!(list.node_type, "orderedList");
6389 let item = &list.content.as_ref().unwrap()[0];
6390 let code = &item.content.as_ref().unwrap()[0];
6391 assert_eq!(code.node_type, "codeBlock");
6392 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6393 assert_eq!(text, "backtick ` here");
6394 }
6395
6396 #[test]
6398 fn code_block_then_paragraph_in_list_item() {
6399 let json = r#"{
6400 "version": 1,
6401 "type": "doc",
6402 "content": [{
6403 "type": "bulletList",
6404 "content": [{
6405 "type": "listItem",
6406 "content": [
6407 {
6408 "type": "codeBlock",
6409 "attrs": {"language": ""},
6410 "content": [{"type": "text", "text": "code with ` backtick"}]
6411 },
6412 {
6413 "type": "paragraph",
6414 "content": [{"type": "text", "text": "description"}]
6415 }
6416 ]
6417 }]
6418 }]
6419 }"#;
6420 let original: AdfDocument = serde_json::from_str(json).unwrap();
6421 let md = adf_to_markdown(&original).unwrap();
6422 let roundtripped = markdown_to_adf(&md).unwrap();
6423 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6424 let children = item.content.as_ref().unwrap();
6425 assert_eq!(children[0].node_type, "codeBlock");
6426 assert_eq!(children[1].node_type, "paragraph");
6427 }
6428
6429 #[test]
6431 fn code_block_multiple_backticks_in_list_item() {
6432 let json = r#"{
6433 "version": 1,
6434 "type": "doc",
6435 "content": [{
6436 "type": "bulletList",
6437 "content": [{
6438 "type": "listItem",
6439 "content": [{
6440 "type": "codeBlock",
6441 "attrs": {"language": ""},
6442 "content": [{"type": "text", "text": "a ` b `` c ``` d"}]
6443 }]
6444 }]
6445 }]
6446 }"#;
6447 let original: AdfDocument = serde_json::from_str(json).unwrap();
6448 let md = adf_to_markdown(&original).unwrap();
6449 let roundtripped = markdown_to_adf(&md).unwrap();
6450 let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6451 let code = &item.content.as_ref().unwrap()[0];
6452 assert_eq!(code.node_type, "codeBlock");
6453 let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6454 assert_eq!(text, "a ` b `` c ``` d");
6455 }
6456
6457 #[test]
6460 fn media_first_child_with_sub_content_in_list_item() {
6461 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
6462 {"type":"listItem","content":[
6463 {"type":"mediaSingle","attrs":{"layout":"center"},
6464 "content":[{"type":"media","attrs":{"type":"file","id":"img-99","collection":"col-x","height":50,"width":100}}]},
6465 {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
6466 ]}
6467 ]}]}"#;
6468 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6469 let md = adf_to_markdown(&doc).unwrap();
6470 let rt = markdown_to_adf(&md).unwrap();
6471 let item = &rt.content[0].content.as_ref().unwrap()[0];
6472 let children = item.content.as_ref().unwrap();
6473 assert_eq!(
6474 children.len(),
6475 2,
6476 "expected 2 children, got {}",
6477 children.len()
6478 );
6479 assert_eq!(children[0].node_type, "mediaSingle");
6480 let media = &children[0].content.as_ref().unwrap()[0];
6481 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-99");
6482 assert_eq!(children[1].node_type, "paragraph");
6483 }
6484
6485 #[test]
6486 fn inline_bold() {
6487 let doc = markdown_to_adf("Some **bold** text").unwrap();
6488 let content = doc.content[0].content.as_ref().unwrap();
6489 assert!(content.len() >= 3);
6490 let bold_node = &content[1];
6491 assert_eq!(bold_node.text.as_deref(), Some("bold"));
6492 let marks = bold_node.marks.as_ref().unwrap();
6493 assert_eq!(marks[0].mark_type, "strong");
6494 }
6495
6496 #[test]
6497 fn inline_italic() {
6498 let doc = markdown_to_adf("Some *italic* text").unwrap();
6499 let content = doc.content[0].content.as_ref().unwrap();
6500 let italic_node = &content[1];
6501 assert_eq!(italic_node.text.as_deref(), Some("italic"));
6502 let marks = italic_node.marks.as_ref().unwrap();
6503 assert_eq!(marks[0].mark_type, "em");
6504 }
6505
6506 #[test]
6507 fn inline_code() {
6508 let doc = markdown_to_adf("Use `code` here").unwrap();
6509 let content = doc.content[0].content.as_ref().unwrap();
6510 let code_node = &content[1];
6511 assert_eq!(code_node.text.as_deref(), Some("code"));
6512 let marks = code_node.marks.as_ref().unwrap();
6513 assert_eq!(marks[0].mark_type, "code");
6514 }
6515
6516 #[test]
6520 fn inline_code_with_backtick_emitted_with_double_delimiters() {
6521 let doc = AdfDocument {
6522 version: 1,
6523 doc_type: "doc".to_string(),
6524 content: vec![AdfNode::paragraph(vec![
6525 AdfNode::text("Run "),
6526 AdfNode::text_with_marks(
6527 "ADD `custom_threshold` TEXT NOT NULL",
6528 vec![AdfMark::code()],
6529 ),
6530 AdfNode::text(" to update the schema."),
6531 ])],
6532 };
6533 let md = adf_to_markdown(&doc).unwrap();
6534 assert!(
6535 md.contains("``ADD `custom_threshold` TEXT NOT NULL``"),
6536 "expected double-backtick delimiters, got: {md}"
6537 );
6538 }
6539
6540 #[test]
6543 fn inline_code_double_backtick_delimiters_parse() {
6544 let doc = markdown_to_adf("Run ``ADD `custom_threshold` TEXT NOT NULL`` now").unwrap();
6545 let content = doc.content[0].content.as_ref().unwrap();
6546 assert_eq!(content.len(), 3, "content: {content:?}");
6547 let code_node = &content[1];
6548 assert_eq!(
6549 code_node.text.as_deref(),
6550 Some("ADD `custom_threshold` TEXT NOT NULL")
6551 );
6552 let marks = code_node.marks.as_ref().unwrap();
6553 assert_eq!(marks[0].mark_type, "code");
6554 }
6555
6556 #[test]
6559 fn inline_code_with_backtick_roundtrip() {
6560 let json = r#"{
6561 "version": 1,
6562 "type": "doc",
6563 "content": [{
6564 "type": "paragraph",
6565 "content": [
6566 {"type": "text", "text": "Run "},
6567 {
6568 "type": "text",
6569 "text": "ADD `custom_threshold` TEXT NOT NULL",
6570 "marks": [{"type": "code"}]
6571 },
6572 {"type": "text", "text": " to update the schema."}
6573 ]
6574 }]
6575 }"#;
6576 let original: AdfDocument = serde_json::from_str(json).unwrap();
6577 let md = adf_to_markdown(&original).unwrap();
6578 let roundtripped = markdown_to_adf(&md).unwrap();
6579 let para = &roundtripped.content[0];
6580 let children = para.content.as_ref().unwrap();
6581 assert_eq!(children.len(), 3, "expected 3 children, got: {children:?}");
6582 assert_eq!(children[0].text.as_deref(), Some("Run "));
6583 assert_eq!(
6584 children[1].text.as_deref(),
6585 Some("ADD `custom_threshold` TEXT NOT NULL")
6586 );
6587 let marks = children[1].marks.as_ref().unwrap();
6588 assert_eq!(marks.len(), 1);
6589 assert_eq!(marks[0].mark_type, "code");
6590 assert_eq!(children[2].text.as_deref(), Some(" to update the schema."));
6591 }
6592
6593 #[test]
6598 fn inline_code_with_double_backtick_roundtrip() {
6599 let doc = AdfDocument {
6600 version: 1,
6601 doc_type: "doc".to_string(),
6602 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6603 "x `` y",
6604 vec![AdfMark::code()],
6605 )])],
6606 };
6607 let md = adf_to_markdown(&doc).unwrap();
6608 let roundtripped = markdown_to_adf(&md).unwrap();
6609 let content = roundtripped.content[0].content.as_ref().unwrap();
6610 assert_eq!(content.len(), 1);
6611 assert_eq!(content[0].text.as_deref(), Some("x `` y"));
6612 let marks = content[0].marks.as_ref().unwrap();
6613 assert_eq!(marks[0].mark_type, "code");
6614 }
6615
6616 #[test]
6619 fn inline_code_leading_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 "`start",
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[0].text.as_deref(), Some("`start"));
6632 assert_eq!(content[0].marks.as_ref().unwrap()[0].mark_type, "code");
6633 }
6634
6635 #[test]
6637 fn inline_code_trailing_backtick_roundtrip() {
6638 let doc = AdfDocument {
6639 version: 1,
6640 doc_type: "doc".to_string(),
6641 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6642 "end`",
6643 vec![AdfMark::code()],
6644 )])],
6645 };
6646 let md = adf_to_markdown(&doc).unwrap();
6647 let roundtripped = markdown_to_adf(&md).unwrap();
6648 let content = roundtripped.content[0].content.as_ref().unwrap();
6649 assert_eq!(content[0].text.as_deref(), Some("end`"));
6650 }
6651
6652 #[test]
6655 fn inline_code_space_padded_content_roundtrip() {
6656 let doc = AdfDocument {
6657 version: 1,
6658 doc_type: "doc".to_string(),
6659 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6660 " foo ",
6661 vec![AdfMark::code()],
6662 )])],
6663 };
6664 let md = adf_to_markdown(&doc).unwrap();
6665 let roundtripped = markdown_to_adf(&md).unwrap();
6666 let content = roundtripped.content[0].content.as_ref().unwrap();
6667 assert_eq!(content[0].text.as_deref(), Some(" foo "));
6668 }
6669
6670 #[test]
6673 fn inline_code_all_spaces_roundtrip() {
6674 let doc = AdfDocument {
6675 version: 1,
6676 doc_type: "doc".to_string(),
6677 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6678 " ",
6679 vec![AdfMark::code()],
6680 )])],
6681 };
6682 let md = adf_to_markdown(&doc).unwrap();
6683 let roundtripped = markdown_to_adf(&md).unwrap();
6684 let content = roundtripped.content[0].content.as_ref().unwrap();
6685 assert_eq!(content[0].text.as_deref(), Some(" "));
6686 }
6687
6688 #[test]
6691 fn inline_code_with_link_and_backtick_roundtrip() {
6692 let doc = AdfDocument {
6693 version: 1,
6694 doc_type: "doc".to_string(),
6695 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6696 "fn `inner`",
6697 vec![AdfMark::code(), AdfMark::link("https://example.com")],
6698 )])],
6699 };
6700 let md = adf_to_markdown(&doc).unwrap();
6701 assert!(
6702 md.contains("`` fn `inner` ``"),
6703 "expected padded double-backtick delimiters inside link, got: {md}"
6704 );
6705 let roundtripped = markdown_to_adf(&md).unwrap();
6706 let content = roundtripped.content[0].content.as_ref().unwrap();
6707 assert_eq!(content[0].text.as_deref(), Some("fn `inner`"));
6708 let mark_types: Vec<&str> = content[0]
6709 .marks
6710 .as_ref()
6711 .unwrap()
6712 .iter()
6713 .map(|m| m.mark_type.as_str())
6714 .collect();
6715 assert!(mark_types.contains(&"code"));
6716 assert!(mark_types.contains(&"link"));
6717 }
6718
6719 #[test]
6721 fn inline_code_unmatched_run_is_plain_text() {
6722 let doc = markdown_to_adf("foo ``bar baz").unwrap();
6723 let content = doc.content[0].content.as_ref().unwrap();
6724 assert_eq!(content.len(), 1);
6725 assert_eq!(content[0].text.as_deref(), Some("foo ``bar baz"));
6726 assert!(content[0].marks.is_none());
6727 }
6728
6729 #[test]
6733 fn inline_code_mismatched_delimiters_is_plain_text() {
6734 let doc = markdown_to_adf("``foo` bar").unwrap();
6735 let content = doc.content[0].content.as_ref().unwrap();
6736 assert_eq!(content.len(), 1);
6737 assert_eq!(content[0].text.as_deref(), Some("``foo` bar"));
6738 assert!(content[0].marks.is_none());
6739 }
6740
6741 #[test]
6742 fn inline_code_delimiter_chooses_correct_length() {
6743 assert_eq!(inline_code_delimiter("no ticks"), (1, false));
6744 assert_eq!(inline_code_delimiter("one ` here"), (2, false));
6745 assert_eq!(inline_code_delimiter("two `` here"), (3, false));
6746 assert_eq!(inline_code_delimiter("three ``` here"), (4, false));
6747 assert_eq!(inline_code_delimiter("`leading"), (2, true));
6748 assert_eq!(inline_code_delimiter("trailing`"), (2, true));
6749 assert_eq!(inline_code_delimiter(" foo "), (1, true));
6750 assert_eq!(inline_code_delimiter(" "), (1, false));
6751 assert_eq!(inline_code_delimiter(" "), (1, false));
6752 assert_eq!(inline_code_delimiter(" foo"), (1, false));
6753 }
6754
6755 #[test]
6756 fn try_parse_inline_code_strips_paired_spaces() {
6757 let (end, content) = try_parse_inline_code("`` `foo` ``", 0).unwrap();
6758 assert_eq!(end, 11);
6759 assert_eq!(content, "`foo`");
6760 }
6761
6762 #[test]
6763 fn try_parse_inline_code_all_space_content_is_preserved() {
6764 let (_end, content) = try_parse_inline_code("` `", 0).unwrap();
6765 assert_eq!(content, " ");
6766 }
6767
6768 #[test]
6769 fn try_parse_inline_code_single_run_matches_first_close() {
6770 let (end, content) = try_parse_inline_code("`foo` tail", 0).unwrap();
6771 assert_eq!(end, 5);
6772 assert_eq!(content, "foo");
6773 }
6774
6775 #[test]
6776 fn try_parse_inline_code_no_match_returns_none() {
6777 assert!(try_parse_inline_code("``unmatched", 0).is_none());
6778 assert!(try_parse_inline_code("plain text", 0).is_none());
6779 }
6780
6781 #[test]
6782 fn is_code_fence_opener_rejects_info_with_backtick() {
6783 assert!(is_code_fence_opener("```"));
6784 assert!(is_code_fence_opener("```rust"));
6785 assert!(is_code_fence_opener("```\"\""));
6786 assert!(!is_code_fence_opener("```x `` y```"));
6787 assert!(!is_code_fence_opener("``not-enough"));
6788 assert!(!is_code_fence_opener("no fence"));
6789 }
6790
6791 #[test]
6792 fn inline_strikethrough() {
6793 let doc = markdown_to_adf("Some ~~deleted~~ text").unwrap();
6794 let content = doc.content[0].content.as_ref().unwrap();
6795 let strike_node = &content[1];
6796 assert_eq!(strike_node.text.as_deref(), Some("deleted"));
6797 let marks = strike_node.marks.as_ref().unwrap();
6798 assert_eq!(marks[0].mark_type, "strike");
6799 }
6800
6801 #[test]
6802 fn inline_link() {
6803 let doc = markdown_to_adf("Click [here](https://example.com) now").unwrap();
6804 let content = doc.content[0].content.as_ref().unwrap();
6805 let link_node = &content[1];
6806 assert_eq!(link_node.text.as_deref(), Some("here"));
6807 let marks = link_node.marks.as_ref().unwrap();
6808 assert_eq!(marks[0].mark_type, "link");
6809 }
6810
6811 #[test]
6812 fn block_image() {
6813 let md = "";
6814 let doc = markdown_to_adf(md).unwrap();
6815 assert_eq!(doc.content[0].node_type, "mediaSingle");
6816 }
6817
6818 #[test]
6819 fn table() {
6820 let md = "| A | B |\n| --- | --- |\n| 1 | 2 |";
6821 let doc = markdown_to_adf(md).unwrap();
6822 assert_eq!(doc.content[0].node_type, "table");
6823 let rows = doc.content[0].content.as_ref().unwrap();
6824 assert_eq!(rows.len(), 2); }
6826
6827 #[test]
6830 fn adf_paragraph_to_markdown() {
6831 let doc = AdfDocument {
6832 version: 1,
6833 doc_type: "doc".to_string(),
6834 content: vec![AdfNode::paragraph(vec![AdfNode::text("Hello world")])],
6835 };
6836 let md = adf_to_markdown(&doc).unwrap();
6837 assert_eq!(md.trim(), "Hello world");
6838 }
6839
6840 #[test]
6841 fn adf_heading_to_markdown() {
6842 let doc = AdfDocument {
6843 version: 1,
6844 doc_type: "doc".to_string(),
6845 content: vec![AdfNode::heading(2, vec![AdfNode::text("Title")])],
6846 };
6847 let md = adf_to_markdown(&doc).unwrap();
6848 assert_eq!(md.trim(), "## Title");
6849 }
6850
6851 #[test]
6852 fn adf_bold_to_markdown() {
6853 let doc = AdfDocument {
6854 version: 1,
6855 doc_type: "doc".to_string(),
6856 content: vec![AdfNode::paragraph(vec![
6857 AdfNode::text("Normal "),
6858 AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
6859 AdfNode::text(" text"),
6860 ])],
6861 };
6862 let md = adf_to_markdown(&doc).unwrap();
6863 assert_eq!(md.trim(), "Normal **bold** text");
6864 }
6865
6866 #[test]
6867 fn adf_code_block_to_markdown() {
6868 let doc = AdfDocument {
6869 version: 1,
6870 doc_type: "doc".to_string(),
6871 content: vec![AdfNode::code_block(Some("rust"), "let x = 1;")],
6872 };
6873 let md = adf_to_markdown(&doc).unwrap();
6874 assert!(md.contains("```rust"));
6875 assert!(md.contains("let x = 1;"));
6876 assert!(md.contains("```"));
6877 }
6878
6879 #[test]
6880 fn adf_rule_to_markdown() {
6881 let doc = AdfDocument {
6882 version: 1,
6883 doc_type: "doc".to_string(),
6884 content: vec![AdfNode::rule()],
6885 };
6886 let md = adf_to_markdown(&doc).unwrap();
6887 assert!(md.contains("---"));
6888 }
6889
6890 #[test]
6891 fn adf_bullet_list_to_markdown() {
6892 let doc = AdfDocument {
6893 version: 1,
6894 doc_type: "doc".to_string(),
6895 content: vec![AdfNode::bullet_list(vec![
6896 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("A")])]),
6897 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("B")])]),
6898 ])],
6899 };
6900 let md = adf_to_markdown(&doc).unwrap();
6901 assert!(md.contains("- A"));
6902 assert!(md.contains("- B"));
6903 }
6904
6905 #[test]
6906 fn adf_link_to_markdown() {
6907 let doc = AdfDocument {
6908 version: 1,
6909 doc_type: "doc".to_string(),
6910 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6911 "click",
6912 vec![AdfMark::link("https://example.com")],
6913 )])],
6914 };
6915 let md = adf_to_markdown(&doc).unwrap();
6916 assert_eq!(md.trim(), "[click](https://example.com)");
6917 }
6918
6919 #[test]
6920 fn unsupported_block_preserved_as_json() {
6921 let doc = AdfDocument {
6922 version: 1,
6923 doc_type: "doc".to_string(),
6924 content: vec![AdfNode {
6925 node_type: "unknownBlock".to_string(),
6926 attrs: Some(serde_json::json!({"key": "value"})),
6927 content: None,
6928 text: None,
6929 marks: None,
6930 local_id: None,
6931 parameters: None,
6932 }],
6933 };
6934 let md = adf_to_markdown(&doc).unwrap();
6935 assert!(md.contains("```adf-unsupported"));
6936 assert!(md.contains("\"unknownBlock\""));
6937 }
6938
6939 #[test]
6940 fn unsupported_block_round_trips() {
6941 let original = AdfDocument {
6942 version: 1,
6943 doc_type: "doc".to_string(),
6944 content: vec![AdfNode {
6945 node_type: "unknownBlock".to_string(),
6946 attrs: Some(serde_json::json!({"key": "value"})),
6947 content: None,
6948 text: None,
6949 marks: None,
6950 local_id: None,
6951 parameters: None,
6952 }],
6953 };
6954 let md = adf_to_markdown(&original).unwrap();
6955 let restored = markdown_to_adf(&md).unwrap();
6956 assert_eq!(restored.content[0].node_type, "unknownBlock");
6957 assert_eq!(restored.content[0].attrs.as_ref().unwrap()["key"], "value");
6958 }
6959
6960 #[test]
6963 fn round_trip_simple_document() {
6964 let md = "# Hello\n\nSome text with **bold** and *italic*.\n\n- Item 1\n- Item 2\n";
6965 let adf = markdown_to_adf(md).unwrap();
6966 let restored = adf_to_markdown(&adf).unwrap();
6967
6968 assert!(restored.contains("# Hello"));
6969 assert!(restored.contains("**bold**"));
6970 assert!(restored.contains("*italic*"));
6971 assert!(restored.contains("- Item 1"));
6972 assert!(restored.contains("- Item 2"));
6973 }
6974
6975 #[test]
6976 fn round_trip_code_block() {
6977 let md = "```python\nprint('hello')\n```\n";
6978 let adf = markdown_to_adf(md).unwrap();
6979 let restored = adf_to_markdown(&adf).unwrap();
6980
6981 assert!(restored.contains("```python"));
6982 assert!(restored.contains("print('hello')"));
6983 }
6984
6985 #[test]
6986 fn round_trip_code_block_no_attrs() {
6987 let adf_json = r#"{"version":1,"type":"doc","content":[
6988 {"type":"codeBlock","content":[{"type":"text","text":"plain code"}]}
6989 ]}"#;
6990 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6991 assert!(doc.content[0].attrs.is_none());
6992 let md = adf_to_markdown(&doc).unwrap();
6993 let round_tripped = markdown_to_adf(&md).unwrap();
6994 assert!(round_tripped.content[0].attrs.is_none());
6995 }
6996
6997 #[test]
6998 fn round_trip_code_block_empty_language() {
6999 let adf_json = r#"{"version":1,"type":"doc","content":[
7000 {"type":"codeBlock","attrs":{"language":""},"content":[{"type":"text","text":"simple code block no backtick"}]}
7001 ]}"#;
7002 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7003 let attrs = doc.content[0].attrs.as_ref().unwrap();
7004 assert_eq!(attrs["language"], "");
7005 let md = adf_to_markdown(&doc).unwrap();
7006 let round_tripped = markdown_to_adf(&md).unwrap();
7007 let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
7008 assert_eq!(rt_attrs["language"], "");
7009 }
7010
7011 #[test]
7012 fn round_trip_code_block_with_language() {
7013 let adf_json = r#"{"version":1,"type":"doc","content":[
7014 {"type":"codeBlock","attrs":{"language":"python"},"content":[{"type":"text","text":"print('hi')"}]}
7015 ]}"#;
7016 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7017 let md = adf_to_markdown(&doc).unwrap();
7018 let round_tripped = markdown_to_adf(&md).unwrap();
7019 let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
7020 assert_eq!(rt_attrs["language"], "python");
7021 }
7022
7023 #[test]
7024 fn multiple_paragraphs() {
7025 let md = "First paragraph.\n\nSecond paragraph.\n";
7026 let adf = markdown_to_adf(md).unwrap();
7027 assert_eq!(adf.content.len(), 2);
7028 assert_eq!(adf.content[0].node_type, "paragraph");
7029 assert_eq!(adf.content[1].node_type, "paragraph");
7030 }
7031
7032 #[test]
7035 fn horizontal_rule_underscores() {
7036 let doc = markdown_to_adf("___").unwrap();
7037 assert_eq!(doc.content[0].node_type, "rule");
7038 }
7039
7040 #[test]
7041 fn not_a_horizontal_rule_too_short() {
7042 let doc = markdown_to_adf("--").unwrap();
7043 assert_eq!(doc.content[0].node_type, "paragraph");
7044 }
7045
7046 #[test]
7047 fn bullet_list_star_marker() {
7048 let md = "* Apple\n* Banana";
7049 let doc = markdown_to_adf(md).unwrap();
7050 assert_eq!(doc.content[0].node_type, "bulletList");
7051 let items = doc.content[0].content.as_ref().unwrap();
7052 assert_eq!(items.len(), 2);
7053 }
7054
7055 #[test]
7056 fn bullet_list_plus_marker() {
7057 let md = "+ One\n+ Two";
7058 let doc = markdown_to_adf(md).unwrap();
7059 assert_eq!(doc.content[0].node_type, "bulletList");
7060 }
7061
7062 #[test]
7063 fn ordered_list_non_one_start() {
7064 let md = "5. Fifth\n6. Sixth";
7065 let doc = markdown_to_adf(md).unwrap();
7066 let node = &doc.content[0];
7067 assert_eq!(node.node_type, "orderedList");
7068 let attrs = node.attrs.as_ref().unwrap();
7069 assert_eq!(attrs["order"], 5);
7070 }
7071
7072 #[test]
7073 fn ordered_list_start_at_one_omits_order_attr() {
7074 let md = "1. First\n2. Second";
7078 let doc = markdown_to_adf(md).unwrap();
7079 let node = &doc.content[0];
7080 assert_eq!(node.node_type, "orderedList");
7081 assert!(
7082 node.attrs.is_none(),
7083 "attrs should be omitted when order=1, got: {:?}",
7084 node.attrs
7085 );
7086 }
7087
7088 #[test]
7089 fn blockquote_bare_marker() {
7090 let md = ">quoted text";
7092 let doc = markdown_to_adf(md).unwrap();
7093 assert_eq!(doc.content[0].node_type, "blockquote");
7094 }
7095
7096 #[test]
7097 fn image_no_alt() {
7098 let md = "";
7099 let doc = markdown_to_adf(md).unwrap();
7100 let node = &doc.content[0];
7101 assert_eq!(node.node_type, "mediaSingle");
7102 let media = &node.content.as_ref().unwrap()[0];
7104 let attrs = media.attrs.as_ref().unwrap();
7105 assert!(attrs.get("alt").is_none());
7106 }
7107
7108 #[test]
7109 fn image_with_alt() {
7110 let md = "";
7111 let doc = markdown_to_adf(md).unwrap();
7112 let media = &doc.content[0].content.as_ref().unwrap()[0];
7113 let attrs = media.attrs.as_ref().unwrap();
7114 assert_eq!(attrs["alt"], "A photo");
7115 }
7116
7117 #[test]
7118 fn table_multi_body_rows() {
7119 let md = "| H1 | H2 |\n| --- | --- |\n| a | b |\n| c | d |";
7120 let doc = markdown_to_adf(md).unwrap();
7121 let rows = doc.content[0].content.as_ref().unwrap();
7122 assert_eq!(rows.len(), 3); let header_cells = rows[0].content.as_ref().unwrap();
7125 assert_eq!(header_cells[0].node_type, "tableHeader");
7126 let body_cells = rows[1].content.as_ref().unwrap();
7128 assert_eq!(body_cells[0].node_type, "tableCell");
7129 }
7130
7131 #[test]
7132 fn table_no_separator_is_not_table() {
7133 let md = "| not | a table |";
7135 let doc = markdown_to_adf(md).unwrap();
7136 assert_eq!(doc.content[0].node_type, "paragraph");
7137 }
7138
7139 #[test]
7140 fn inline_underscore_bold() {
7141 let doc = markdown_to_adf("Some __bold__ text").unwrap();
7142 let content = doc.content[0].content.as_ref().unwrap();
7143 let bold_node = &content[1];
7144 assert_eq!(bold_node.text.as_deref(), Some("bold"));
7145 let marks = bold_node.marks.as_ref().unwrap();
7146 assert_eq!(marks[0].mark_type, "strong");
7147 }
7148
7149 #[test]
7150 fn inline_underscore_italic() {
7151 let doc = markdown_to_adf("Some _italic_ text").unwrap();
7152 let content = doc.content[0].content.as_ref().unwrap();
7153 let italic_node = &content[1];
7154 assert_eq!(italic_node.text.as_deref(), Some("italic"));
7155 let marks = italic_node.marks.as_ref().unwrap();
7156 assert_eq!(marks[0].mark_type, "em");
7157 }
7158
7159 #[test]
7160 fn intraword_underscore_not_emphasis() {
7161 let doc = markdown_to_adf("call do_something_useful now").unwrap();
7163 let content = doc.content[0].content.as_ref().unwrap();
7164 assert_eq!(content.len(), 1, "should be a single text node");
7165 assert_eq!(
7166 content[0].text.as_deref(),
7167 Some("call do_something_useful now")
7168 );
7169 assert!(content[0].marks.is_none());
7170 }
7171
7172 #[test]
7173 fn intraword_underscore_multiple() {
7174 let doc = markdown_to_adf("use a_b_c_d here").unwrap();
7176 let content = doc.content[0].content.as_ref().unwrap();
7177 assert_eq!(content.len(), 1);
7178 assert_eq!(content[0].text.as_deref(), Some("use a_b_c_d here"));
7179 assert!(content[0].marks.is_none());
7180 }
7181
7182 #[test]
7183 fn intraword_double_underscore_not_bold() {
7184 let doc = markdown_to_adf("foo__bar__baz").unwrap();
7186 let content = doc.content[0].content.as_ref().unwrap();
7187 assert_eq!(content.len(), 1);
7188 assert_eq!(content[0].text.as_deref(), Some("foo__bar__baz"));
7189 assert!(content[0].marks.is_none());
7190 }
7191
7192 #[test]
7193 fn intraword_triple_underscore_not_bold_italic() {
7194 let doc = markdown_to_adf("x___y___z").unwrap();
7196 let content = doc.content[0].content.as_ref().unwrap();
7197 assert_eq!(content.len(), 1);
7198 assert_eq!(content[0].text.as_deref(), Some("x___y___z"));
7199 assert!(content[0].marks.is_none());
7200 }
7201
7202 #[test]
7203 fn underscore_emphasis_still_works_with_spaces() {
7204 let doc = markdown_to_adf("some _italic_ here").unwrap();
7206 let content = doc.content[0].content.as_ref().unwrap();
7207 assert_eq!(content.len(), 3);
7208 assert_eq!(content[1].text.as_deref(), Some("italic"));
7209 let marks = content[1].marks.as_ref().unwrap();
7210 assert_eq!(marks[0].mark_type, "em");
7211 }
7212
7213 #[test]
7214 fn underscore_bold_still_works_with_spaces() {
7215 let doc = markdown_to_adf("some __bold__ here").unwrap();
7217 let content = doc.content[0].content.as_ref().unwrap();
7218 assert_eq!(content.len(), 3);
7219 assert_eq!(content[1].text.as_deref(), Some("bold"));
7220 let marks = content[1].marks.as_ref().unwrap();
7221 assert_eq!(marks[0].mark_type, "strong");
7222 }
7223
7224 #[test]
7225 fn intraword_underscore_closing_only() {
7226 let doc = markdown_to_adf("_foo_bar").unwrap();
7228 let content = doc.content[0].content.as_ref().unwrap();
7229 assert_eq!(content.len(), 1);
7230 assert_eq!(content[0].text.as_deref(), Some("_foo_bar"));
7231 }
7232
7233 #[test]
7234 fn intraword_double_underscore_closing_only() {
7235 let doc = markdown_to_adf("__foo__bar").unwrap();
7237 let content = doc.content[0].content.as_ref().unwrap();
7238 assert_eq!(content.len(), 1);
7239 assert_eq!(content[0].text.as_deref(), Some("__foo__bar"));
7240 }
7241
7242 #[test]
7243 fn intraword_triple_underscore_closing_only() {
7244 let doc = markdown_to_adf("___foo___bar").unwrap();
7246 let content = doc.content[0].content.as_ref().unwrap();
7247 assert_eq!(content.len(), 1);
7248 assert_eq!(content[0].text.as_deref(), Some("___foo___bar"));
7249 }
7250
7251 #[test]
7252 fn asterisk_emphasis_unaffected_by_intraword_fix() {
7253 let doc = markdown_to_adf("foo*bar*baz").unwrap();
7255 let content = doc.content[0].content.as_ref().unwrap();
7256 assert!(content.len() > 1 || content[0].marks.is_some());
7258 }
7259
7260 #[test]
7261 fn intraword_underscore_at_start_of_text() {
7262 let doc = markdown_to_adf("_italic_ word").unwrap();
7264 let content = doc.content[0].content.as_ref().unwrap();
7265 assert_eq!(content[0].text.as_deref(), Some("italic"));
7266 let marks = content[0].marks.as_ref().unwrap();
7267 assert_eq!(marks[0].mark_type, "em");
7268 }
7269
7270 #[test]
7271 fn intraword_underscore_at_end_of_text() {
7272 let doc = markdown_to_adf("word _italic_").unwrap();
7274 let content = doc.content[0].content.as_ref().unwrap();
7275 let last = content.last().unwrap();
7276 assert_eq!(last.text.as_deref(), Some("italic"));
7277 let marks = last.marks.as_ref().unwrap();
7278 assert_eq!(marks[0].mark_type, "em");
7279 }
7280
7281 #[test]
7282 fn intraword_underscore_opening_only() {
7283 let doc = markdown_to_adf("a_b c_d").unwrap();
7286 let content = doc.content[0].content.as_ref().unwrap();
7287 assert_eq!(content.len(), 1);
7288 assert_eq!(content[0].text.as_deref(), Some("a_b c_d"));
7289 }
7290
7291 #[test]
7292 fn intraword_underscore_roundtrip() {
7293 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call the do_something_useful function"}]}]}"#;
7295 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7296 let jfm = adf_to_markdown(&adf).unwrap();
7297 let roundtripped = markdown_to_adf(&jfm).unwrap();
7298 let content = roundtripped.content[0].content.as_ref().unwrap();
7299 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7300 assert_eq!(
7301 content[0].text.as_deref(),
7302 Some("call the do_something_useful function")
7303 );
7304 assert!(content[0].marks.is_none());
7305 }
7306
7307 #[test]
7308 fn asterisk_emphasis_roundtrip() {
7309 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status: *confirmed* and active"}]}]}"#;
7311 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7312 let jfm = adf_to_markdown(&adf).unwrap();
7313 let roundtripped = markdown_to_adf(&jfm).unwrap();
7314 let content = roundtripped.content[0].content.as_ref().unwrap();
7315 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7316 assert_eq!(
7317 content[0].text.as_deref(),
7318 Some("Status: *confirmed* and active")
7319 );
7320 assert!(content[0].marks.is_none());
7321 }
7322
7323 #[test]
7324 fn double_asterisk_roundtrip() {
7325 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use **kwargs in Python"}]}]}"#;
7327 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7328 let jfm = adf_to_markdown(&adf).unwrap();
7329 let roundtripped = markdown_to_adf(&jfm).unwrap();
7330 let content = roundtripped.content[0].content.as_ref().unwrap();
7331 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7332 assert_eq!(content[0].text.as_deref(), Some("Use **kwargs in Python"));
7333 assert!(content[0].marks.is_none());
7334 }
7335
7336 #[test]
7337 fn asterisk_with_em_mark_roundtrip() {
7338 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a*b","marks":[{"type":"em"}]}]}]}"#;
7340 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7341 let jfm = adf_to_markdown(&adf).unwrap();
7342 let roundtripped = markdown_to_adf(&jfm).unwrap();
7343 let content = roundtripped.content[0].content.as_ref().unwrap();
7344 let em_node = content.iter().find(|n| {
7346 n.marks
7347 .as_ref()
7348 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7349 });
7350 assert!(em_node.is_some(), "should have an em-marked node");
7351 assert_eq!(em_node.unwrap().text.as_deref(), Some("a*b"));
7352 }
7353
7354 #[test]
7355 fn lone_asterisk_roundtrip() {
7356 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"rating: 5 * stars"}]}]}"#;
7358 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7359 let jfm = adf_to_markdown(&adf).unwrap();
7360 let roundtripped = markdown_to_adf(&jfm).unwrap();
7361 let content = roundtripped.content[0].content.as_ref().unwrap();
7362 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7363 assert_eq!(content[0].text.as_deref(), Some("rating: 5 * stars"));
7364 }
7365
7366 #[test]
7367 fn escape_emphasis_markers_unit() {
7368 assert_eq!(escape_emphasis_markers("hello"), "hello");
7369 assert_eq!(escape_emphasis_markers("*bold*"), r"\*bold\*");
7370 assert_eq!(escape_emphasis_markers("**strong**"), r"\*\*strong\*\*");
7371 assert_eq!(escape_emphasis_markers("no stars"), "no stars");
7372 assert_eq!(escape_emphasis_markers("a * b"), r"a \* b");
7373 assert_eq!(escape_emphasis_markers(""), "");
7374 }
7375
7376 #[test]
7377 fn escape_emphasis_markers_underscore_intraword() {
7378 assert_eq!(escape_emphasis_markers("foo_bar"), "foo_bar");
7381 assert_eq!(escape_emphasis_markers("a_b_c"), "a_b_c");
7382 assert_eq!(escape_emphasis_markers("foo__bar"), "foo__bar");
7383 assert_eq!(
7384 escape_emphasis_markers("call do_something_useful"),
7385 "call do_something_useful"
7386 );
7387 }
7388
7389 #[test]
7390 fn escape_emphasis_markers_underscore_at_boundary() {
7391 assert_eq!(escape_emphasis_markers("_Action"), r"\_Action");
7394 assert_eq!(escape_emphasis_markers("Action_"), r"Action\_");
7395 assert_eq!(escape_emphasis_markers("_ "), r"\_ ");
7396 assert_eq!(escape_emphasis_markers(" _"), r" \_");
7397 assert_eq!(escape_emphasis_markers("_"), r"\_");
7398 }
7399
7400 #[test]
7401 fn escape_emphasis_markers_underscore_with_punctuation() {
7402 assert_eq!(escape_emphasis_markers("foo _bar"), r"foo \_bar");
7404 assert_eq!(escape_emphasis_markers("foo_ bar"), r"foo\_ bar");
7405 assert_eq!(escape_emphasis_markers("(_x_)"), r"(\_x\_)");
7406 }
7407
7408 #[test]
7409 fn find_unescaped_skips_backslash_escaped() {
7410 assert_eq!(find_unescaped(r"a\*\*b**", "**"), Some(6));
7412 assert_eq!(find_unescaped(r"a\*\*b", "**"), None);
7414 assert_eq!(find_unescaped("a**b", "**"), Some(1));
7416 assert_eq!(find_unescaped("", "**"), None);
7418 }
7419
7420 #[test]
7421 fn find_unescaped_char_skips_backslash_escaped() {
7422 assert_eq!(find_unescaped_char(r"a\*b*", b'*'), Some(4));
7424 assert_eq!(find_unescaped_char(r"\*", b'*'), None);
7426 assert_eq!(find_unescaped_char("a*b", b'*'), Some(1));
7428 assert_eq!(find_unescaped_char("", b'*'), None);
7430 }
7431
7432 #[test]
7433 fn double_asterisk_in_strong_mark_roundtrip() {
7434 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call **kwargs","marks":[{"type":"strong"}]}]}]}"#;
7436 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7437 let jfm = adf_to_markdown(&adf).unwrap();
7438 let roundtripped = markdown_to_adf(&jfm).unwrap();
7439 let content = roundtripped.content[0].content.as_ref().unwrap();
7440 let strong_node = content.iter().find(|n| {
7441 n.marks
7442 .as_ref()
7443 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7444 });
7445 assert!(strong_node.is_some(), "should have a strong-marked node");
7446 assert_eq!(strong_node.unwrap().text.as_deref(), Some("call **kwargs"));
7447 }
7448
7449 #[test]
7450 fn backtick_code_roundtrip() {
7451 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Set `max_retries` to 3 in the config"}]}]}"#;
7453 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7454 let jfm = adf_to_markdown(&adf).unwrap();
7455 let roundtripped = markdown_to_adf(&jfm).unwrap();
7456 let content = roundtripped.content[0].content.as_ref().unwrap();
7457 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7458 assert_eq!(
7459 content[0].text.as_deref(),
7460 Some("Set `max_retries` to 3 in the config")
7461 );
7462 assert!(content[0].marks.is_none());
7463 }
7464
7465 #[test]
7466 fn multiple_backtick_spans_roundtrip() {
7467 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use `foo` and `bar` together"}]}]}"#;
7469 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7470 let jfm = adf_to_markdown(&adf).unwrap();
7471 let roundtripped = markdown_to_adf(&jfm).unwrap();
7472 let content = roundtripped.content[0].content.as_ref().unwrap();
7473 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7474 assert_eq!(
7475 content[0].text.as_deref(),
7476 Some("Use `foo` and `bar` together")
7477 );
7478 assert!(content[0].marks.is_none());
7479 }
7480
7481 #[test]
7482 fn lone_backtick_roundtrip() {
7483 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use a ` character"}]}]}"#;
7485 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7486 let jfm = adf_to_markdown(&adf).unwrap();
7487 let roundtripped = markdown_to_adf(&jfm).unwrap();
7488 let content = roundtripped.content[0].content.as_ref().unwrap();
7489 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7490 assert_eq!(content[0].text.as_deref(), Some("Use a ` character"));
7491 assert!(content[0].marks.is_none());
7492 }
7493
7494 #[test]
7495 fn backtick_with_code_mark_roundtrip() {
7496 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"max_retries","marks":[{"type":"code"}]}]}]}"#;
7498 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7499 let jfm = adf_to_markdown(&adf).unwrap();
7500 assert_eq!(jfm.trim(), "`max_retries`");
7501 let roundtripped = markdown_to_adf(&jfm).unwrap();
7502 let content = roundtripped.content[0].content.as_ref().unwrap();
7503 let code_node = content.iter().find(|n| {
7504 n.marks
7505 .as_ref()
7506 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7507 });
7508 assert!(code_node.is_some(), "should have a code-marked node");
7509 assert_eq!(code_node.unwrap().text.as_deref(), Some("max_retries"));
7510 }
7511
7512 #[test]
7513 fn backtick_with_em_mark_roundtrip() {
7514 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"use `cfg`","marks":[{"type":"em"}]}]}]}"#;
7516 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7517 let jfm = adf_to_markdown(&adf).unwrap();
7518 let roundtripped = markdown_to_adf(&jfm).unwrap();
7519 let content = roundtripped.content[0].content.as_ref().unwrap();
7520 let em_node = content.iter().find(|n| {
7521 n.marks
7522 .as_ref()
7523 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7524 });
7525 assert!(em_node.is_some(), "should have an em-marked node");
7526 assert_eq!(em_node.unwrap().text.as_deref(), Some("use `cfg`"));
7527 }
7528
7529 #[test]
7530 fn escape_pipes_in_cell_unit() {
7531 assert_eq!(escape_pipes_in_cell("hello"), "hello");
7532 assert_eq!(escape_pipes_in_cell("a|b"), r"a\|b");
7533 assert_eq!(escape_pipes_in_cell("|"), r"\|");
7534 assert_eq!(escape_pipes_in_cell("|a|b|"), r"\|a\|b\|");
7535 assert_eq!(escape_pipes_in_cell(""), "");
7536 assert_eq!(
7537 escape_pipes_in_cell("`parser.decode[T|json]`"),
7538 r"`parser.decode[T\|json]`"
7539 );
7540 }
7541
7542 #[test]
7543 fn escape_backticks_unit() {
7544 assert_eq!(escape_backticks("hello"), "hello");
7545 assert_eq!(escape_backticks("`code`"), r"\`code\`");
7546 assert_eq!(escape_backticks("no ticks"), "no ticks");
7547 assert_eq!(escape_backticks("a ` b"), r"a \` b");
7548 assert_eq!(escape_backticks(""), "");
7549 assert_eq!(escape_backticks("`a` and `b`"), r"\`a\` and \`b\`");
7550 }
7551
7552 #[test]
7555 fn backslash_in_text_roundtrip() {
7556 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"The path is C:\\Users\\admin\\file.txt"}]}]}"#;
7558 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7559 let jfm = adf_to_markdown(&adf).unwrap();
7560 let roundtripped = markdown_to_adf(&jfm).unwrap();
7561 let content = roundtripped.content[0].content.as_ref().unwrap();
7562 assert_eq!(content.len(), 1, "should round-trip as a single text node");
7563 assert_eq!(
7564 content[0].text.as_deref(),
7565 Some(r"The path is C:\Users\admin\file.txt")
7566 );
7567 }
7568
7569 #[test]
7570 fn backslash_emitted_as_double_backslash() {
7571 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\b"}]}]}"#;
7572 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7573 let jfm = adf_to_markdown(&adf).unwrap();
7574 assert!(
7575 jfm.contains(r"a\\b"),
7576 "JFM should contain escaped backslash: {jfm}"
7577 );
7578 }
7579
7580 #[test]
7581 fn consecutive_backslashes_roundtrip() {
7582 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\\\b"}]}]}"#;
7583 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7584 let jfm = adf_to_markdown(&adf).unwrap();
7585 let roundtripped = markdown_to_adf(&jfm).unwrap();
7586 let content = roundtripped.content[0].content.as_ref().unwrap();
7587 assert_eq!(
7588 content[0].text.as_deref(),
7589 Some(r"a\\b"),
7590 "consecutive backslashes should survive round-trip"
7591 );
7592 }
7593
7594 #[test]
7595 fn backslash_with_strong_mark_roundtrip() {
7596 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"strong"}]}]}]}"#;
7597 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7598 let jfm = adf_to_markdown(&adf).unwrap();
7599 let roundtripped = markdown_to_adf(&jfm).unwrap();
7600 let content = roundtripped.content[0].content.as_ref().unwrap();
7601 let strong_node = content.iter().find(|n| {
7602 n.marks
7603 .as_ref()
7604 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7605 });
7606 assert!(strong_node.is_some(), "should have a strong-marked node");
7607 assert_eq!(strong_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7608 }
7609
7610 #[test]
7611 fn backslash_with_code_mark_not_escaped() {
7612 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"code"}]}]}]}"#;
7614 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7615 let jfm = adf_to_markdown(&adf).unwrap();
7616 assert_eq!(jfm.trim(), r"`C:\Users`");
7617 let roundtripped = markdown_to_adf(&jfm).unwrap();
7618 let content = roundtripped.content[0].content.as_ref().unwrap();
7619 let code_node = content.iter().find(|n| {
7620 n.marks
7621 .as_ref()
7622 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7623 });
7624 assert!(code_node.is_some(), "should have a code-marked node");
7625 assert_eq!(code_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7626 }
7627
7628 #[test]
7629 fn backslash_before_special_chars_roundtrip() {
7630 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"\\*not bold\\*"}]}]}"#;
7632 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7633 let jfm = adf_to_markdown(&adf).unwrap();
7634 let roundtripped = markdown_to_adf(&jfm).unwrap();
7635 let content = roundtripped.content[0].content.as_ref().unwrap();
7636 assert_eq!(
7637 content[0].text.as_deref(),
7638 Some(r"\*not bold\*"),
7639 "backslash before special char should survive round-trip"
7640 );
7641 }
7642
7643 #[test]
7644 fn backslash_and_newline_in_text_roundtrip() {
7645 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\path\nline2"}]}]}"#;
7647 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7648 let jfm = adf_to_markdown(&adf).unwrap();
7649 let roundtripped = markdown_to_adf(&jfm).unwrap();
7650 let content = roundtripped.content[0].content.as_ref().unwrap();
7651 assert_eq!(
7652 content[0].text.as_deref(),
7653 Some("C:\\path\nline2"),
7654 "backslash and newline should both survive round-trip"
7655 );
7656 }
7657
7658 #[test]
7659 fn lone_backslash_roundtrip() {
7660 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a \\ b"}]}]}"#;
7661 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7662 let jfm = adf_to_markdown(&adf).unwrap();
7663 let roundtripped = markdown_to_adf(&jfm).unwrap();
7664 let content = roundtripped.content[0].content.as_ref().unwrap();
7665 assert_eq!(content[0].text.as_deref(), Some(r"a \ b"));
7666 }
7667
7668 #[test]
7669 fn trailing_backslash_in_text_roundtrip() {
7670 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\"}]}]}"#;
7672 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7673 let jfm = adf_to_markdown(&adf).unwrap();
7674 let roundtripped = markdown_to_adf(&jfm).unwrap();
7675 let content = roundtripped.content[0].content.as_ref().unwrap();
7676 assert_eq!(
7677 content[0].text.as_deref(),
7678 Some(r"end\"),
7679 "trailing backslash should survive round-trip"
7680 );
7681 }
7682
7683 #[test]
7684 fn escape_bare_urls_unit() {
7685 assert_eq!(escape_bare_urls("hello"), "hello");
7686 assert_eq!(escape_bare_urls(""), "");
7687 assert_eq!(
7688 escape_bare_urls("https://example.com"),
7689 r"\https://example.com"
7690 );
7691 assert_eq!(
7692 escape_bare_urls("http://example.com"),
7693 r"\http://example.com"
7694 );
7695 assert_eq!(
7696 escape_bare_urls("see https://a.com and https://b.com"),
7697 r"see \https://a.com and \https://b.com"
7698 );
7699 assert_eq!(escape_bare_urls("http header"), "http header");
7701 assert_eq!(escape_bare_urls("https is secure"), "https is secure");
7702 }
7703
7704 #[test]
7705 fn heading_not_valid_without_space() {
7706 let doc = markdown_to_adf("#Title").unwrap();
7708 assert_eq!(doc.content[0].node_type, "paragraph");
7709 }
7710
7711 #[test]
7712 fn heading_level_too_high() {
7713 let doc = markdown_to_adf("####### Not a heading").unwrap();
7715 assert_eq!(doc.content[0].node_type, "paragraph");
7716 }
7717
7718 #[test]
7719 fn empty_document() {
7720 let doc = markdown_to_adf("").unwrap();
7721 assert!(doc.content.is_empty());
7722 }
7723
7724 #[test]
7725 fn only_blank_lines() {
7726 let doc = markdown_to_adf("\n\n\n").unwrap();
7727 assert!(doc.content.is_empty());
7728 }
7729
7730 #[test]
7731 fn code_block_unterminated() {
7732 let md = "```rust\nfn main() {}";
7734 let doc = markdown_to_adf(md).unwrap();
7735 assert_eq!(doc.content[0].node_type, "codeBlock");
7736 }
7737
7738 #[test]
7739 fn mixed_document() {
7740 let md = "# Title\n\nA paragraph.\n\n- Item\n\n```\ncode\n```\n\n> quote\n\n---\n\n1. numbered\n";
7741 let doc = markdown_to_adf(md).unwrap();
7742 let types: Vec<&str> = doc.content.iter().map(|n| n.node_type.as_str()).collect();
7743 assert_eq!(
7744 types,
7745 vec![
7746 "heading",
7747 "paragraph",
7748 "bulletList",
7749 "codeBlock",
7750 "blockquote",
7751 "rule",
7752 "orderedList",
7753 ]
7754 );
7755 }
7756
7757 #[test]
7760 fn adf_ordered_list_to_markdown() {
7761 let doc = AdfDocument {
7762 version: 1,
7763 doc_type: "doc".to_string(),
7764 content: vec![AdfNode::ordered_list(
7765 vec![
7766 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("First")])]),
7767 AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("Second")])]),
7768 ],
7769 None,
7770 )],
7771 };
7772 let md = adf_to_markdown(&doc).unwrap();
7773 assert!(md.contains("1. First"));
7774 assert!(md.contains("2. Second"));
7775 }
7776
7777 #[test]
7778 fn adf_ordered_list_custom_start() {
7779 let doc = AdfDocument {
7780 version: 1,
7781 doc_type: "doc".to_string(),
7782 content: vec![AdfNode::ordered_list(
7783 vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
7784 AdfNode::text("Third"),
7785 ])])],
7786 Some(3),
7787 )],
7788 };
7789 let md = adf_to_markdown(&doc).unwrap();
7790 assert!(md.contains("3. Third"));
7791 }
7792
7793 #[test]
7794 fn adf_blockquote_to_markdown() {
7795 let doc = AdfDocument {
7796 version: 1,
7797 doc_type: "doc".to_string(),
7798 content: vec![AdfNode::blockquote(vec![AdfNode::paragraph(vec![
7799 AdfNode::text("A quote"),
7800 ])])],
7801 };
7802 let md = adf_to_markdown(&doc).unwrap();
7803 assert!(md.contains("> A quote"));
7804 }
7805
7806 #[test]
7807 fn adf_table_to_markdown() {
7808 let doc = AdfDocument {
7809 version: 1,
7810 doc_type: "doc".to_string(),
7811 content: vec![AdfNode::table(vec![
7812 AdfNode::table_row(vec![
7813 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Name")])]),
7814 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Value")])]),
7815 ]),
7816 AdfNode::table_row(vec![
7817 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a")])]),
7818 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("1")])]),
7819 ]),
7820 ])],
7821 };
7822 let md = adf_to_markdown(&doc).unwrap();
7823 assert!(md.contains("| Name | Value |"));
7824 assert!(md.contains("| --- | --- |"));
7825 assert!(md.contains("| a | 1 |"));
7826 }
7827
7828 #[test]
7829 fn adf_media_to_markdown() {
7830 let doc = AdfDocument {
7831 version: 1,
7832 doc_type: "doc".to_string(),
7833 content: vec![AdfNode::media_single(
7834 "https://example.com/img.png",
7835 Some("Alt"),
7836 )],
7837 };
7838 let md = adf_to_markdown(&doc).unwrap();
7839 assert!(md.contains(""));
7840 }
7841
7842 #[test]
7843 fn adf_media_no_alt_to_markdown() {
7844 let doc = AdfDocument {
7845 version: 1,
7846 doc_type: "doc".to_string(),
7847 content: vec![AdfNode::media_single("https://example.com/img.png", None)],
7848 };
7849 let md = adf_to_markdown(&doc).unwrap();
7850 assert!(md.contains(""));
7851 }
7852
7853 #[test]
7854 fn adf_italic_to_markdown() {
7855 let doc = AdfDocument {
7856 version: 1,
7857 doc_type: "doc".to_string(),
7858 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7859 "emphasis",
7860 vec![AdfMark::em()],
7861 )])],
7862 };
7863 let md = adf_to_markdown(&doc).unwrap();
7864 assert_eq!(md.trim(), "*emphasis*");
7865 }
7866
7867 #[test]
7868 fn adf_strikethrough_to_markdown() {
7869 let doc = AdfDocument {
7870 version: 1,
7871 doc_type: "doc".to_string(),
7872 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7873 "deleted",
7874 vec![AdfMark::strike()],
7875 )])],
7876 };
7877 let md = adf_to_markdown(&doc).unwrap();
7878 assert_eq!(md.trim(), "~~deleted~~");
7879 }
7880
7881 #[test]
7882 fn adf_inline_code_to_markdown() {
7883 let doc = AdfDocument {
7884 version: 1,
7885 doc_type: "doc".to_string(),
7886 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7887 "code",
7888 vec![AdfMark::code()],
7889 )])],
7890 };
7891 let md = adf_to_markdown(&doc).unwrap();
7892 assert_eq!(md.trim(), "`code`");
7893 }
7894
7895 #[test]
7896 fn adf_code_with_link_to_markdown() {
7897 let doc = AdfDocument {
7898 version: 1,
7899 doc_type: "doc".to_string(),
7900 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7901 "func",
7902 vec![AdfMark::code(), AdfMark::link("https://example.com")],
7903 )])],
7904 };
7905 let md = adf_to_markdown(&doc).unwrap();
7906 assert_eq!(md.trim(), "[`func`](https://example.com)");
7907 }
7908
7909 #[test]
7910 fn adf_bold_italic_to_markdown() {
7911 let doc = AdfDocument {
7912 version: 1,
7913 doc_type: "doc".to_string(),
7914 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7915 "both",
7916 vec![AdfMark::strong(), AdfMark::em()],
7917 )])],
7918 };
7919 let md = adf_to_markdown(&doc).unwrap();
7920 assert_eq!(md.trim(), "***both***");
7921 }
7922
7923 #[test]
7924 fn adf_bold_link_to_markdown() {
7925 let doc = AdfDocument {
7926 version: 1,
7927 doc_type: "doc".to_string(),
7928 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7929 "bold link",
7930 vec![AdfMark::strong(), AdfMark::link("https://example.com")],
7931 )])],
7932 };
7933 let md = adf_to_markdown(&doc).unwrap();
7934 assert_eq!(md.trim(), "**[bold link](https://example.com)**");
7935 }
7936
7937 #[test]
7938 fn adf_strikethrough_bold_to_markdown() {
7939 let doc = AdfDocument {
7940 version: 1,
7941 doc_type: "doc".to_string(),
7942 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7943 "struck",
7944 vec![AdfMark::strike(), AdfMark::strong()],
7945 )])],
7946 };
7947 let md = adf_to_markdown(&doc).unwrap();
7948 assert_eq!(md.trim(), "~~**struck**~~");
7949 }
7950
7951 #[test]
7952 fn adf_hard_break_to_markdown() {
7953 let doc = AdfDocument {
7954 version: 1,
7955 doc_type: "doc".to_string(),
7956 content: vec![AdfNode::paragraph(vec![
7957 AdfNode::text("Line 1"),
7958 AdfNode::hard_break(),
7959 AdfNode::text("Line 2"),
7960 ])],
7961 };
7962 let md = adf_to_markdown(&doc).unwrap();
7963 assert!(md.contains("Line 1\\\n Line 2"));
7964 }
7965
7966 #[test]
7967 #[test]
7968 fn adf_unsupported_inline_to_markdown() {
7969 let doc = AdfDocument {
7970 version: 1,
7971 doc_type: "doc".to_string(),
7972 content: vec![AdfNode::paragraph(vec![AdfNode {
7973 node_type: "unknownInline".to_string(),
7974 attrs: None,
7975 content: None,
7976 text: None,
7977 marks: None,
7978 local_id: None,
7979 parameters: None,
7980 }])],
7981 };
7982 let md = adf_to_markdown(&doc).unwrap();
7983 assert!(md.contains("<!-- unsupported inline: unknownInline -->"));
7984 }
7985
7986 #[test]
7989 fn adf_media_inline_to_markdown() {
7990 let doc = AdfDocument {
7991 version: 1,
7992 doc_type: "doc".to_string(),
7993 content: vec![AdfNode::paragraph(vec![
7994 AdfNode::text("see "),
7995 AdfNode::media_inline(serde_json::json!({
7996 "type": "image",
7997 "id": "abcdef01-2345-6789-abcd-abcdef012345",
7998 "collection": "contentId-111111",
7999 "width": 200,
8000 "height": 100
8001 })),
8002 AdfNode::text(" for details"),
8003 ])],
8004 };
8005 let md = adf_to_markdown(&doc).unwrap();
8006 assert!(md.contains(":media-inline[]{"), "got: {md}");
8007 assert!(md.contains("type=image"));
8008 assert!(md.contains("id=abcdef01-2345-6789-abcd-abcdef012345"));
8009 assert!(md.contains("collection=contentId-111111"));
8010 assert!(md.contains("width=200"));
8011 assert!(md.contains("height=100"));
8012 assert!(!md.contains("<!-- unsupported inline"));
8013 }
8014
8015 #[test]
8016 fn media_inline_round_trip() {
8017 let doc = AdfDocument {
8018 version: 1,
8019 doc_type: "doc".to_string(),
8020 content: vec![AdfNode::paragraph(vec![
8021 AdfNode::text("see "),
8022 AdfNode::media_inline(serde_json::json!({
8023 "type": "image",
8024 "id": "abcdef01-2345-6789-abcd-abcdef012345",
8025 "collection": "contentId-111111",
8026 "width": 200,
8027 "height": 100
8028 })),
8029 AdfNode::text(" for details"),
8030 ])],
8031 };
8032 let md = adf_to_markdown(&doc).unwrap();
8033 let rt = markdown_to_adf(&md).unwrap();
8034
8035 let content = rt.content[0].content.as_ref().unwrap();
8036 assert_eq!(content[0].text.as_deref(), Some("see "));
8037 assert_eq!(content[1].node_type, "mediaInline");
8038 let attrs = content[1].attrs.as_ref().unwrap();
8039 assert_eq!(attrs["type"], "image");
8040 assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
8041 assert_eq!(attrs["collection"], "contentId-111111");
8042 assert_eq!(attrs["width"], 200);
8043 assert_eq!(attrs["height"], 100);
8044 assert_eq!(content[2].text.as_deref(), Some(" for details"));
8045 }
8046
8047 #[test]
8048 fn media_inline_external_url_round_trip() {
8049 let doc = AdfDocument {
8050 version: 1,
8051 doc_type: "doc".to_string(),
8052 content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
8053 serde_json::json!({
8054 "type": "external",
8055 "url": "https://example.com/image.png",
8056 "alt": "example",
8057 "width": 400,
8058 "height": 300
8059 }),
8060 )])],
8061 };
8062 let md = adf_to_markdown(&doc).unwrap();
8063 let rt = markdown_to_adf(&md).unwrap();
8064
8065 let content = rt.content[0].content.as_ref().unwrap();
8066 assert_eq!(content[0].node_type, "mediaInline");
8067 let attrs = content[0].attrs.as_ref().unwrap();
8068 assert_eq!(attrs["type"], "external");
8069 assert_eq!(attrs["url"], "https://example.com/image.png");
8070 assert_eq!(attrs["alt"], "example");
8071 assert_eq!(attrs["width"], 400);
8072 assert_eq!(attrs["height"], 300);
8073 }
8074
8075 #[test]
8076 fn media_inline_minimal_attrs() {
8077 let doc = AdfDocument {
8078 version: 1,
8079 doc_type: "doc".to_string(),
8080 content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
8081 serde_json::json!({"type": "file", "id": "abc-123"}),
8082 )])],
8083 };
8084 let md = adf_to_markdown(&doc).unwrap();
8085 let rt = markdown_to_adf(&md).unwrap();
8086
8087 let content = rt.content[0].content.as_ref().unwrap();
8088 assert_eq!(content[0].node_type, "mediaInline");
8089 let attrs = content[0].attrs.as_ref().unwrap();
8090 assert_eq!(attrs["type"], "file");
8091 assert_eq!(attrs["id"], "abc-123");
8092 }
8093
8094 #[test]
8095 fn media_inline_from_issue_476_reproducer() {
8096 let adf_json: serde_json::Value = serde_json::json!({
8098 "type": "doc",
8099 "version": 1,
8100 "content": [
8101 {
8102 "type": "paragraph",
8103 "content": [
8104 {"type": "text", "text": "see "},
8105 {
8106 "type": "mediaInline",
8107 "attrs": {
8108 "collection": "contentId-111111",
8109 "height": 100,
8110 "id": "abcdef01-2345-6789-abcd-abcdef012345",
8111 "localId": "aabbccdd-1234-5678-abcd-aabbccdd1234",
8112 "type": "image",
8113 "width": 200
8114 }
8115 },
8116 {"type": "text", "text": " for details"}
8117 ]
8118 }
8119 ]
8120 });
8121 let doc: AdfDocument = serde_json::from_value(adf_json).unwrap();
8122 let md = adf_to_markdown(&doc).unwrap();
8123 assert!(
8124 !md.contains("<!-- unsupported inline"),
8125 "mediaInline should not be unsupported; got: {md}"
8126 );
8127
8128 let rt = markdown_to_adf(&md).unwrap();
8129 let content = rt.content[0].content.as_ref().unwrap();
8130 assert_eq!(content[1].node_type, "mediaInline");
8131 let attrs = content[1].attrs.as_ref().unwrap();
8132 assert_eq!(attrs["type"], "image");
8133 assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
8134 assert_eq!(attrs["collection"], "contentId-111111");
8135 assert_eq!(attrs["width"], 200);
8136 assert_eq!(attrs["height"], 100);
8137 assert_eq!(attrs["localId"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
8138 }
8139
8140 #[test]
8141 fn emoji_shortcode() {
8142 let doc = markdown_to_adf("Hello :wave: world").unwrap();
8143 let content = doc.content[0].content.as_ref().unwrap();
8144 assert_eq!(content[0].text.as_deref(), Some("Hello "));
8145 assert_eq!(content[1].node_type, "emoji");
8146 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":wave:");
8147 assert_eq!(content[2].text.as_deref(), Some(" world"));
8148 }
8149
8150 #[test]
8151 fn adf_emoji_to_markdown() {
8152 let doc = AdfDocument {
8153 version: 1,
8154 doc_type: "doc".to_string(),
8155 content: vec![AdfNode::paragraph(vec![AdfNode::emoji("thumbsup")])],
8156 };
8157 let md = adf_to_markdown(&doc).unwrap();
8158 assert!(md.contains(":thumbsup:"));
8159 }
8160
8161 #[test]
8162 fn adf_emoji_with_colon_prefix_to_markdown() {
8163 let doc = AdfDocument {
8165 version: 1,
8166 doc_type: "doc".to_string(),
8167 content: vec![AdfNode::paragraph(vec![AdfNode {
8168 node_type: "emoji".to_string(),
8169 attrs: Some(serde_json::json!({"shortName": ":thumbsup:"})),
8170 content: None,
8171 text: None,
8172 marks: None,
8173 local_id: None,
8174 parameters: None,
8175 }])],
8176 };
8177 let md = adf_to_markdown(&doc).unwrap();
8178 assert!(md.contains(":thumbsup:"));
8179 assert!(!md.contains("::thumbsup::"));
8181 }
8182
8183 #[test]
8184 fn round_trip_emoji() {
8185 let md = "Hello :wave: world\n";
8186 let doc = markdown_to_adf(md).unwrap();
8187 let result = adf_to_markdown(&doc).unwrap();
8188 assert!(result.contains(":wave:"));
8189 }
8190
8191 #[test]
8192 fn emoji_with_id_and_text_round_trips() {
8193 let doc = AdfDocument {
8194 version: 1,
8195 doc_type: "doc".to_string(),
8196 content: vec![AdfNode::paragraph(vec![AdfNode {
8197 node_type: "emoji".to_string(),
8198 attrs: Some(
8199 serde_json::json!({"shortName": ":check_mark:", "id": "2705", "text": "✅"}),
8200 ),
8201 content: None,
8202 text: None,
8203 marks: None,
8204 local_id: None,
8205 parameters: None,
8206 }])],
8207 };
8208 let md = adf_to_markdown(&doc).unwrap();
8209 assert!(md.contains(":check_mark:"), "shortcode present: {md}");
8210 assert!(md.contains("id="), "id attr present: {md}");
8211 assert!(md.contains("text="), "text attr present: {md}");
8212
8213 let round_tripped = markdown_to_adf(&md).unwrap();
8215 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8216 let attrs = emoji.attrs.as_ref().unwrap();
8217 assert_eq!(attrs["shortName"], ":check_mark:");
8218 assert_eq!(attrs["id"], "2705");
8219 assert_eq!(attrs["text"], "✅");
8220 }
8221
8222 #[test]
8223 fn emoji_without_extra_attrs_still_works() {
8224 let md = "Hello :wave: world\n";
8225 let doc = markdown_to_adf(md).unwrap();
8226 let emoji = &doc.content[0].content.as_ref().unwrap()[1];
8227 assert_eq!(emoji.attrs.as_ref().unwrap()["shortName"], ":wave:");
8228 assert!(emoji.attrs.as_ref().unwrap().get("id").is_none());
8230 }
8231
8232 #[test]
8233 fn emoji_shortname_preserves_colons_round_trip() {
8234 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8236 {"type":"emoji","attrs":{"shortName":":cross_mark:","id":"atlassian-cross_mark","text":"❌"}}
8237 ]}]}"#;
8238 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8239
8240 let md = adf_to_markdown(&doc).unwrap();
8242 let round_tripped = markdown_to_adf(&md).unwrap();
8243 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8244 let attrs = emoji.attrs.as_ref().unwrap();
8245 assert_eq!(
8246 attrs["shortName"], ":cross_mark:",
8247 "shortName should preserve colons, got: {}",
8248 attrs["shortName"]
8249 );
8250 assert_eq!(attrs["id"], "atlassian-cross_mark");
8251 assert_eq!(attrs["text"], "❌");
8252 }
8253
8254 #[test]
8255 fn emoji_shortname_without_colons_preserved() {
8256 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8258 {"type":"emoji","attrs":{"shortName":"white_check_mark","id":"2705","text":"✅"}}
8259 ]}]}"#;
8260 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8261 let md = adf_to_markdown(&doc).unwrap();
8262 let round_tripped = markdown_to_adf(&md).unwrap();
8263 let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8264 let attrs = emoji.attrs.as_ref().unwrap();
8265 assert_eq!(
8266 attrs["shortName"], "white_check_mark",
8267 "shortName without colons should stay without colons, got: {}",
8268 attrs["shortName"]
8269 );
8270 }
8271
8272 #[test]
8273 fn colon_in_text_not_emoji() {
8274 let doc = markdown_to_adf("Time is 10:30 today").unwrap();
8276 let content = doc.content[0].content.as_ref().unwrap();
8277 assert_eq!(content.len(), 1);
8278 assert_eq!(content[0].node_type, "text");
8279 }
8280
8281 #[test]
8282 fn text_with_shortcode_pattern_round_trips_as_text() {
8283 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Alert :fire: triggered on pod:pod42"}]}]}"#;
8285 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8286
8287 let md = adf_to_markdown(&doc).unwrap();
8288 let round_tripped = markdown_to_adf(&md).unwrap();
8289 let content = round_tripped.content[0].content.as_ref().unwrap();
8290
8291 assert_eq!(
8292 content.len(),
8293 1,
8294 "should be a single text node, got: {content:?}"
8295 );
8296 assert_eq!(content[0].node_type, "text");
8297 assert_eq!(
8298 content[0].text.as_deref().unwrap(),
8299 "Alert :fire: triggered on pod:pod42"
8300 );
8301 }
8302
8303 #[test]
8304 fn double_colon_pattern_round_trips_as_text() {
8305 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status::Active::Running"}]}]}"#;
8307 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8308
8309 let md = adf_to_markdown(&doc).unwrap();
8310 let round_tripped = markdown_to_adf(&md).unwrap();
8311 let content = round_tripped.content[0].content.as_ref().unwrap();
8312
8313 assert_eq!(
8314 content.len(),
8315 1,
8316 "should be a single text node, got: {content:?}"
8317 );
8318 assert_eq!(content[0].node_type, "text");
8319 assert_eq!(
8320 content[0].text.as_deref().unwrap(),
8321 "Status::Active::Running"
8322 );
8323 }
8324
8325 #[test]
8326 fn real_emoji_node_still_round_trips() {
8327 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8329 {"type":"text","text":"Hello "},
8330 {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8331 {"type":"text","text":" world"}
8332 ]}]}"#;
8333 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8334
8335 let md = adf_to_markdown(&doc).unwrap();
8336 let round_tripped = markdown_to_adf(&md).unwrap();
8337 let content = round_tripped.content[0].content.as_ref().unwrap();
8338
8339 assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8341 assert_eq!(content[0].text.as_deref(), Some("Hello "));
8342 assert_eq!(content[1].node_type, "emoji");
8343 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":fire:");
8344 assert_eq!(content[2].text.as_deref(), Some(" world"));
8345 }
8346
8347 #[test]
8348 fn combined_emoji_shortname_round_trips_as_single_node() {
8349 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8354 {"type":"text","text":"Thanks for the help "},
8355 {"type":"emoji","attrs":{"shortName":":slightly_smiling_face::bow:","id":"","text":""}}
8356 ]}]}"#;
8357 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8358
8359 let md = adf_to_markdown(&doc).unwrap();
8360 let round_tripped = markdown_to_adf(&md).unwrap();
8361 let content = round_tripped.content[0].content.as_ref().unwrap();
8362
8363 assert_eq!(
8364 content.len(),
8365 2,
8366 "should have text + single combined emoji: {content:?}"
8367 );
8368 assert_eq!(content[0].text.as_deref(), Some("Thanks for the help "));
8369 assert_eq!(content[1].node_type, "emoji");
8370 let attrs = content[1].attrs.as_ref().unwrap();
8371 assert_eq!(attrs["shortName"], ":slightly_smiling_face::bow:");
8372 assert_eq!(attrs["id"], "");
8373 assert_eq!(attrs["text"], "");
8374 }
8375
8376 #[test]
8377 fn triple_combined_emoji_shortname_round_trips_as_single_node() {
8378 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8380 {"type":"emoji","attrs":{"shortName":":a::b::c:","id":"x","text":""}}
8381 ]}]}"#;
8382 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8383
8384 let md = adf_to_markdown(&doc).unwrap();
8385 let round_tripped = markdown_to_adf(&md).unwrap();
8386 let content = round_tripped.content[0].content.as_ref().unwrap();
8387
8388 assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8389 assert_eq!(content[0].node_type, "emoji");
8390 let attrs = content[0].attrs.as_ref().unwrap();
8391 assert_eq!(attrs["shortName"], ":a::b::c:");
8392 assert_eq!(attrs["id"], "x");
8393 }
8394
8395 #[test]
8396 fn consecutive_emojis_remain_separate_nodes() {
8397 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8401 {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8402 {"type":"emoji","attrs":{"shortName":":water:","id":"1f4a7","text":"💧"}}
8403 ]}]}"#;
8404 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8405
8406 let md = adf_to_markdown(&doc).unwrap();
8407 let round_tripped = markdown_to_adf(&md).unwrap();
8408 let content = round_tripped.content[0].content.as_ref().unwrap();
8409
8410 assert_eq!(content.len(), 2, "should be two emoji nodes: {content:?}");
8411 assert_eq!(content[0].node_type, "emoji");
8412 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8413 assert_eq!(content[1].node_type, "emoji");
8414 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8415 }
8416
8417 #[test]
8418 fn adjacent_shortcodes_without_directive_parse_as_two_emojis() {
8419 let md = ":fire::water:";
8422 let doc = markdown_to_adf(md).unwrap();
8423 let content = doc.content[0].content.as_ref().unwrap();
8424
8425 assert_eq!(content.len(), 2, "should be two emojis: {content:?}");
8426 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8427 assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8428 }
8429
8430 #[test]
8431 fn combined_emoji_shortname_preserves_local_id() {
8432 let md = r#":a::b:{shortName=":a::b:" id="x" text="y" localId="abc"}"#;
8435 let doc = markdown_to_adf(md).unwrap();
8436 let content = doc.content[0].content.as_ref().unwrap();
8437
8438 assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8439 let attrs = content[0].attrs.as_ref().unwrap();
8440 assert_eq!(attrs["shortName"], ":a::b:");
8441 assert_eq!(attrs["id"], "x");
8442 assert_eq!(attrs["text"], "y");
8443 assert_eq!(attrs["localId"], "abc");
8444 }
8445
8446 #[test]
8447 fn text_shortcode_with_marks_round_trips() {
8448 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8450 {"type":"text","text":"Alert :fire: triggered","marks":[{"type":"strong"}]}
8451 ]}]}"#;
8452 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8453
8454 let md = adf_to_markdown(&doc).unwrap();
8455 let round_tripped = markdown_to_adf(&md).unwrap();
8456 let content = round_tripped.content[0].content.as_ref().unwrap();
8457
8458 assert_eq!(
8459 content.len(),
8460 1,
8461 "should be single bold text node: {content:?}"
8462 );
8463 assert_eq!(content[0].node_type, "text");
8464 assert_eq!(
8465 content[0].text.as_deref().unwrap(),
8466 "Alert :fire: triggered"
8467 );
8468 assert!(content[0]
8469 .marks
8470 .as_ref()
8471 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong")));
8472 }
8473
8474 #[test]
8475 fn mixed_emoji_node_and_text_shortcode_round_trips() {
8476 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8478 {"type":"emoji","attrs":{"shortName":":wave:","id":"1f44b","text":"👋"}},
8479 {"type":"text","text":" says :hello: to you"}
8480 ]}]}"#;
8481 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8482
8483 let md = adf_to_markdown(&doc).unwrap();
8484 let round_tripped = markdown_to_adf(&md).unwrap();
8485 let content = round_tripped.content[0].content.as_ref().unwrap();
8486
8487 assert_eq!(content.len(), 2, "should have 2 nodes: {content:?}");
8489 assert_eq!(content[0].node_type, "emoji");
8490 assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":wave:");
8491 assert_eq!(content[1].node_type, "text");
8492 assert_eq!(content[1].text.as_deref().unwrap(), " says :hello: to you");
8493 }
8494
8495 #[test]
8496 fn code_block_with_shortcode_pattern_round_trips() {
8497 let adf_json = r#"{"version":1,"type":"doc","content":[
8500 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8501 {"type":"text","text":"module Foo::Bar::Baz\n def hello\n puts 'world'\n end\nend"}
8502 ]}
8503 ]}"#;
8504 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8505
8506 let md = adf_to_markdown(&doc).unwrap();
8507 let round_tripped = markdown_to_adf(&md).unwrap();
8508
8509 assert_eq!(
8510 round_tripped.content.len(),
8511 1,
8512 "should be a single codeBlock"
8513 );
8514 let cb = &round_tripped.content[0];
8515 assert_eq!(cb.node_type, "codeBlock");
8516 let content = cb.content.as_ref().expect("codeBlock content");
8517 assert_eq!(
8518 content.len(),
8519 1,
8520 "should be a single text node: {content:?}"
8521 );
8522 assert_eq!(content[0].node_type, "text");
8523 assert_eq!(
8524 content[0].text.as_deref().unwrap(),
8525 "module Foo::Bar::Baz\n def hello\n puts 'world'\n end\nend"
8526 );
8527 assert!(
8528 content.iter().all(|n| n.node_type != "emoji"),
8529 "no emoji nodes should be present, got: {content:?}"
8530 );
8531 }
8532
8533 #[test]
8534 fn code_block_with_exact_namespaced_shortcode_pattern_round_trips() {
8535 let adf_json = r#"{"version":1,"type":"doc","content":[
8537 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8538 {"type":"text","text":"class ZBC::Acme::PlanType::Professional < Base"}
8539 ]}
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
8546 let cb = &round_tripped.content[0];
8547 assert_eq!(cb.node_type, "codeBlock");
8548 let content = cb.content.as_ref().expect("codeBlock content");
8549 assert_eq!(content.len(), 1, "should be a single text node");
8550 assert_eq!(
8551 content[0].text.as_deref().unwrap(),
8552 "class ZBC::Acme::PlanType::Professional < Base"
8553 );
8554 }
8555
8556 #[test]
8557 fn code_block_with_literal_shortcode_round_trips() {
8558 let adf_json = r#"{"version":1,"type":"doc","content":[
8561 {"type":"codeBlock","attrs":{"language":"text"},"content":[
8562 {"type":"text","text":":fire: :wave: :thumbsup:"}
8563 ]}
8564 ]}"#;
8565 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8566
8567 let md = adf_to_markdown(&doc).unwrap();
8568 let round_tripped = markdown_to_adf(&md).unwrap();
8569
8570 let cb = &round_tripped.content[0];
8571 assert_eq!(cb.node_type, "codeBlock");
8572 let content = cb.content.as_ref().expect("codeBlock content");
8573 assert_eq!(
8574 content.len(),
8575 1,
8576 "should be a single text node: {content:?}"
8577 );
8578 assert_eq!(content[0].node_type, "text");
8579 assert_eq!(
8580 content[0].text.as_deref().unwrap(),
8581 ":fire: :wave: :thumbsup:"
8582 );
8583 }
8584
8585 #[test]
8586 fn inline_code_with_shortcode_pattern_round_trips() {
8587 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8590 {"type":"text","text":"See "},
8591 {"type":"text","text":"Foo::Bar::Baz","marks":[{"type":"code"}]},
8592 {"type":"text","text":" for details"}
8593 ]}]}"#;
8594 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8595
8596 let md = adf_to_markdown(&doc).unwrap();
8597 let round_tripped = markdown_to_adf(&md).unwrap();
8598 let content = round_tripped.content[0].content.as_ref().unwrap();
8599
8600 assert_eq!(content.len(), 3, "should have 3 text nodes: {content:?}");
8601 assert_eq!(content[0].text.as_deref(), Some("See "));
8602 assert_eq!(content[1].text.as_deref(), Some("Foo::Bar::Baz"));
8603 assert!(content[1]
8604 .marks
8605 .as_ref()
8606 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8607 assert_eq!(content[2].text.as_deref(), Some(" for details"));
8608 assert!(
8609 content.iter().all(|n| n.node_type != "emoji"),
8610 "no emoji nodes should be present"
8611 );
8612 }
8613
8614 #[test]
8615 fn inline_code_with_literal_shortcode_round_trips() {
8616 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8619 {"type":"text","text":":fire:","marks":[{"type":"code"}]}
8620 ]}]}"#;
8621 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8622
8623 let md = adf_to_markdown(&doc).unwrap();
8624 let round_tripped = markdown_to_adf(&md).unwrap();
8625 let content = round_tripped.content[0].content.as_ref().unwrap();
8626
8627 assert_eq!(
8628 content.len(),
8629 1,
8630 "should be a single code node: {content:?}"
8631 );
8632 assert_eq!(content[0].node_type, "text");
8633 assert_eq!(content[0].text.as_deref(), Some(":fire:"));
8634 assert!(content[0]
8635 .marks
8636 .as_ref()
8637 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8638 }
8639
8640 #[test]
8641 fn code_block_in_list_with_shortcode_pattern_round_trips() {
8642 let adf_json = r#"{"version":1,"type":"doc","content":[
8645 {"type":"bulletList","content":[
8646 {"type":"listItem","content":[
8647 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8648 {"type":"text","text":"Foo::Bar::Baz"}
8649 ]}
8650 ]}
8651 ]}
8652 ]}"#;
8653 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8654
8655 let md = adf_to_markdown(&doc).unwrap();
8656 let round_tripped = markdown_to_adf(&md).unwrap();
8657
8658 let list = &round_tripped.content[0];
8659 assert_eq!(list.node_type, "bulletList");
8660 let item = &list.content.as_ref().unwrap()[0];
8661 assert_eq!(item.node_type, "listItem");
8662 let cb = &item.content.as_ref().unwrap()[0];
8663 assert_eq!(cb.node_type, "codeBlock");
8664 let cb_content = cb.content.as_ref().unwrap();
8665 assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8666 assert_eq!(cb_content[0].node_type, "text");
8667 }
8668
8669 #[test]
8670 fn code_block_with_unicode_shortcode_pattern_round_trips() {
8671 let adf_json = r#"{"version":1,"type":"doc","content":[
8675 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8676 {"type":"text","text":"class ZBC::配置::Production < Base"}
8677 ]}
8678 ]}"#;
8679 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8680
8681 let md = adf_to_markdown(&doc).unwrap();
8682 let round_tripped = markdown_to_adf(&md).unwrap();
8683
8684 let cb = &round_tripped.content[0];
8685 assert_eq!(cb.node_type, "codeBlock");
8686 let content = cb.content.as_ref().expect("codeBlock content");
8687 assert_eq!(content.len(), 1);
8688 assert_eq!(
8689 content[0].text.as_deref().unwrap(),
8690 "class ZBC::配置::Production < Base"
8691 );
8692 }
8693
8694 #[test]
8695 fn list_item_hardbreak_then_code_block_round_trips() {
8696 let adf_json = r#"{"version":1,"type":"doc","content":[
8702 {"type":"bulletList","content":[
8703 {"type":"listItem","content":[
8704 {"type":"paragraph","content":[
8705 {"type":"text","text":"Consider removing this check."},
8706 {"type":"hardBreak"}
8707 ]},
8708 {"type":"codeBlock","content":[
8709 {"type":"text","text":"x = Foo::Bar::Baz.new"}
8710 ]}
8711 ]}
8712 ]}
8713 ]}"#;
8714 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8715
8716 let md = adf_to_markdown(&doc).unwrap();
8717 let round_tripped = markdown_to_adf(&md).unwrap();
8718
8719 let list = &round_tripped.content[0];
8720 assert_eq!(list.node_type, "bulletList");
8721 let item = &list.content.as_ref().unwrap()[0];
8722 assert_eq!(item.node_type, "listItem");
8723 let item_content = item.content.as_ref().unwrap();
8724 assert_eq!(
8725 item_content.len(),
8726 2,
8727 "listItem should have paragraph + codeBlock, got: {item_content:?}"
8728 );
8729 assert_eq!(item_content[0].node_type, "paragraph");
8730 assert_eq!(item_content[1].node_type, "codeBlock");
8731
8732 let cb_content = item_content[1].content.as_ref().unwrap();
8734 assert_eq!(cb_content[0].node_type, "text");
8735 assert_eq!(
8736 cb_content[0].text.as_deref().unwrap(),
8737 "x = Foo::Bar::Baz.new"
8738 );
8739
8740 assert!(
8742 item_content
8743 .iter()
8744 .flat_map(|n| n.content.iter().flat_map(|c| c.iter()))
8745 .all(|n| n.node_type != "emoji"),
8746 "no emoji nodes should be present, got: {item_content:?}"
8747 );
8748 }
8749
8750 #[test]
8751 fn list_item_hardbreak_then_nested_list_still_works() {
8752 let md = "- first\\\n continuation text\\\n - nested item\n";
8756 let doc = markdown_to_adf(md).unwrap();
8757 let list = &doc.content[0];
8758 assert_eq!(list.node_type, "bulletList");
8759 let item = &list.content.as_ref().unwrap()[0];
8760 let item_content = item.content.as_ref().unwrap();
8762 let para = &item_content[0];
8763 assert_eq!(para.node_type, "paragraph");
8764 let para_nodes = para.content.as_ref().unwrap();
8765 assert!(
8766 para_nodes
8767 .iter()
8768 .any(|n| n.text.as_deref() == Some("continuation text")),
8769 "continuation text should survive: {para_nodes:?}"
8770 );
8771 }
8772
8773 #[test]
8774 fn list_item_hardbreak_then_image_still_works() {
8775 let md = "- first\\\n {type=file id=x}\n";
8780 let doc = markdown_to_adf(md).unwrap();
8781 let list = &doc.content[0];
8782 let item = &list.content.as_ref().unwrap()[0];
8783 let item_content = item.content.as_ref().unwrap();
8784 assert!(
8786 item_content.iter().any(|n| n.node_type == "mediaSingle"),
8787 "mediaSingle should still be a block-level sibling, got: {item_content:?}"
8788 );
8789 }
8790
8791 #[test]
8792 fn list_item_hardbreak_then_container_directive_round_trips() {
8793 let adf_json = r#"{"version":1,"type":"doc","content":[
8797 {"type":"bulletList","content":[
8798 {"type":"listItem","content":[
8799 {"type":"paragraph","content":[
8800 {"type":"text","text":"intro"},
8801 {"type":"hardBreak"}
8802 ]},
8803 {"type":"panel","attrs":{"panelType":"info"},"content":[
8804 {"type":"paragraph","content":[
8805 {"type":"text","text":"panel body"}
8806 ]}
8807 ]}
8808 ]}
8809 ]}
8810 ]}"#;
8811 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8812 let md = adf_to_markdown(&doc).unwrap();
8813 let round_tripped = markdown_to_adf(&md).unwrap();
8814
8815 let item = &round_tripped.content[0].content.as_ref().unwrap()[0];
8816 let item_content = item.content.as_ref().unwrap();
8817 assert!(
8818 item_content.iter().any(|n| n.node_type == "panel"),
8819 "panel should survive as block-level sibling, got: {item_content:?}"
8820 );
8821 }
8822
8823 #[test]
8824 fn inline_code_with_unicode_shortcode_pattern_round_trips() {
8825 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8828 {"type":"text","text":"See "},
8829 {"type":"text","text":"ZBC::配置::Production","marks":[{"type":"code"}]},
8830 {"type":"text","text":" for prod"}
8831 ]}]}"#;
8832 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8833
8834 let md = adf_to_markdown(&doc).unwrap();
8835 let round_tripped = markdown_to_adf(&md).unwrap();
8836 let content = round_tripped.content[0].content.as_ref().unwrap();
8837
8838 assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8839 assert_eq!(content[1].text.as_deref(), Some("ZBC::配置::Production"));
8840 assert!(content[1]
8841 .marks
8842 .as_ref()
8843 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8844 }
8845
8846 #[test]
8847 fn code_block_followed_by_shortcode_text_round_trips() {
8848 let adf_json = r#"{"version":1,"type":"doc","content":[
8851 {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8852 {"type":"text","text":"Foo::Bar::Baz"}
8853 ]},
8854 {"type":"paragraph","content":[
8855 {"type":"text","text":"Status::Active::Running"}
8856 ]}
8857 ]}"#;
8858 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8859
8860 let md = adf_to_markdown(&doc).unwrap();
8861 let round_tripped = markdown_to_adf(&md).unwrap();
8862
8863 assert_eq!(round_tripped.content.len(), 2);
8864 let cb = &round_tripped.content[0];
8865 assert_eq!(cb.node_type, "codeBlock");
8866 let cb_content = cb.content.as_ref().unwrap();
8867 assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8868
8869 let para = &round_tripped.content[1];
8870 assert_eq!(para.node_type, "paragraph");
8871 let para_content = para.content.as_ref().unwrap();
8872 assert_eq!(para_content.len(), 1);
8873 assert_eq!(para_content[0].node_type, "text");
8874 assert_eq!(
8875 para_content[0].text.as_deref(),
8876 Some("Status::Active::Running")
8877 );
8878 }
8879
8880 #[test]
8881 fn adf_inline_card_to_markdown() {
8882 let doc = AdfDocument {
8883 version: 1,
8884 doc_type: "doc".to_string(),
8885 content: vec![AdfNode::paragraph(vec![AdfNode {
8886 node_type: "inlineCard".to_string(),
8887 attrs: Some(
8888 serde_json::json!({"url": "https://org.atlassian.net/browse/ACCS-4382"}),
8889 ),
8890 content: None,
8891 text: None,
8892 marks: None,
8893 local_id: None,
8894 parameters: None,
8895 }])],
8896 };
8897 let md = adf_to_markdown(&doc).unwrap();
8898 assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
8899 assert!(!md.contains("<!-- unsupported inline"));
8900 }
8901
8902 #[test]
8903 fn inline_card_directive_round_trips() {
8904 let original = AdfDocument {
8906 version: 1,
8907 doc_type: "doc".to_string(),
8908 content: vec![AdfNode::paragraph(vec![AdfNode::inline_card(
8909 "https://org.atlassian.net/browse/ACCS-4382",
8910 )])],
8911 };
8912 let md = adf_to_markdown(&original).unwrap();
8913 assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
8914 let restored = markdown_to_adf(&md).unwrap();
8915 let node = &restored.content[0].content.as_ref().unwrap()[0];
8916 assert_eq!(node.node_type, "inlineCard");
8917 assert_eq!(
8918 node.attrs.as_ref().unwrap()["url"],
8919 "https://org.atlassian.net/browse/ACCS-4382"
8920 );
8921 }
8922
8923 #[test]
8924 fn inline_card_directive_parsed_from_jfm() {
8925 let doc = markdown_to_adf("See :card[https://example.com/issue/123] for details.").unwrap();
8927 let nodes = doc.content[0].content.as_ref().unwrap();
8928 assert_eq!(nodes[0].node_type, "text");
8929 assert_eq!(nodes[0].text.as_deref(), Some("See "));
8930 assert_eq!(nodes[1].node_type, "inlineCard");
8931 assert_eq!(
8932 nodes[1].attrs.as_ref().unwrap()["url"],
8933 "https://example.com/issue/123"
8934 );
8935 assert_eq!(nodes[2].node_type, "text");
8936 assert_eq!(nodes[2].text.as_deref(), Some(" for details."));
8937 }
8938
8939 #[test]
8940 fn self_link_becomes_link_mark_not_inline_card() {
8941 let doc = markdown_to_adf("[https://example.com](https://example.com)").unwrap();
8944 let node = &doc.content[0].content.as_ref().unwrap()[0];
8945 assert_eq!(node.node_type, "text");
8946 assert_eq!(node.text.as_deref(), Some("https://example.com"));
8947 let mark = &node.marks.as_ref().unwrap()[0];
8948 assert_eq!(mark.mark_type, "link");
8949 assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
8950 }
8951
8952 #[test]
8953 fn url_link_text_with_trailing_slash_mismatch_becomes_link_mark() {
8954 let doc =
8957 markdown_to_adf("[https://octopz.example.com](https://octopz.example.com/)").unwrap();
8958 let node = &doc.content[0].content.as_ref().unwrap()[0];
8959 assert_eq!(node.node_type, "text");
8960 assert_eq!(node.text.as_deref(), Some("https://octopz.example.com"));
8961 let mark = &node.marks.as_ref().unwrap()[0];
8962 assert_eq!(mark.mark_type, "link");
8963 assert_eq!(
8964 mark.attrs.as_ref().unwrap()["href"],
8965 "https://octopz.example.com/"
8966 );
8967 }
8968
8969 #[test]
8970 fn named_link_does_not_become_inline_card() {
8971 let doc = markdown_to_adf("[#4668](https://github.com/org/repo/pull/4668)").unwrap();
8973 let node = &doc.content[0].content.as_ref().unwrap()[0];
8974 assert_eq!(node.node_type, "text");
8975 assert_eq!(node.text.as_deref(), Some("#4668"));
8976 let mark = &node.marks.as_ref().unwrap()[0];
8977 assert_eq!(mark.mark_type, "link");
8978 }
8979
8980 #[test]
8981 fn adf_inline_card_no_url_to_markdown() {
8982 let doc = AdfDocument {
8983 version: 1,
8984 doc_type: "doc".to_string(),
8985 content: vec![AdfNode::paragraph(vec![AdfNode {
8986 node_type: "inlineCard".to_string(),
8987 attrs: Some(serde_json::json!({})),
8988 content: None,
8989 text: None,
8990 marks: None,
8991 local_id: None,
8992 parameters: None,
8993 }])],
8994 };
8995 let md = adf_to_markdown(&doc).unwrap();
8996 assert!(!md.contains("<!-- unsupported inline"));
8998 }
8999
9000 #[test]
9001 fn adf_code_block_no_language_to_markdown() {
9002 let doc = AdfDocument {
9003 version: 1,
9004 doc_type: "doc".to_string(),
9005 content: vec![AdfNode::code_block(None, "plain code")],
9006 };
9007 let md = adf_to_markdown(&doc).unwrap();
9008 assert!(md.contains("```\n"));
9009 assert!(md.contains("plain code"));
9010 }
9011
9012 #[test]
9013 fn adf_code_block_empty_language_to_markdown() {
9014 let doc = AdfDocument {
9015 version: 1,
9016 doc_type: "doc".to_string(),
9017 content: vec![AdfNode::code_block(Some(""), "plain code")],
9018 };
9019 let md = adf_to_markdown(&doc).unwrap();
9020 assert!(md.contains("```\"\"\n"));
9021 assert!(md.contains("plain code"));
9022 }
9023
9024 #[test]
9027 fn round_trip_table() {
9028 let md = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
9029 let adf = markdown_to_adf(md).unwrap();
9030 let restored = adf_to_markdown(&adf).unwrap();
9031 assert!(restored.contains("| A | B |"));
9032 assert!(restored.contains("| 1 | 2 |"));
9033 }
9034
9035 #[test]
9036 fn round_trip_blockquote() {
9037 let md = "> This is quoted\n";
9038 let adf = markdown_to_adf(md).unwrap();
9039 let restored = adf_to_markdown(&adf).unwrap();
9040 assert!(restored.contains("> This is quoted"));
9041 }
9042
9043 #[test]
9044 fn round_trip_image() {
9045 let md = "\n";
9046 let adf = markdown_to_adf(md).unwrap();
9047 let restored = adf_to_markdown(&adf).unwrap();
9048 assert!(restored.contains(""));
9049 }
9050
9051 #[test]
9052 fn round_trip_ordered_list() {
9053 let md = "1. A\n2. B\n3. C\n";
9054 let adf = markdown_to_adf(md).unwrap();
9055 let restored = adf_to_markdown(&adf).unwrap();
9056 assert!(restored.contains("1. A"));
9057 assert!(restored.contains("2. B"));
9058 assert!(restored.contains("3. C"));
9059 }
9060
9061 #[test]
9062 fn round_trip_inline_marks() {
9063 let md = "Text with `code` and ~~strike~~ and [link](https://x.com).\n";
9064 let adf = markdown_to_adf(md).unwrap();
9065 let restored = adf_to_markdown(&adf).unwrap();
9066 assert!(restored.contains("`code`"));
9067 assert!(restored.contains("~~strike~~"));
9068 assert!(restored.contains("[link](https://x.com)"));
9069 }
9070
9071 #[test]
9074 fn panel_info() {
9075 let md = ":::panel{type=info}\nThis is informational.\n:::";
9076 let doc = markdown_to_adf(md).unwrap();
9077 assert_eq!(doc.content[0].node_type, "panel");
9078 assert_eq!(doc.content[0].attrs.as_ref().unwrap()["panelType"], "info");
9079 let inner = doc.content[0].content.as_ref().unwrap();
9080 assert_eq!(inner[0].node_type, "paragraph");
9081 }
9082
9083 #[test]
9084 fn adf_panel_to_markdown() {
9085 let doc = AdfDocument {
9086 version: 1,
9087 doc_type: "doc".to_string(),
9088 content: vec![AdfNode::panel(
9089 "warning",
9090 vec![AdfNode::paragraph(vec![AdfNode::text("Be careful.")])],
9091 )],
9092 };
9093 let md = adf_to_markdown(&doc).unwrap();
9094 assert!(md.contains(":::panel{type=warning}"));
9095 assert!(md.contains("Be careful."));
9096 assert!(md.contains(":::"));
9097 }
9098
9099 #[test]
9100 fn round_trip_panel() {
9101 let md = ":::panel{type=info}\nThis is informational.\n:::\n";
9102 let doc = markdown_to_adf(md).unwrap();
9103 let result = adf_to_markdown(&doc).unwrap();
9104 assert!(result.contains(":::panel{type=info}"));
9105 assert!(result.contains("This is informational."));
9106 }
9107
9108 #[test]
9109 fn expand_with_title() {
9110 let md = ":::expand{title=\"Click me\"}\nHidden content.\n:::";
9111 let doc = markdown_to_adf(md).unwrap();
9112 assert_eq!(doc.content[0].node_type, "expand");
9113 assert_eq!(doc.content[0].attrs.as_ref().unwrap()["title"], "Click me");
9114 }
9115
9116 #[test]
9117 fn adf_expand_to_markdown() {
9118 let doc = AdfDocument {
9119 version: 1,
9120 doc_type: "doc".to_string(),
9121 content: vec![AdfNode::expand(
9122 Some("Details"),
9123 vec![AdfNode::paragraph(vec![AdfNode::text("Inner.")])],
9124 )],
9125 };
9126 let md = adf_to_markdown(&doc).unwrap();
9127 assert!(md.contains(":::expand{title=\"Details\"}"));
9128 assert!(md.contains("Inner."));
9129 }
9130
9131 #[test]
9132 fn round_trip_expand() {
9133 let md = ":::expand{title=\"Details\"}\nInner content.\n:::\n";
9134 let doc = markdown_to_adf(md).unwrap();
9135 let result = adf_to_markdown(&doc).unwrap();
9136 assert!(result.contains(":::expand{title=\"Details\"}"));
9137 assert!(result.contains("Inner content."));
9138 }
9139
9140 #[test]
9141 fn layout_two_columns() {
9142 let md =
9143 "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
9144 let doc = markdown_to_adf(md).unwrap();
9145 assert_eq!(doc.content[0].node_type, "layoutSection");
9146 let columns = doc.content[0].content.as_ref().unwrap();
9147 assert_eq!(columns.len(), 2);
9148 assert_eq!(columns[0].node_type, "layoutColumn");
9149 assert_eq!(columns[1].node_type, "layoutColumn");
9150 }
9151
9152 #[test]
9153 fn adf_layout_to_markdown() {
9154 let doc = AdfDocument {
9155 version: 1,
9156 doc_type: "doc".to_string(),
9157 content: vec![AdfNode::layout_section(vec![
9158 AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Left.")])]),
9159 AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Right.")])]),
9160 ])],
9161 };
9162 let md = adf_to_markdown(&doc).unwrap();
9163 assert!(md.contains("::::layout"));
9164 assert!(md.contains(":::column{width=50}"));
9165 assert!(md.contains("Left."));
9166 assert!(md.contains("Right."));
9167 }
9168
9169 #[test]
9170 fn layout_column_localid_roundtrip() {
9171 let adf_json = r#"{
9172 "version": 1,
9173 "type": "doc",
9174 "content": [{
9175 "type": "layoutSection",
9176 "content": [
9177 {
9178 "type": "layoutColumn",
9179 "attrs": {"width": 50.0, "localId": "aabb112233cc"},
9180 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Left"}]}]
9181 },
9182 {
9183 "type": "layoutColumn",
9184 "attrs": {"width": 50.0, "localId": "ddeeff445566"},
9185 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Right"}]}]
9186 }
9187 ]
9188 }]
9189 }"#;
9190 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9191 let md = adf_to_markdown(&doc).unwrap();
9192 assert!(
9193 md.contains("localId=aabb112233cc"),
9194 "first column localId should appear in markdown: {md}"
9195 );
9196 assert!(
9197 md.contains("localId=ddeeff445566"),
9198 "second column localId should appear in markdown: {md}"
9199 );
9200 let rt = markdown_to_adf(&md).unwrap();
9201 let cols = rt.content[0].content.as_ref().unwrap();
9202 assert_eq!(
9203 cols[0].attrs.as_ref().unwrap()["localId"],
9204 "aabb112233cc",
9205 "first column localId should round-trip"
9206 );
9207 assert_eq!(
9208 cols[1].attrs.as_ref().unwrap()["localId"],
9209 "ddeeff445566",
9210 "second column localId should round-trip"
9211 );
9212 }
9213
9214 #[test]
9215 fn layout_column_without_localid() {
9216 let md =
9217 "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
9218 let doc = markdown_to_adf(md).unwrap();
9219 let cols = doc.content[0].content.as_ref().unwrap();
9220 assert!(
9221 cols[0].attrs.as_ref().unwrap().get("localId").is_none(),
9222 "column without localId should not gain one"
9223 );
9224 let md2 = adf_to_markdown(&doc).unwrap();
9225 assert!(
9226 !md2.contains("localId"),
9227 "no localId should appear in output: {md2}"
9228 );
9229 }
9230
9231 #[test]
9232 fn layout_column_localid_stripped_when_option_set() {
9233 let adf_json = r#"{
9234 "version": 1,
9235 "type": "doc",
9236 "content": [{
9237 "type": "layoutSection",
9238 "content": [{
9239 "type": "layoutColumn",
9240 "attrs": {"width": 50.0, "localId": "aabb112233cc"},
9241 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Col"}]}]
9242 }]
9243 }]
9244 }"#;
9245 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9246 let opts = RenderOptions {
9247 strip_local_ids: true,
9248 ..Default::default()
9249 };
9250 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
9251 assert!(!md.contains("localId"), "localId should be stripped: {md}");
9252 }
9253
9254 #[test]
9255 fn layout_column_localid_flush_previous() {
9256 let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nLeft.\n:::column{width=50 localId=ddeeff445566}\nRight.\n:::\n::::";
9258 let doc = markdown_to_adf(md).unwrap();
9259 let cols = doc.content[0].content.as_ref().unwrap();
9260 assert_eq!(
9261 cols[0].attrs.as_ref().unwrap()["localId"],
9262 "aabb112233cc",
9263 "flush-previous column should preserve localId"
9264 );
9265 assert_eq!(
9266 cols[1].attrs.as_ref().unwrap()["localId"],
9267 "ddeeff445566",
9268 "second column localId should be preserved"
9269 );
9270 }
9271
9272 #[test]
9273 fn layout_column_localid_flush_last() {
9274 let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nOnly column.";
9276 let doc = markdown_to_adf(md).unwrap();
9277 let cols = doc.content[0].content.as_ref().unwrap();
9278 assert_eq!(
9279 cols[0].attrs.as_ref().unwrap()["localId"],
9280 "aabb112233cc",
9281 "flush-last column should preserve localId"
9282 );
9283 }
9284
9285 #[test]
9287 fn issue_555_layout_column_fractional_width_roundtrip() {
9288 let adf_json = r#"{
9289 "version": 1,
9290 "type": "doc",
9291 "content": [{
9292 "type": "layoutSection",
9293 "content": [
9294 {
9295 "type": "layoutColumn",
9296 "attrs": {"width": 66.66},
9297 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9298 },
9299 {
9300 "type": "layoutColumn",
9301 "attrs": {"width": 33.34},
9302 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9303 }
9304 ]
9305 }]
9306 }"#;
9307 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9308 let md = adf_to_markdown(&doc).unwrap();
9309 assert!(md.contains("width=66.66"), "fractional width in md: {md}");
9310 assert!(md.contains("width=33.34"), "fractional width in md: {md}");
9311 let rt = markdown_to_adf(&md).unwrap();
9312 let cols = rt.content[0].content.as_ref().unwrap();
9313 assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 66.66);
9314 assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 33.34);
9315 }
9316
9317 #[test]
9319 fn issue_555_layout_column_five_sixths_width_roundtrip() {
9320 let adf_json = r#"{
9321 "version": 1,
9322 "type": "doc",
9323 "content": [{
9324 "type": "layoutSection",
9325 "content": [
9326 {
9327 "type": "layoutColumn",
9328 "attrs": {"width": 83.33},
9329 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9330 },
9331 {
9332 "type": "layoutColumn",
9333 "attrs": {"width": 16.67},
9334 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9335 }
9336 ]
9337 }]
9338 }"#;
9339 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9340 let md = adf_to_markdown(&doc).unwrap();
9341 let rt = markdown_to_adf(&md).unwrap();
9342 let cols = rt.content[0].content.as_ref().unwrap();
9343 assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 83.33);
9344 assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 16.67);
9345 }
9346
9347 #[test]
9349 fn issue_555_layout_column_integer_width_preserved() {
9350 let adf_json = r#"{
9351 "version": 1,
9352 "type": "doc",
9353 "content": [{
9354 "type": "layoutSection",
9355 "content": [
9356 {
9357 "type": "layoutColumn",
9358 "attrs": {"width": 50},
9359 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "A"}]}]
9360 },
9361 {
9362 "type": "layoutColumn",
9363 "attrs": {"width": 50},
9364 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "B"}]}]
9365 }
9366 ]
9367 }]
9368 }"#;
9369 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9370 let md = adf_to_markdown(&doc).unwrap();
9371 assert!(
9372 md.contains("width=50") && !md.contains("width=50."),
9373 "integer width should render without decimal: {md}"
9374 );
9375 let rt = markdown_to_adf(&md).unwrap();
9376 let cols = rt.content[0].content.as_ref().unwrap();
9377 let w0 = &cols[0].attrs.as_ref().unwrap()["width"];
9378 assert!(
9379 w0.is_i64() || w0.is_u64(),
9380 "width should remain a JSON integer, got: {w0}"
9381 );
9382 assert_eq!(w0.as_i64(), Some(50));
9383 }
9384
9385 #[test]
9387 fn issue_555_media_single_integer_width_preserved() {
9388 let adf_json = r#"{
9389 "version": 1,
9390 "type": "doc",
9391 "content": [{
9392 "type": "mediaSingle",
9393 "attrs": {"layout": "center", "width": 800},
9394 "content": [
9395 {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9396 ]
9397 }]
9398 }"#;
9399 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9400 let md = adf_to_markdown(&doc).unwrap();
9401 assert!(
9402 md.contains("width=800") && !md.contains("width=800."),
9403 "integer width should render without decimal: {md}"
9404 );
9405 let rt = markdown_to_adf(&md).unwrap();
9406 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9407 let w = &ms_attrs["width"];
9408 assert!(
9409 w.is_i64() || w.is_u64(),
9410 "mediaSingle width should remain JSON integer, got: {w}"
9411 );
9412 assert_eq!(w.as_i64(), Some(800));
9413 }
9414
9415 #[test]
9419 fn issue_555_media_single_fractional_width_preserved() {
9420 let adf_json = r#"{
9421 "version": 1,
9422 "type": "doc",
9423 "content": [{
9424 "type": "mediaSingle",
9425 "attrs": {"layout": "center", "width": 66.5},
9426 "content": [
9427 {"type": "media", "attrs": {"type": "external", "url": "https://example.com/diagram.png"}}
9428 ]
9429 }]
9430 }"#;
9431 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9432 let md = adf_to_markdown(&doc).unwrap();
9433 assert!(
9434 md.contains("width=66.5"),
9435 "fractional width must appear in JFM: {md}"
9436 );
9437 let rt = markdown_to_adf(&md).unwrap();
9438 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9439 assert_eq!(ms_attrs["width"], 66.5);
9440 }
9441
9442 #[test]
9444 fn issue_555_media_single_float_width_preserved() {
9445 let adf_json = r#"{
9446 "version": 1,
9447 "type": "doc",
9448 "content": [{
9449 "type": "mediaSingle",
9450 "attrs": {"layout": "center", "width": 800.0},
9451 "content": [
9452 {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9453 ]
9454 }]
9455 }"#;
9456 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9457 let md = adf_to_markdown(&doc).unwrap();
9458 assert!(
9459 md.contains("width=800.0"),
9460 "float width should render with decimal: {md}"
9461 );
9462 let rt = markdown_to_adf(&md).unwrap();
9463 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9464 let w = &ms_attrs["width"];
9465 assert!(
9466 w.is_f64(),
9467 "mediaSingle float width should stay a JSON float, got: {w}"
9468 );
9469 assert_eq!(w.as_f64(), Some(800.0));
9470 }
9471
9472 #[test]
9474 fn issue_555_media_single_wide_layout_integer_width_roundtrip() {
9475 let adf_json = r#"{
9476 "version": 1,
9477 "type": "doc",
9478 "content": [{
9479 "type": "mediaSingle",
9480 "attrs": {"layout": "wide", "width": 1420},
9481 "content": [
9482 {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9483 ]
9484 }]
9485 }"#;
9486 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9487 let md = adf_to_markdown(&doc).unwrap();
9488 let rt = markdown_to_adf(&md).unwrap();
9489 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9490 assert_eq!(ms_attrs["layout"], "wide");
9491 let w = &ms_attrs["width"];
9492 assert!(
9493 w.is_i64() || w.is_u64(),
9494 "mediaSingle width should remain JSON integer, got: {w}"
9495 );
9496 assert_eq!(w.as_i64(), Some(1420));
9497 }
9498
9499 #[test]
9502 fn issue_555_file_media_single_integer_width_preserved() {
9503 let adf_json = r#"{
9504 "version": 1,
9505 "type": "doc",
9506 "content": [{
9507 "type": "mediaSingle",
9508 "attrs": {"layout": "wide", "width": 1420},
9509 "content": [
9510 {"type": "media", "attrs": {"id": "abc-123", "type": "file", "collection": "col-1", "width": 1200, "height": 800}}
9511 ]
9512 }]
9513 }"#;
9514 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9515 let md = adf_to_markdown(&doc).unwrap();
9516 let rt = markdown_to_adf(&md).unwrap();
9517 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9518 let ms_w = &ms_attrs["width"];
9519 assert!(ms_w.is_i64() || ms_w.is_u64(), "ms width: {ms_w}");
9520 assert_eq!(ms_w.as_i64(), Some(1420));
9521 let media = &rt.content[0].content.as_ref().unwrap()[0];
9522 let media_attrs = media.attrs.as_ref().unwrap();
9523 let mw = &media_attrs["width"];
9524 assert!(mw.is_i64() || mw.is_u64(), "media width: {mw}");
9525 assert_eq!(mw.as_i64(), Some(1200));
9526 let mh = &media_attrs["height"];
9527 assert!(mh.is_i64() || mh.is_u64(), "media height: {mh}");
9528 assert_eq!(mh.as_i64(), Some(800));
9529 }
9530
9531 #[test]
9533 fn issue_555_fmt_numeric_attr_preserves_type() {
9534 assert_eq!(
9535 fmt_numeric_attr(&serde_json::json!(50)).as_deref(),
9536 Some("50")
9537 );
9538 assert_eq!(
9539 fmt_numeric_attr(&serde_json::json!(50.0)).as_deref(),
9540 Some("50.0")
9541 );
9542 assert_eq!(
9543 fmt_numeric_attr(&serde_json::json!(66.66)).as_deref(),
9544 Some("66.66")
9545 );
9546 assert_eq!(
9547 fmt_numeric_attr(&serde_json::json!(-5)).as_deref(),
9548 Some("-5")
9549 );
9550 assert_eq!(fmt_numeric_attr(&serde_json::json!("not a number")), None);
9551 let big = serde_json::Value::Number(serde_json::Number::from(u64::MAX));
9553 assert_eq!(
9554 fmt_numeric_attr(&big).as_deref(),
9555 Some("18446744073709551615")
9556 );
9557 assert_eq!(fmt_numeric_attr(&serde_json::Value::Null), None);
9559 }
9560
9561 #[test]
9563 fn issue_555_parse_numeric_attr_detects_type() {
9564 let v = parse_numeric_attr("800").unwrap();
9565 assert!(v.is_i64() || v.is_u64(), "'800' should parse as integer");
9566 assert_eq!(v.as_i64(), Some(800));
9567
9568 let v = parse_numeric_attr("800.0").unwrap();
9569 assert!(v.is_f64(), "'800.0' should parse as float");
9570 assert_eq!(v.as_f64(), Some(800.0));
9571
9572 let v = parse_numeric_attr("66.66").unwrap();
9573 assert!(v.is_f64());
9574 assert_eq!(v.as_f64(), Some(66.66));
9575
9576 let v = parse_numeric_attr("1e2").unwrap();
9578 assert!(v.is_f64());
9579 assert_eq!(v.as_f64(), Some(100.0));
9580 let v = parse_numeric_attr("1E2").unwrap();
9581 assert!(v.is_f64());
9582 assert_eq!(v.as_f64(), Some(100.0));
9583
9584 let v = parse_numeric_attr("-42").unwrap();
9586 assert!(v.is_i64());
9587 assert_eq!(v.as_i64(), Some(-42));
9588
9589 assert!(parse_numeric_attr("not-a-number").is_none());
9590 assert!(parse_numeric_attr("").is_none());
9591 assert!(parse_numeric_attr("1.2.3").is_none());
9592 }
9593
9594 #[test]
9597 fn issue_555_media_single_wide_layout_fractional_width_roundtrip() {
9598 let adf_json = r#"{
9599 "version": 1,
9600 "type": "doc",
9601 "content": [{
9602 "type": "mediaSingle",
9603 "attrs": {"layout": "wide", "width": 83.33},
9604 "content": [
9605 {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9606 ]
9607 }]
9608 }"#;
9609 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9610 let md = adf_to_markdown(&doc).unwrap();
9611 assert!(md.contains("layout=wide"), "layout must appear in md: {md}");
9612 assert!(md.contains("width=83.33"), "width must appear in md: {md}");
9613 let rt = markdown_to_adf(&md).unwrap();
9614 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9615 assert_eq!(ms_attrs["layout"], "wide");
9616 assert_eq!(ms_attrs["width"], 83.33);
9617 }
9618
9619 #[test]
9623 fn issue_555_file_media_single_fractional_media_width_preserved() {
9624 let adf_json = r#"{
9625 "version": 1,
9626 "type": "doc",
9627 "content": [{
9628 "type": "mediaSingle",
9629 "attrs": {"layout": "wide", "width": 66.5},
9630 "content": [
9631 {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c"}}
9632 ]
9633 }]
9634 }"#;
9635 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9636 let md = adf_to_markdown(&doc).unwrap();
9637 assert!(md.contains("mediaWidth=66.5"), "mediaWidth in md: {md}");
9638 let rt = markdown_to_adf(&md).unwrap();
9639 let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9640 assert_eq!(ms_attrs["width"], 66.5);
9641 }
9642
9643 #[test]
9647 fn issue_555_file_media_fractional_inner_dimensions_preserved() {
9648 let adf_json = r#"{
9649 "version": 1,
9650 "type": "doc",
9651 "content": [{
9652 "type": "mediaSingle",
9653 "attrs": {"layout": "center"},
9654 "content": [
9655 {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c", "width": 1200.5, "height": 800.25}}
9656 ]
9657 }]
9658 }"#;
9659 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9660 let md = adf_to_markdown(&doc).unwrap();
9661 assert!(md.contains("width=1200.5"), "width in md: {md}");
9662 assert!(md.contains("height=800.25"), "height in md: {md}");
9663 let rt = markdown_to_adf(&md).unwrap();
9664 let media = &rt.content[0].content.as_ref().unwrap()[0];
9665 let attrs = media.attrs.as_ref().unwrap();
9666 assert_eq!(attrs["width"], 1200.5);
9667 assert_eq!(attrs["height"], 800.25);
9668 }
9669
9670 #[test]
9671 fn decisions_list() {
9672 let md = ":::decisions\n- <> Use PostgreSQL\n- <> REST API\n:::";
9673 let doc = markdown_to_adf(md).unwrap();
9674 assert_eq!(doc.content[0].node_type, "decisionList");
9675 let items = doc.content[0].content.as_ref().unwrap();
9676 assert_eq!(items.len(), 2);
9677 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DECIDED");
9678 }
9679
9680 #[test]
9683 fn decision_item_content_is_inline_not_paragraph() {
9684 let md = ":::decisions\n- <> Use Rust\n:::";
9685 let doc = markdown_to_adf(md).unwrap();
9686 let items = doc.content[0].content.as_ref().unwrap();
9687 let first_child = &items[0].content.as_ref().unwrap()[0];
9688 assert_eq!(
9689 first_child.node_type, "text",
9690 "decisionItem must contain inline nodes directly, not a paragraph wrapper"
9691 );
9692 assert_eq!(first_child.text.as_deref(), Some("Use Rust"));
9693 }
9694
9695 #[test]
9696 fn adf_decisions_to_markdown() {
9697 let doc = AdfDocument {
9698 version: 1,
9699 doc_type: "doc".to_string(),
9700 content: vec![AdfNode::decision_list(vec![AdfNode::decision_item(
9701 "DECIDED",
9702 vec![AdfNode::paragraph(vec![AdfNode::text("Use PostgreSQL")])],
9703 )])],
9704 };
9705 let md = adf_to_markdown(&doc).unwrap();
9706 assert!(md.contains(":::decisions"));
9707 assert!(md.contains("- <> Use PostgreSQL"));
9708 }
9709
9710 #[test]
9711 fn bodied_extension_container() {
9712 let md = ":::extension{type=com.forge key=my-macro}\nContent.\n:::";
9713 let doc = markdown_to_adf(md).unwrap();
9714 assert_eq!(doc.content[0].node_type, "bodiedExtension");
9715 assert_eq!(
9716 doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9717 "com.forge"
9718 );
9719 }
9720
9721 #[test]
9722 fn adf_bodied_extension_to_markdown() {
9723 let doc = AdfDocument {
9724 version: 1,
9725 doc_type: "doc".to_string(),
9726 content: vec![AdfNode::bodied_extension(
9727 "com.forge",
9728 "my-macro",
9729 vec![AdfNode::paragraph(vec![AdfNode::text("Content.")])],
9730 )],
9731 };
9732 let md = adf_to_markdown(&doc).unwrap();
9733 assert!(md.contains(":::extension{type=com.forge key=my-macro}"));
9734 assert!(md.contains("Content."));
9735 }
9736
9737 #[test]
9740 fn leaf_block_card() {
9741 let doc = markdown_to_adf("::card[https://example.com/browse/PROJ-123]").unwrap();
9742 assert_eq!(doc.content[0].node_type, "blockCard");
9743 assert_eq!(
9744 doc.content[0].attrs.as_ref().unwrap()["url"],
9745 "https://example.com/browse/PROJ-123"
9746 );
9747 }
9748
9749 #[test]
9750 fn adf_block_card_to_markdown() {
9751 let doc = AdfDocument {
9752 version: 1,
9753 doc_type: "doc".to_string(),
9754 content: vec![AdfNode::block_card("https://example.com/browse/PROJ-123")],
9755 };
9756 let md = adf_to_markdown(&doc).unwrap();
9757 assert!(md.contains("::card[https://example.com/browse/PROJ-123]"));
9758 }
9759
9760 #[test]
9761 fn round_trip_block_card() {
9762 let md = "::card[https://example.com/browse/PROJ-123]\n";
9763 let doc = markdown_to_adf(md).unwrap();
9764 let result = adf_to_markdown(&doc).unwrap();
9765 assert!(result.contains("::card[https://example.com/browse/PROJ-123]"));
9766 }
9767
9768 #[test]
9769 fn leaf_embed_card() {
9770 let doc =
9771 markdown_to_adf("::embed[https://figma.com/file/abc]{layout=wide width=80}").unwrap();
9772 assert_eq!(doc.content[0].node_type, "embedCard");
9773 let attrs = doc.content[0].attrs.as_ref().unwrap();
9774 assert_eq!(attrs["url"], "https://figma.com/file/abc");
9775 assert_eq!(attrs["layout"], "wide");
9776 assert_eq!(attrs["width"], 80.0);
9777 }
9778
9779 #[test]
9780 fn leaf_embed_card_with_original_height() {
9781 let doc = markdown_to_adf(
9782 "::embed[https://example.com]{layout=center originalHeight=732 width=100}",
9783 )
9784 .unwrap();
9785 assert_eq!(doc.content[0].node_type, "embedCard");
9786 let attrs = doc.content[0].attrs.as_ref().unwrap();
9787 assert_eq!(attrs["url"], "https://example.com");
9788 assert_eq!(attrs["layout"], "center");
9789 assert_eq!(attrs["originalHeight"], 732.0);
9790 assert_eq!(attrs["width"], 100.0);
9791 }
9792
9793 #[test]
9794 fn adf_embed_card_to_markdown() {
9795 let doc = AdfDocument {
9796 version: 1,
9797 doc_type: "doc".to_string(),
9798 content: vec![AdfNode::embed_card(
9799 "https://figma.com/file/abc",
9800 Some("wide"),
9801 None,
9802 Some(80.0),
9803 )],
9804 };
9805 let md = adf_to_markdown(&doc).unwrap();
9806 assert!(md.contains("::embed[https://figma.com/file/abc]{layout=wide width=80}"));
9807 }
9808
9809 #[test]
9810 fn adf_embed_card_original_height_to_markdown() {
9811 let doc = AdfDocument {
9812 version: 1,
9813 doc_type: "doc".to_string(),
9814 content: vec![AdfNode::embed_card(
9815 "https://example.com",
9816 Some("center"),
9817 Some(732.0),
9818 Some(100.0),
9819 )],
9820 };
9821 let md = adf_to_markdown(&doc).unwrap();
9822 assert!(
9823 md.contains("::embed[https://example.com]{layout=center originalHeight=732 width=100}"),
9824 "expected originalHeight and width in md: {md}"
9825 );
9826 }
9827
9828 #[test]
9829 fn embed_card_roundtrip_with_all_attrs() {
9830 let adf_json = r#"{"version":1,"type":"doc","content":[{
9831 "type":"embedCard",
9832 "attrs":{"layout":"center","originalHeight":732.0,"url":"https://example.com","width":100.0}
9833 }]}"#;
9834 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9835 let md = adf_to_markdown(&doc).unwrap();
9836 assert!(
9837 md.contains("originalHeight=732"),
9838 "originalHeight missing from md: {md}"
9839 );
9840 assert!(md.contains("width=100"), "width missing from md: {md}");
9841 let rt = markdown_to_adf(&md).unwrap();
9842 let attrs = rt.content[0].attrs.as_ref().unwrap();
9843 assert_eq!(attrs["originalHeight"], 732.0);
9844 assert_eq!(attrs["width"], 100.0);
9845 assert_eq!(attrs["layout"], "center");
9846 assert_eq!(attrs["url"], "https://example.com");
9847 }
9848
9849 #[test]
9850 fn embed_card_fractional_dimensions() {
9851 let doc = AdfDocument {
9852 version: 1,
9853 doc_type: "doc".to_string(),
9854 content: vec![AdfNode::embed_card(
9855 "https://example.com",
9856 Some("center"),
9857 Some(732.5),
9858 Some(99.9),
9859 )],
9860 };
9861 let md = adf_to_markdown(&doc).unwrap();
9862 assert!(
9863 md.contains("originalHeight=732.5"),
9864 "fractional originalHeight missing: {md}"
9865 );
9866 assert!(md.contains("width=99.9"), "fractional width missing: {md}");
9867 let rt = markdown_to_adf(&md).unwrap();
9868 let attrs = rt.content[0].attrs.as_ref().unwrap();
9869 assert_eq!(attrs["originalHeight"], 732.5);
9870 assert_eq!(attrs["width"], 99.9);
9871 }
9872
9873 #[test]
9874 fn embed_card_integer_width_in_json() {
9875 let adf_json = r#"{"version":1,"type":"doc","content":[{
9877 "type":"embedCard",
9878 "attrs":{"url":"https://example.com","width":100}
9879 }]}"#;
9880 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9881 let md = adf_to_markdown(&doc).unwrap();
9882 assert!(
9883 md.contains("width=100"),
9884 "integer width missing from md: {md}"
9885 );
9886 let rt = markdown_to_adf(&md).unwrap();
9887 assert_eq!(rt.content[0].attrs.as_ref().unwrap()["width"], 100.0);
9888 }
9889
9890 #[test]
9891 fn embed_card_only_original_height() {
9892 let adf_json = r#"{"version":1,"type":"doc","content":[{
9894 "type":"embedCard",
9895 "attrs":{"url":"https://example.com","originalHeight":500.0}
9896 }]}"#;
9897 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9898 let md = adf_to_markdown(&doc).unwrap();
9899 assert!(
9900 md.contains("originalHeight=500"),
9901 "originalHeight missing: {md}"
9902 );
9903 assert!(!md.contains("width="), "width should not appear: {md}");
9904 let rt = markdown_to_adf(&md).unwrap();
9905 let attrs = rt.content[0].attrs.as_ref().unwrap();
9906 assert_eq!(attrs["originalHeight"], 500.0);
9907 assert!(attrs.get("width").is_none());
9908 }
9909
9910 #[test]
9911 fn leaf_void_extension() {
9912 let doc = markdown_to_adf("::extension{type=com.atlassian.macro key=jira-chart}").unwrap();
9913 assert_eq!(doc.content[0].node_type, "extension");
9914 assert_eq!(
9915 doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9916 "com.atlassian.macro"
9917 );
9918 assert_eq!(
9919 doc.content[0].attrs.as_ref().unwrap()["extensionKey"],
9920 "jira-chart"
9921 );
9922 }
9923
9924 #[test]
9925 fn adf_void_extension_to_markdown() {
9926 let doc = AdfDocument {
9927 version: 1,
9928 doc_type: "doc".to_string(),
9929 content: vec![AdfNode::extension(
9930 "com.atlassian.macro",
9931 "jira-chart",
9932 None,
9933 )],
9934 };
9935 let md = adf_to_markdown(&doc).unwrap();
9936 assert!(md.contains("::extension{type=com.atlassian.macro key=jira-chart}"));
9937 }
9938
9939 #[test]
9942 fn bare_url_autolink() {
9943 let doc = markdown_to_adf("Visit https://example.com today").unwrap();
9944 let content = doc.content[0].content.as_ref().unwrap();
9945 assert_eq!(content[0].text.as_deref(), Some("Visit "));
9946 assert_eq!(content[1].node_type, "inlineCard");
9947 assert_eq!(
9948 content[1].attrs.as_ref().unwrap()["url"],
9949 "https://example.com"
9950 );
9951 assert_eq!(content[2].text.as_deref(), Some(" today"));
9952 }
9953
9954 #[test]
9955 fn bare_url_strips_trailing_punctuation() {
9956 let doc = markdown_to_adf("See https://example.com.").unwrap();
9957 let content = doc.content[0].content.as_ref().unwrap();
9958 assert_eq!(
9959 content[1].attrs.as_ref().unwrap()["url"],
9960 "https://example.com"
9961 );
9962 }
9963
9964 #[test]
9965 fn bare_url_round_trip() {
9966 let doc = markdown_to_adf("Visit https://example.com/path today").unwrap();
9967 let md = adf_to_markdown(&doc).unwrap();
9968 assert!(md.contains(":card[https://example.com/path]"));
9969 }
9970
9971 #[test]
9974 fn plain_text_url_round_trips_as_text() {
9975 let adf_json = r#"{
9978 "version": 1,
9979 "type": "doc",
9980 "content": [{
9981 "type": "paragraph",
9982 "content": [
9983 {"type": "text", "text": "https://example.com/some/path/to/resource"}
9984 ]
9985 }]
9986 }"#;
9987 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9988 let jfm = adf_to_markdown(&adf).unwrap();
9989 let roundtripped = markdown_to_adf(&jfm).unwrap();
9990 let content = roundtripped.content[0].content.as_ref().unwrap();
9991 assert_eq!(content.len(), 1, "should be a single node");
9992 assert_eq!(content[0].node_type, "text");
9993 assert_eq!(
9994 content[0].text.as_deref(),
9995 Some("https://example.com/some/path/to/resource")
9996 );
9997 }
9998
9999 #[test]
10000 fn url_text_with_link_mark_round_trips_as_text_node() {
10001 let adf_json = r#"{
10005 "version": 1,
10006 "type": "doc",
10007 "content": [{
10008 "type": "paragraph",
10009 "content": [{
10010 "type": "text",
10011 "text": "https://octopz.example.com",
10012 "marks": [{"type": "link", "attrs": {"href": "https://octopz.example.com/"}}]
10013 }]
10014 }]
10015 }"#;
10016 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10017 let jfm = adf_to_markdown(&adf).unwrap();
10018 let roundtripped = markdown_to_adf(&jfm).unwrap();
10019 let content = roundtripped.content[0].content.as_ref().unwrap();
10020 assert_eq!(content.len(), 1, "should be a single node");
10021 assert_eq!(content[0].node_type, "text", "must be text, not inlineCard");
10022 assert_eq!(
10023 content[0].text.as_deref(),
10024 Some("https://octopz.example.com")
10025 );
10026 let mark = &content[0].marks.as_ref().unwrap()[0];
10027 assert_eq!(mark.mark_type, "link");
10028 assert_eq!(
10029 mark.attrs.as_ref().unwrap()["href"],
10030 "https://octopz.example.com/"
10031 );
10032 }
10033
10034 #[test]
10035 fn url_text_with_exact_link_mark_round_trips() {
10036 let adf_json = r#"{
10038 "version": 1,
10039 "type": "doc",
10040 "content": [{
10041 "type": "paragraph",
10042 "content": [{
10043 "type": "text",
10044 "text": "https://example.com/path",
10045 "marks": [{"type": "link", "attrs": {"href": "https://example.com/path"}}]
10046 }]
10047 }]
10048 }"#;
10049 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10050 let jfm = adf_to_markdown(&adf).unwrap();
10051 let roundtripped = markdown_to_adf(&jfm).unwrap();
10052 let content = roundtripped.content[0].content.as_ref().unwrap();
10053 assert_eq!(content.len(), 1, "should be a single node");
10054 assert_eq!(content[0].node_type, "text");
10055 assert_eq!(content[0].text.as_deref(), Some("https://example.com/path"));
10056 let mark = &content[0].marks.as_ref().unwrap()[0];
10057 assert_eq!(mark.mark_type, "link");
10058 }
10059
10060 #[test]
10061 fn plain_text_url_amid_text_round_trips() {
10062 let adf_json = r#"{
10064 "version": 1,
10065 "type": "doc",
10066 "content": [{
10067 "type": "paragraph",
10068 "content": [
10069 {"type": "text", "text": "see https://example.com for info"}
10070 ]
10071 }]
10072 }"#;
10073 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10074 let jfm = adf_to_markdown(&adf).unwrap();
10075 let roundtripped = markdown_to_adf(&jfm).unwrap();
10076 let content = roundtripped.content[0].content.as_ref().unwrap();
10077 assert_eq!(content.len(), 1);
10078 assert_eq!(content[0].node_type, "text");
10079 assert_eq!(
10080 content[0].text.as_deref(),
10081 Some("see https://example.com for info")
10082 );
10083 }
10084
10085 #[test]
10086 fn plain_text_multiple_urls_round_trips() {
10087 let adf_json = r#"{
10088 "version": 1,
10089 "type": "doc",
10090 "content": [{
10091 "type": "paragraph",
10092 "content": [
10093 {"type": "text", "text": "http://a.com and https://b.com"}
10094 ]
10095 }]
10096 }"#;
10097 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10098 let jfm = adf_to_markdown(&adf).unwrap();
10099 let roundtripped = markdown_to_adf(&jfm).unwrap();
10100 let content = roundtripped.content[0].content.as_ref().unwrap();
10101 assert_eq!(content.len(), 1);
10102 assert_eq!(content[0].node_type, "text");
10103 assert_eq!(
10104 content[0].text.as_deref(),
10105 Some("http://a.com and https://b.com")
10106 );
10107 }
10108
10109 #[test]
10110 fn plain_text_http_prefix_no_url_unchanged() {
10111 let adf_json = r#"{
10113 "version": 1,
10114 "type": "doc",
10115 "content": [{
10116 "type": "paragraph",
10117 "content": [
10118 {"type": "text", "text": "the http header is important"}
10119 ]
10120 }]
10121 }"#;
10122 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10123 let jfm = adf_to_markdown(&adf).unwrap();
10124 let roundtripped = markdown_to_adf(&jfm).unwrap();
10125 let content = roundtripped.content[0].content.as_ref().unwrap();
10126 assert_eq!(
10127 content[0].text.as_deref(),
10128 Some("the http header is important")
10129 );
10130 }
10131
10132 #[test]
10133 fn linked_url_text_round_trips() {
10134 let adf_json = r#"{
10138 "version": 1,
10139 "type": "doc",
10140 "content": [{
10141 "type": "paragraph",
10142 "content": [{
10143 "type": "text",
10144 "text": "https://example.com",
10145 "marks": [{"type": "link", "attrs": {"href": "https://example.com"}}]
10146 }]
10147 }]
10148 }"#;
10149 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10150 let jfm = adf_to_markdown(&adf).unwrap();
10151 let roundtripped = markdown_to_adf(&jfm).unwrap();
10152 let content = roundtripped.content[0].content.as_ref().unwrap();
10153 assert_eq!(content.len(), 1);
10154 assert_eq!(content[0].node_type, "text");
10155 assert_eq!(content[0].text.as_deref(), Some("https://example.com"));
10156 let mark = &content[0].marks.as_ref().unwrap()[0];
10157 assert_eq!(mark.mark_type, "link");
10158 assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
10159 }
10160
10161 #[test]
10164 fn escape_link_brackets_unit() {
10165 assert_eq!(escape_link_brackets("hello"), "hello");
10166 assert_eq!(escape_link_brackets("["), "\\[");
10167 assert_eq!(escape_link_brackets("]"), "\\]");
10168 assert_eq!(escape_link_brackets("[PROJ-456]"), "\\[PROJ-456\\]");
10169 assert_eq!(escape_link_brackets("a[b]c"), "a\\[b\\]c");
10170 }
10171
10172 #[test]
10173 fn bracket_text_with_link_mark_escapes_brackets() {
10174 let doc = AdfDocument {
10177 version: 1,
10178 doc_type: "doc".to_string(),
10179 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10180 "[",
10181 vec![AdfMark::link("https://example.com")],
10182 )])],
10183 };
10184 let md = adf_to_markdown(&doc).unwrap();
10185 assert_eq!(md.trim(), "[\\[](https://example.com)");
10186 }
10187
10188 #[test]
10189 fn bracket_text_with_link_mark_round_trips() {
10190 let adf_json = r#"{
10193 "type": "doc",
10194 "version": 1,
10195 "content": [{
10196 "type": "paragraph",
10197 "content": [
10198 {
10199 "type": "text",
10200 "text": "[",
10201 "marks": [{"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}]
10202 },
10203 {
10204 "type": "text",
10205 "text": "PROJ-456] Fix the auth bug",
10206 "marks": [
10207 {"type": "underline"},
10208 {"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}
10209 ]
10210 }
10211 ]
10212 }]
10213 }"#;
10214 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10215 let jfm = adf_to_markdown(&adf).unwrap();
10216
10217 assert!(jfm.contains("\\["), "opening bracket should be escaped");
10219
10220 let rt = markdown_to_adf(&jfm).unwrap();
10222 let content = rt.content[0].content.as_ref().unwrap();
10223
10224 let link_nodes: Vec<_> = content
10226 .iter()
10227 .filter(|n| {
10228 n.marks
10229 .as_ref()
10230 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
10231 })
10232 .collect();
10233 assert!(
10234 !link_nodes.is_empty(),
10235 "link mark must be preserved on round-trip"
10236 );
10237
10238 let all_text: String = content.iter().filter_map(|n| n.text.as_deref()).collect();
10240 assert!(
10241 all_text.contains('['),
10242 "literal '[' must survive round-trip"
10243 );
10244 assert!(
10245 all_text.contains("PROJ-456]"),
10246 "continuation text must survive round-trip"
10247 );
10248 }
10249
10250 #[test]
10251 fn closing_bracket_in_link_text_round_trips() {
10252 let doc = AdfDocument {
10255 version: 1,
10256 doc_type: "doc".to_string(),
10257 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10258 "item]",
10259 vec![AdfMark::link("https://example.com")],
10260 )])],
10261 };
10262 let md = adf_to_markdown(&doc).unwrap();
10263 assert_eq!(md.trim(), "[item\\]](https://example.com)");
10264
10265 let rt = markdown_to_adf(&md).unwrap();
10266 let content = rt.content[0].content.as_ref().unwrap();
10267 assert_eq!(content[0].text.as_deref(), Some("item]"));
10268 assert!(content[0]
10269 .marks
10270 .as_ref()
10271 .unwrap()
10272 .iter()
10273 .any(|m| m.mark_type == "link"));
10274 }
10275
10276 #[test]
10277 fn brackets_in_link_text_round_trip() {
10278 let doc = AdfDocument {
10280 version: 1,
10281 doc_type: "doc".to_string(),
10282 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10283 "[PROJ-123]",
10284 vec![AdfMark::link("https://example.com")],
10285 )])],
10286 };
10287 let md = adf_to_markdown(&doc).unwrap();
10288 assert_eq!(md.trim(), "[\\[PROJ-123\\]](https://example.com)");
10289
10290 let rt = markdown_to_adf(&md).unwrap();
10291 let content = rt.content[0].content.as_ref().unwrap();
10292 assert_eq!(content[0].text.as_deref(), Some("[PROJ-123]"));
10293 assert!(content[0]
10294 .marks
10295 .as_ref()
10296 .unwrap()
10297 .iter()
10298 .any(|m| m.mark_type == "link"));
10299 }
10300
10301 #[test]
10302 fn plain_text_brackets_not_escaped() {
10303 let doc = AdfDocument {
10305 version: 1,
10306 doc_type: "doc".to_string(),
10307 content: vec![AdfNode::paragraph(vec![AdfNode::text(
10308 "see [PROJ-123] for details",
10309 )])],
10310 };
10311 let md = adf_to_markdown(&doc).unwrap();
10312 assert_eq!(md.trim(), "see [PROJ-123] for details");
10313 }
10314
10315 #[test]
10316 fn link_with_no_brackets_unchanged() {
10317 let doc = AdfDocument {
10319 version: 1,
10320 doc_type: "doc".to_string(),
10321 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10322 "click here",
10323 vec![AdfMark::link("https://example.com")],
10324 )])],
10325 };
10326 let md = adf_to_markdown(&doc).unwrap();
10327 assert_eq!(md.trim(), "[click here](https://example.com)");
10328 }
10329
10330 #[test]
10333 fn url_with_brackets_as_link_text_round_trips() {
10334 let href = "https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10339 let doc = AdfDocument {
10340 version: 1,
10341 doc_type: "doc".to_string(),
10342 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10343 href,
10344 vec![AdfMark::link(href)],
10345 )])],
10346 };
10347 let md = adf_to_markdown(&doc).unwrap();
10348 let rt = markdown_to_adf(&md).unwrap();
10349 let content = rt.content[0].content.as_ref().unwrap();
10350 assert_eq!(content.len(), 1);
10351 assert_eq!(content[0].node_type, "text");
10352 assert_eq!(content[0].text.as_deref(), Some(href));
10353 let mark = &content[0].marks.as_ref().unwrap()[0];
10354 assert_eq!(mark.mark_type, "link");
10355 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10356 }
10357
10358 #[test]
10359 fn url_with_brackets_embedded_in_link_text_round_trips() {
10360 let href = "https://example.com/logs?query=service%20environment%20data&from=100&to=200";
10367 let text =
10368 "See the logs: https://example.com/logs?query=service[\u{2026}]data&from=100&to=200";
10369 let doc = AdfDocument {
10370 version: 1,
10371 doc_type: "doc".to_string(),
10372 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10373 text,
10374 vec![AdfMark::link(href)],
10375 )])],
10376 };
10377 let md = adf_to_markdown(&doc).unwrap();
10378 let rt = markdown_to_adf(&md).unwrap();
10379 let content = rt.content[0].content.as_ref().unwrap();
10380 assert_eq!(content.len(), 1, "content split unexpectedly: {content:?}");
10381 assert_eq!(content[0].node_type, "text");
10382 assert_eq!(content[0].text.as_deref(), Some(text));
10383 let mark = &content[0].marks.as_ref().unwrap()[0];
10384 assert_eq!(mark.mark_type, "link");
10385 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10386 }
10387
10388 #[test]
10389 fn url_with_brackets_plain_text_round_trips() {
10390 let text =
10393 "See the dashboard: https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10394 let doc = AdfDocument {
10395 version: 1,
10396 doc_type: "doc".to_string(),
10397 content: vec![AdfNode::paragraph(vec![AdfNode::text(text)])],
10398 };
10399 let md = adf_to_markdown(&doc).unwrap();
10400 let rt = markdown_to_adf(&md).unwrap();
10401 let content = rt.content[0].content.as_ref().unwrap();
10402 assert_eq!(content.len(), 1);
10403 assert_eq!(content[0].node_type, "text");
10404 assert_eq!(content[0].text.as_deref(), Some(text));
10405 assert!(content[0].marks.is_none());
10406 }
10407
10408 #[test]
10409 fn url_with_link_mark_embedded_no_brackets_round_trips() {
10410 let href = "https://example.com/";
10413 let text = "See https://example.com/ for more";
10414 let doc = AdfDocument {
10415 version: 1,
10416 doc_type: "doc".to_string(),
10417 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10418 text,
10419 vec![AdfMark::link(href)],
10420 )])],
10421 };
10422 let md = adf_to_markdown(&doc).unwrap();
10423 let rt = markdown_to_adf(&md).unwrap();
10424 let content = rt.content[0].content.as_ref().unwrap();
10425 assert_eq!(content.len(), 1);
10426 assert_eq!(content[0].node_type, "text");
10427 assert_eq!(content[0].text.as_deref(), Some(text));
10428 let mark = &content[0].marks.as_ref().unwrap()[0];
10429 assert_eq!(mark.mark_type, "link");
10430 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10431 }
10432
10433 #[test]
10434 fn nested_brackets_in_link_text_round_trip() {
10435 let href = "https://x.com";
10438 let text = "foo [a[b]c] bar";
10439 let doc = AdfDocument {
10440 version: 1,
10441 doc_type: "doc".to_string(),
10442 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10443 text,
10444 vec![AdfMark::link(href)],
10445 )])],
10446 };
10447 let md = adf_to_markdown(&doc).unwrap();
10448 let rt = markdown_to_adf(&md).unwrap();
10449 let content = rt.content[0].content.as_ref().unwrap();
10450 assert_eq!(content.len(), 1);
10451 assert_eq!(content[0].node_type, "text");
10452 assert_eq!(content[0].text.as_deref(), Some(text));
10453 }
10454
10455 #[test]
10456 fn bracket_url_bracket_in_link_text_round_trips() {
10457 let href = "https://y.com";
10463 let text = "[see https://x.com/a[0]=1 here]";
10464 let doc = AdfDocument {
10465 version: 1,
10466 doc_type: "doc".to_string(),
10467 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10468 text,
10469 vec![AdfMark::link(href)],
10470 )])],
10471 };
10472 let md = adf_to_markdown(&doc).unwrap();
10473 let rt = markdown_to_adf(&md).unwrap();
10474 let content = rt.content[0].content.as_ref().unwrap();
10475 assert_eq!(content.len(), 1);
10476 assert_eq!(content[0].node_type, "text");
10477 assert_eq!(content[0].text.as_deref(), Some(text));
10478 let mark = &content[0].marks.as_ref().unwrap()[0];
10479 assert_eq!(mark.mark_type, "link");
10480 assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10481 }
10482
10483 #[test]
10484 fn escape_bare_urls_applied_inside_link_text() {
10485 let doc = AdfDocument {
10491 version: 1,
10492 doc_type: "doc".to_string(),
10493 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10494 "See https://example.com/",
10495 vec![AdfMark::link("https://target.example.com/")],
10496 )])],
10497 };
10498 let md = adf_to_markdown(&doc).unwrap();
10499 assert!(
10500 md.contains(r"\https://example.com/"),
10501 "bare URL inside link text must be escaped, got: {md}"
10502 );
10503 }
10504
10505 #[test]
10506 fn inline_card_still_round_trips() {
10507 let adf_json = r#"{
10510 "version": 1,
10511 "type": "doc",
10512 "content": [{
10513 "type": "paragraph",
10514 "content": [
10515 {"type": "inlineCard", "attrs": {"url": "https://example.com/page"}}
10516 ]
10517 }]
10518 }"#;
10519 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10520 let jfm = adf_to_markdown(&adf).unwrap();
10521 assert!(jfm.contains(":card[https://example.com/page]"));
10522 let roundtripped = markdown_to_adf(&jfm).unwrap();
10523 let content = roundtripped.content[0].content.as_ref().unwrap();
10524 assert_eq!(content[0].node_type, "inlineCard");
10525 assert_eq!(
10526 content[0].attrs.as_ref().unwrap()["url"],
10527 "https://example.com/page"
10528 );
10529 }
10530
10531 #[test]
10534 fn url_safe_in_bracket_content_balanced() {
10535 assert!(url_safe_in_bracket_content("https://example.com"));
10537 assert!(url_safe_in_bracket_content("https://example.com/[id]"));
10538 assert!(url_safe_in_bracket_content("a[b[c]d]e"));
10539 assert!(url_safe_in_bracket_content(""));
10540 }
10541
10542 #[test]
10543 fn url_safe_in_bracket_content_unbalanced() {
10544 assert!(!url_safe_in_bracket_content("a]b"));
10546 assert!(!url_safe_in_bracket_content("https://example.com/path]end"));
10547 assert!(!url_safe_in_bracket_content("a\nb"));
10549 }
10550
10551 #[test]
10552 fn inline_card_url_with_closing_bracket_round_trip() {
10553 let adf_json = r#"{
10558 "version": 1,
10559 "type": "doc",
10560 "content": [{
10561 "type": "paragraph",
10562 "content": [
10563 {"type": "text", "text": "See: "},
10564 {"type": "inlineCard", "attrs": {"url": "https://example.com/path]end/?q=1"}}
10565 ]
10566 }]
10567 }"#;
10568 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10569 let jfm = adf_to_markdown(&adf).unwrap();
10570 assert!(
10571 jfm.contains(r#":card[]{url="https://example.com/path]end/?q=1"}"#),
10572 "expected attr-form for URL with `]`, got: {jfm}"
10573 );
10574 let rt = markdown_to_adf(&jfm).unwrap();
10575 let content = rt.content[0].content.as_ref().unwrap();
10576 assert_eq!(content.len(), 2, "expected 2 inline nodes, got {content:?}");
10577 assert_eq!(content[0].node_type, "text");
10578 assert_eq!(content[0].text.as_deref(), Some("See: "));
10579 assert_eq!(content[1].node_type, "inlineCard");
10580 assert_eq!(
10581 content[1].attrs.as_ref().unwrap()["url"],
10582 "https://example.com/path]end/?q=1"
10583 );
10584 }
10585
10586 #[test]
10587 fn inline_card_url_with_closing_bracket_preserves_local_id() {
10588 let adf_json = r#"{
10590 "version": 1,
10591 "type": "doc",
10592 "content": [{
10593 "type": "paragraph",
10594 "content": [
10595 {"type": "inlineCard", "attrs": {
10596 "url": "https://example.com/a]b",
10597 "localId": "c-77"
10598 }}
10599 ]
10600 }]
10601 }"#;
10602 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10603 let jfm = adf_to_markdown(&adf).unwrap();
10604 assert!(
10605 jfm.contains(r#"url="https://example.com/a]b""#),
10606 "jfm: {jfm}"
10607 );
10608 assert!(jfm.contains("localId=c-77"), "jfm: {jfm}");
10609 let rt = markdown_to_adf(&jfm).unwrap();
10610 let card = &rt.content[0].content.as_ref().unwrap()[0];
10611 assert_eq!(card.node_type, "inlineCard");
10612 assert_eq!(
10613 card.attrs.as_ref().unwrap()["url"],
10614 "https://example.com/a]b"
10615 );
10616 assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-77");
10617 }
10618
10619 #[test]
10620 fn block_card_url_with_closing_bracket_round_trip() {
10621 let adf_json = r#"{
10623 "version": 1,
10624 "type": "doc",
10625 "content": [
10626 {"type": "blockCard", "attrs": {"url": "https://example.com/path]end"}}
10627 ]
10628 }"#;
10629 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10630 let jfm = adf_to_markdown(&adf).unwrap();
10631 assert!(
10632 jfm.contains(r#"::card[]{url="https://example.com/path]end"}"#),
10633 "expected attr-form for blockCard with `]`, got: {jfm}"
10634 );
10635 let rt = markdown_to_adf(&jfm).unwrap();
10636 assert_eq!(rt.content[0].node_type, "blockCard");
10637 assert_eq!(
10638 rt.content[0].attrs.as_ref().unwrap()["url"],
10639 "https://example.com/path]end"
10640 );
10641 }
10642
10643 #[test]
10644 fn block_card_attr_form_parses_without_renderer() {
10645 let doc = markdown_to_adf(r#"::card[]{url="https://example.com/a"}"#).unwrap();
10649 assert_eq!(doc.content[0].node_type, "blockCard");
10650 assert_eq!(
10651 doc.content[0].attrs.as_ref().unwrap()["url"],
10652 "https://example.com/a"
10653 );
10654 }
10655
10656 #[test]
10657 fn block_card_attr_form_url_overrides_content() {
10658 let doc =
10662 markdown_to_adf(r#"::card[https://old.example.com]{url="https://new.example.com"}"#)
10663 .unwrap();
10664 assert_eq!(doc.content[0].node_type, "blockCard");
10665 assert_eq!(
10666 doc.content[0].attrs.as_ref().unwrap()["url"],
10667 "https://new.example.com"
10668 );
10669 }
10670
10671 #[test]
10672 fn block_card_attr_form_with_layout_and_width() {
10673 let doc =
10676 markdown_to_adf(r#"::card[]{url="https://example.com/a]b" layout=wide width=80}"#)
10677 .unwrap();
10678 let attrs = doc.content[0].attrs.as_ref().unwrap();
10679 assert_eq!(attrs["url"], "https://example.com/a]b");
10680 assert_eq!(attrs["layout"], "wide");
10681 assert_eq!(attrs["width"], 80);
10682 }
10683
10684 #[test]
10685 fn inline_card_issue_553_reproducer() {
10686 let adf_json = r#"{
10690 "version": 1,
10691 "type": "doc",
10692 "content": [{
10693 "type": "paragraph",
10694 "content": [
10695 {"type": "text", "text": "See the related page: "},
10696 {"type": "inlineCard", "attrs": {
10697 "url": "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10698 }}
10699 ]
10700 }]
10701 }"#;
10702 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10703 let jfm = adf_to_markdown(&adf).unwrap();
10704 let rt = markdown_to_adf(&jfm).unwrap();
10705 let content = rt.content[0].content.as_ref().unwrap();
10706 assert_eq!(content.len(), 2);
10707 assert_eq!(content[0].node_type, "text");
10708 assert_eq!(content[1].node_type, "inlineCard");
10709 assert_eq!(
10710 content[1].attrs.as_ref().unwrap()["url"],
10711 "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10712 );
10713 }
10714
10715 #[test]
10716 fn inline_card_attr_form_parses_even_without_renderer() {
10717 let doc = markdown_to_adf(r#":card[]{url="https://example.com/a"}"#).unwrap();
10719 let node = &doc.content[0].content.as_ref().unwrap()[0];
10720 assert_eq!(node.node_type, "inlineCard");
10721 assert_eq!(node.attrs.as_ref().unwrap()["url"], "https://example.com/a");
10722 }
10723
10724 #[test]
10725 fn inline_card_attr_form_url_overrides_content() {
10726 let doc =
10730 markdown_to_adf(r#":card[https://old.example.com]{url="https://new.example.com"}"#)
10731 .unwrap();
10732 let node = &doc.content[0].content.as_ref().unwrap()[0];
10733 assert_eq!(node.node_type, "inlineCard");
10734 assert_eq!(
10735 node.attrs.as_ref().unwrap()["url"],
10736 "https://new.example.com"
10737 );
10738 }
10739
10740 #[test]
10743 fn url_with_link_and_underline_marks_round_trip() {
10744 let adf_json = r#"{
10748 "version": 1,
10749 "type": "doc",
10750 "content": [{
10751 "type": "paragraph",
10752 "content": [
10753 {"type": "text", "text": "See results at: "},
10754 {"type": "text",
10755 "text": "https://example.com/projects/abc123/analytics",
10756 "marks": [
10757 {"type": "link", "attrs": {"href": "https://example.com/projects/abc123/analytics"}},
10758 {"type": "underline"}
10759 ]},
10760 {"type": "text", "text": " for details."}
10761 ]
10762 }]
10763 }"#;
10764 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10765 let jfm = adf_to_markdown(&adf).unwrap();
10766 let rt = markdown_to_adf(&jfm).unwrap();
10767 let content = rt.content[0].content.as_ref().unwrap();
10768 assert_eq!(
10769 content.len(),
10770 3,
10771 "expected 3 inline nodes, got: {content:?}"
10772 );
10773 assert_eq!(content[0].node_type, "text");
10774 assert_eq!(
10775 content[1].node_type, "text",
10776 "must stay text, not inlineCard"
10777 );
10778 assert_eq!(
10779 content[1].text.as_deref(),
10780 Some("https://example.com/projects/abc123/analytics")
10781 );
10782 let mark_types: Vec<&str> = content[1]
10783 .marks
10784 .as_deref()
10785 .unwrap_or(&[])
10786 .iter()
10787 .map(|m| m.mark_type.as_str())
10788 .collect();
10789 assert_eq!(mark_types, vec!["link", "underline"], "marks lost");
10790 assert_eq!(content[2].node_type, "text");
10791 }
10792
10793 #[test]
10794 fn url_inside_bracketed_span_stays_text() {
10795 let doc = markdown_to_adf("[https://example.com]{underline}").unwrap();
10799 let node = &doc.content[0].content.as_ref().unwrap()[0];
10800 assert_eq!(node.node_type, "text");
10801 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10802 let mark_types: Vec<&str> = node
10803 .marks
10804 .as_deref()
10805 .unwrap_or(&[])
10806 .iter()
10807 .map(|m| m.mark_type.as_str())
10808 .collect();
10809 assert_eq!(mark_types, vec!["underline"]);
10810 }
10811
10812 #[test]
10813 fn url_inside_emphasis_stays_text() {
10814 for (md, mark) in [
10817 ("**https://example.com**", "strong"),
10818 ("*https://example.com*", "em"),
10819 ("~~https://example.com~~", "strike"),
10820 ] {
10821 let doc = markdown_to_adf(md).unwrap();
10822 let node = &doc.content[0].content.as_ref().unwrap()[0];
10823 assert_eq!(node.node_type, "text", "md={md}: must be text");
10824 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10825 let mark_types: Vec<&str> = node
10826 .marks
10827 .as_deref()
10828 .unwrap_or(&[])
10829 .iter()
10830 .map(|m| m.mark_type.as_str())
10831 .collect();
10832 assert_eq!(mark_types, vec![mark], "md={md}: wrong marks");
10833 }
10834 }
10835
10836 #[test]
10837 fn url_inside_span_directive_stays_text() {
10838 let doc = markdown_to_adf(":span[https://example.com]{color=red}").unwrap();
10840 let node = &doc.content[0].content.as_ref().unwrap()[0];
10841 assert_eq!(node.node_type, "text");
10842 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10843 let mark = &node.marks.as_ref().unwrap()[0];
10844 assert_eq!(mark.mark_type, "textColor");
10845 }
10846
10847 #[test]
10848 fn url_as_link_text_with_underline_after_link_mark_order() {
10849 let adf_json = r#"{
10853 "version": 1,
10854 "type": "doc",
10855 "content": [{
10856 "type": "paragraph",
10857 "content": [
10858 {"type": "text",
10859 "text": "https://example.com",
10860 "marks": [
10861 {"type": "underline"},
10862 {"type": "link", "attrs": {"href": "https://example.com"}}
10863 ]}
10864 ]
10865 }]
10866 }"#;
10867 let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10868 let jfm = adf_to_markdown(&adf).unwrap();
10869 let rt = markdown_to_adf(&jfm).unwrap();
10870 let node = &rt.content[0].content.as_ref().unwrap()[0];
10871 assert_eq!(node.node_type, "text", "must stay text, got: {node:?}");
10872 assert_eq!(node.text.as_deref(), Some("https://example.com"));
10873 let mark_types: Vec<&str> = node
10874 .marks
10875 .as_deref()
10876 .unwrap_or(&[])
10877 .iter()
10878 .map(|m| m.mark_type.as_str())
10879 .collect();
10880 assert_eq!(mark_types, vec!["underline", "link"]);
10881 }
10882
10883 #[test]
10884 fn bare_url_at_top_level_still_becomes_inline_card() {
10885 let doc = markdown_to_adf("Visit https://example.com today").unwrap();
10889 let content = doc.content[0].content.as_ref().unwrap();
10890 assert_eq!(content.len(), 3);
10891 assert_eq!(content[0].node_type, "text");
10892 assert_eq!(content[1].node_type, "inlineCard");
10893 assert_eq!(
10894 content[1].attrs.as_ref().unwrap()["url"],
10895 "https://example.com"
10896 );
10897 assert_eq!(content[2].node_type, "text");
10898 }
10899
10900 #[test]
10903 fn paragraph_align_center() {
10904 let md = "Centered text.\n{align=center}";
10905 let doc = markdown_to_adf(md).unwrap();
10906 let marks = doc.content[0].marks.as_ref().unwrap();
10907 assert_eq!(marks[0].mark_type, "alignment");
10908 assert_eq!(marks[0].attrs.as_ref().unwrap()["align"], "center");
10909 }
10910
10911 #[test]
10912 fn adf_alignment_to_markdown() {
10913 let mut node = AdfNode::paragraph(vec![AdfNode::text("Centered.")]);
10914 node.marks = Some(vec![AdfMark::alignment("center")]);
10915 let doc = AdfDocument {
10916 version: 1,
10917 doc_type: "doc".to_string(),
10918 content: vec![node],
10919 };
10920 let md = adf_to_markdown(&doc).unwrap();
10921 assert!(md.contains("Centered."));
10922 assert!(md.contains("{align=center}"));
10923 }
10924
10925 #[test]
10926 fn round_trip_alignment() {
10927 let md = "Centered.\n{align=center}\n";
10928 let doc = markdown_to_adf(md).unwrap();
10929 let result = adf_to_markdown(&doc).unwrap();
10930 assert!(result.contains("{align=center}"));
10931 }
10932
10933 #[test]
10934 fn paragraph_indent() {
10935 let md = "Indented.\n{indent=2}";
10936 let doc = markdown_to_adf(md).unwrap();
10937 let marks = doc.content[0].marks.as_ref().unwrap();
10938 assert_eq!(marks[0].mark_type, "indentation");
10939 assert_eq!(marks[0].attrs.as_ref().unwrap()["level"], 2);
10940 }
10941
10942 #[test]
10943 fn code_block_breakout() {
10944 let md = "```python\ndef f(): pass\n```\n{breakout=wide}";
10945 let doc = markdown_to_adf(md).unwrap();
10946 let marks = doc.content[0].marks.as_ref().unwrap();
10947 assert_eq!(marks[0].mark_type, "breakout");
10948 assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
10949 assert!(marks[0].attrs.as_ref().unwrap().get("width").is_none());
10950 }
10951
10952 #[test]
10953 fn code_block_breakout_with_width() {
10954 let md = "```python\ndef f(): pass\n```\n{breakout=wide breakoutWidth=1200}";
10955 let doc = markdown_to_adf(md).unwrap();
10956 let marks = doc.content[0].marks.as_ref().unwrap();
10957 assert_eq!(marks[0].mark_type, "breakout");
10958 assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
10959 assert_eq!(marks[0].attrs.as_ref().unwrap()["width"], 1200);
10960 }
10961
10962 #[test]
10963 fn adf_breakout_to_markdown() {
10964 let mut node = AdfNode::code_block(Some("python"), "pass");
10965 node.marks = Some(vec![AdfMark::breakout("wide", None)]);
10966 let doc = AdfDocument {
10967 version: 1,
10968 doc_type: "doc".to_string(),
10969 content: vec![node],
10970 };
10971 let md = adf_to_markdown(&doc).unwrap();
10972 assert!(md.contains("{breakout=wide}"));
10973 assert!(!md.contains("breakoutWidth"));
10974 }
10975
10976 #[test]
10977 fn adf_breakout_with_width_to_markdown() {
10978 let mut node = AdfNode::code_block(Some("python"), "pass");
10979 node.marks = Some(vec![AdfMark::breakout("wide", Some(1200))]);
10980 let doc = AdfDocument {
10981 version: 1,
10982 doc_type: "doc".to_string(),
10983 content: vec![node],
10984 };
10985 let md = adf_to_markdown(&doc).unwrap();
10986 assert!(md.contains("breakout=wide"));
10987 assert!(md.contains("breakoutWidth=1200"));
10988 }
10989
10990 #[test]
10991 fn breakout_width_round_trip() {
10992 let adf_json = r#"{"version":1,"type":"doc","content":[{
10993 "type":"codeBlock",
10994 "attrs":{"language":"text"},
10995 "marks":[{"type":"breakout","attrs":{"mode":"wide","width":1200}}],
10996 "content":[{"type":"text","text":"some code"}]
10997 }]}"#;
10998 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10999 let md = adf_to_markdown(&doc).unwrap();
11000 assert!(md.contains("breakout=wide"));
11001 assert!(md.contains("breakoutWidth=1200"));
11002 let round_tripped = markdown_to_adf(&md).unwrap();
11003 let marks = round_tripped.content[0].marks.as_ref().unwrap();
11004 let breakout = marks.iter().find(|m| m.mark_type == "breakout").unwrap();
11005 assert_eq!(breakout.attrs.as_ref().unwrap()["mode"], "wide");
11006 assert_eq!(breakout.attrs.as_ref().unwrap()["width"], 1200);
11007 }
11008
11009 #[test]
11012 fn image_with_layout_attrs() {
11013 let doc = markdown_to_adf("{layout=wide width=80}").unwrap();
11014 let node = &doc.content[0];
11015 assert_eq!(node.node_type, "mediaSingle");
11016 let attrs = node.attrs.as_ref().unwrap();
11017 assert_eq!(attrs["layout"], "wide");
11018 assert_eq!(attrs["width"], 80);
11019 }
11020
11021 #[test]
11022 fn adf_image_with_layout_to_markdown() {
11023 let mut node = AdfNode::media_single("url", Some("alt"));
11024 node.attrs.as_mut().unwrap()["layout"] = serde_json::json!("wide");
11025 node.attrs.as_mut().unwrap()["width"] = serde_json::json!(80);
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("{layout=wide width=80}"));
11033 }
11034
11035 #[test]
11036 fn table_with_layout_attrs() {
11037 let md = "| H |\n| --- |\n| C |\n{layout=wide numbered}";
11038 let doc = markdown_to_adf(md).unwrap();
11039 let table = &doc.content[0];
11040 assert_eq!(table.node_type, "table");
11041 let attrs = table.attrs.as_ref().unwrap();
11042 assert_eq!(attrs["layout"], "wide");
11043 assert_eq!(attrs["isNumberColumnEnabled"], true);
11044 }
11045
11046 #[test]
11047 fn adf_table_with_attrs_to_markdown() {
11048 let mut table = AdfNode::table(vec![
11049 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
11050 AdfNode::text("H"),
11051 ])])]),
11052 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
11053 AdfNode::text("C"),
11054 ])])]),
11055 ]);
11056 table.attrs = Some(serde_json::json!({"layout": "wide", "isNumberColumnEnabled": true}));
11057 let doc = AdfDocument {
11058 version: 1,
11059 doc_type: "doc".to_string(),
11060 content: vec![table],
11061 };
11062 let md = adf_to_markdown(&doc).unwrap();
11063 assert!(md.contains("{layout=wide numbered}"));
11064 }
11065
11066 #[test]
11069 fn underline_bracketed_span() {
11070 let doc = markdown_to_adf("This is [underlined text]{underline} here.").unwrap();
11071 let content = doc.content[0].content.as_ref().unwrap();
11072 assert_eq!(content[1].text.as_deref(), Some("underlined text"));
11073 let marks = content[1].marks.as_ref().unwrap();
11074 assert_eq!(marks[0].mark_type, "underline");
11075 }
11076
11077 #[test]
11078 fn adf_underline_to_markdown() {
11079 let doc = AdfDocument {
11080 version: 1,
11081 doc_type: "doc".to_string(),
11082 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11083 "underlined",
11084 vec![AdfMark::underline()],
11085 )])],
11086 };
11087 let md = adf_to_markdown(&doc).unwrap();
11088 assert!(md.contains("[underlined]{underline}"));
11089 }
11090
11091 #[test]
11092 fn round_trip_underline() {
11093 let md = "This is [underlined text]{underline} here.\n";
11094 let doc = markdown_to_adf(md).unwrap();
11095 let result = adf_to_markdown(&doc).unwrap();
11096 assert!(result.contains("[underlined text]{underline}"));
11097 }
11098
11099 #[test]
11100 fn mark_ordering_underline_strong_preserved() {
11101 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11103 {"type":"text","text":"bold and underlined","marks":[{"type":"underline"},{"type":"strong"}]}
11104 ]}]}"#;
11105 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11106 let md = adf_to_markdown(&doc).unwrap();
11107 let round_tripped = markdown_to_adf(&md).unwrap();
11108 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11109 let mark_types: Vec<&str> = node
11110 .marks
11111 .as_ref()
11112 .unwrap()
11113 .iter()
11114 .map(|m| m.mark_type.as_str())
11115 .collect();
11116 assert_eq!(
11117 mark_types,
11118 vec!["underline", "strong"],
11119 "mark order should be preserved, got: {mark_types:?}"
11120 );
11121 }
11122
11123 #[test]
11124 fn mark_ordering_link_strong_preserved() {
11125 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11127 {"type":"text","text":"bold link","marks":[
11128 {"type":"link","attrs":{"href":"https://example.com"}},
11129 {"type":"strong"}
11130 ]}
11131 ]}]}"#;
11132 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11133 let md = adf_to_markdown(&doc).unwrap();
11134 let round_tripped = markdown_to_adf(&md).unwrap();
11135 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11136 let mark_types: Vec<&str> = node
11137 .marks
11138 .as_ref()
11139 .unwrap()
11140 .iter()
11141 .map(|m| m.mark_type.as_str())
11142 .collect();
11143 assert_eq!(
11144 mark_types,
11145 vec!["link", "strong"],
11146 "mark order should be preserved, got: {mark_types:?}"
11147 );
11148 }
11149
11150 #[test]
11151 fn mark_ordering_link_textcolor_preserved() {
11152 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11154 {"type":"text","text":"red link","marks":[
11155 {"type":"link","attrs":{"href":"https://example.com"}},
11156 {"type":"textColor","attrs":{"color":"#ff0000"}}
11157 ]}
11158 ]}]}"##;
11159 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11160 let md = adf_to_markdown(&doc).unwrap();
11161 let round_tripped = markdown_to_adf(&md).unwrap();
11162 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11163 let mark_types: Vec<&str> = node
11164 .marks
11165 .as_ref()
11166 .unwrap()
11167 .iter()
11168 .map(|m| m.mark_type.as_str())
11169 .collect();
11170 assert_eq!(
11171 mark_types,
11172 vec!["link", "textColor"],
11173 "mark order should be preserved, got: {mark_types:?}"
11174 );
11175 }
11176
11177 #[test]
11178 fn mark_ordering_link_em_preserved() {
11179 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11181 {"type":"text","text":"italic link","marks":[
11182 {"type":"link","attrs":{"href":"https://example.com"}},
11183 {"type":"em"}
11184 ]}
11185 ]}]}"#;
11186 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11187 let md = adf_to_markdown(&doc).unwrap();
11188 let round_tripped = markdown_to_adf(&md).unwrap();
11189 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11190 let mark_types: Vec<&str> = node
11191 .marks
11192 .as_ref()
11193 .unwrap()
11194 .iter()
11195 .map(|m| m.mark_type.as_str())
11196 .collect();
11197 assert_eq!(
11198 mark_types,
11199 vec!["link", "em"],
11200 "mark order should be preserved, got: {mark_types:?}"
11201 );
11202 }
11203
11204 #[test]
11205 fn mark_ordering_link_strike_preserved() {
11206 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11208 {"type":"text","text":"struck link","marks":[
11209 {"type":"link","attrs":{"href":"https://example.com"}},
11210 {"type":"strike"}
11211 ]}
11212 ]}]}"#;
11213 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11214 let md = adf_to_markdown(&doc).unwrap();
11215 let round_tripped = markdown_to_adf(&md).unwrap();
11216 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11217 let mark_types: Vec<&str> = node
11218 .marks
11219 .as_ref()
11220 .unwrap()
11221 .iter()
11222 .map(|m| m.mark_type.as_str())
11223 .collect();
11224 assert_eq!(
11225 mark_types,
11226 vec!["link", "strike"],
11227 "mark order should be preserved, got: {mark_types:?}"
11228 );
11229 }
11230
11231 #[test]
11232 fn mark_ordering_strong_link_preserved() {
11233 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11235 {"type":"text","text":"bold link","marks":[
11236 {"type":"strong"},
11237 {"type":"link","attrs":{"href":"https://example.com"}}
11238 ]}
11239 ]}]}"#;
11240 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11241 let md = adf_to_markdown(&doc).unwrap();
11242 let round_tripped = markdown_to_adf(&md).unwrap();
11243 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11244 let mark_types: Vec<&str> = node
11245 .marks
11246 .as_ref()
11247 .unwrap()
11248 .iter()
11249 .map(|m| m.mark_type.as_str())
11250 .collect();
11251 assert_eq!(
11252 mark_types,
11253 vec!["strong", "link"],
11254 "mark order should be preserved, got: {mark_types:?}"
11255 );
11256 }
11257
11258 #[test]
11259 fn mark_ordering_em_link_preserved() {
11260 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11262 {"type":"text","text":"italic link","marks":[
11263 {"type":"em"},
11264 {"type":"link","attrs":{"href":"https://example.com"}}
11265 ]}
11266 ]}]}"#;
11267 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11268 let md = adf_to_markdown(&doc).unwrap();
11269 let round_tripped = markdown_to_adf(&md).unwrap();
11270 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11271 let mark_types: Vec<&str> = node
11272 .marks
11273 .as_ref()
11274 .unwrap()
11275 .iter()
11276 .map(|m| m.mark_type.as_str())
11277 .collect();
11278 assert_eq!(
11279 mark_types,
11280 vec!["em", "link"],
11281 "mark order should be preserved, got: {mark_types:?}"
11282 );
11283 }
11284
11285 #[test]
11286 fn mark_ordering_strike_link_preserved() {
11287 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11289 {"type":"text","text":"struck link","marks":[
11290 {"type":"strike"},
11291 {"type":"link","attrs":{"href":"https://example.com"}}
11292 ]}
11293 ]}]}"#;
11294 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11295 let md = adf_to_markdown(&doc).unwrap();
11296 let round_tripped = markdown_to_adf(&md).unwrap();
11297 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11298 let mark_types: Vec<&str> = node
11299 .marks
11300 .as_ref()
11301 .unwrap()
11302 .iter()
11303 .map(|m| m.mark_type.as_str())
11304 .collect();
11305 assert_eq!(
11306 mark_types,
11307 vec!["strike", "link"],
11308 "mark order should be preserved, got: {mark_types:?}"
11309 );
11310 }
11311
11312 #[test]
11313 fn mark_ordering_underline_link_preserved() {
11314 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11316 {"type":"text","text":"click here","marks":[
11317 {"type":"underline"},
11318 {"type":"link","attrs":{"href":"https://example.com"}}
11319 ]}
11320 ]}]}"#;
11321 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11322 let md = adf_to_markdown(&doc).unwrap();
11323 let round_tripped = markdown_to_adf(&md).unwrap();
11324 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11325 let mark_types: Vec<&str> = node
11326 .marks
11327 .as_ref()
11328 .unwrap()
11329 .iter()
11330 .map(|m| m.mark_type.as_str())
11331 .collect();
11332 assert_eq!(
11333 mark_types,
11334 vec!["underline", "link"],
11335 "mark order should be preserved, got: {mark_types:?}"
11336 );
11337 }
11338
11339 #[test]
11340 fn mark_ordering_textcolor_link_preserved() {
11341 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11343 {"type":"text","text":"red link","marks":[
11344 {"type":"textColor","attrs":{"color":"#ff0000"}},
11345 {"type":"link","attrs":{"href":"https://example.com"}}
11346 ]}
11347 ]}]}"##;
11348 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11349 let md = adf_to_markdown(&doc).unwrap();
11350 let round_tripped = markdown_to_adf(&md).unwrap();
11351 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11352 let mark_types: Vec<&str> = node
11353 .marks
11354 .as_ref()
11355 .unwrap()
11356 .iter()
11357 .map(|m| m.mark_type.as_str())
11358 .collect();
11359 assert_eq!(
11360 mark_types,
11361 vec!["textColor", "link"],
11362 "mark order should be preserved, got: {mark_types:?}"
11363 );
11364 }
11365
11366 #[test]
11367 fn mark_ordering_link_underline_preserved() {
11368 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11370 {"type":"text","text":"click here","marks":[
11371 {"type":"link","attrs":{"href":"https://example.com"}},
11372 {"type":"underline"}
11373 ]}
11374 ]}]}"#;
11375 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11376 let md = adf_to_markdown(&doc).unwrap();
11377 assert!(
11379 md.contains("](https://example.com)"),
11380 "should have link: {md}"
11381 );
11382 assert!(md.contains("underline"), "should have underline: {md}");
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!["link", "underline"],
11395 "mark order should be preserved, got: {mark_types:?}"
11396 );
11397 }
11398
11399 #[test]
11400 fn mark_ordering_underline_strong_link_preserved() {
11401 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11403 {"type":"text","text":"bold underlined link","marks":[
11404 {"type":"underline"},
11405 {"type":"strong"},
11406 {"type":"link","attrs":{"href":"https://example.com/page"}}
11407 ]}
11408 ]}]}"#;
11409 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11410 let md = adf_to_markdown(&doc).unwrap();
11411 let round_tripped = markdown_to_adf(&md).unwrap();
11412 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11413 let mark_types: Vec<&str> = node
11414 .marks
11415 .as_ref()
11416 .unwrap()
11417 .iter()
11418 .map(|m| m.mark_type.as_str())
11419 .collect();
11420 assert_eq!(
11421 mark_types,
11422 vec!["underline", "strong", "link"],
11423 "mark order should be preserved, got: {mark_types:?}"
11424 );
11425 }
11426
11427 #[test]
11428 fn mark_ordering_strong_underline_link_preserved() {
11429 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11431 {"type":"text","text":"bold underlined link","marks":[
11432 {"type":"strong"},
11433 {"type":"underline"},
11434 {"type":"link","attrs":{"href":"https://example.com/page"}}
11435 ]}
11436 ]}]}"#;
11437 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11438 let md = adf_to_markdown(&doc).unwrap();
11439 let round_tripped = markdown_to_adf(&md).unwrap();
11440 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11441 let mark_types: Vec<&str> = node
11442 .marks
11443 .as_ref()
11444 .unwrap()
11445 .iter()
11446 .map(|m| m.mark_type.as_str())
11447 .collect();
11448 assert_eq!(
11449 mark_types,
11450 vec!["strong", "underline", "link"],
11451 "mark order should be preserved, got: {mark_types:?}"
11452 );
11453 }
11454
11455 #[test]
11456 fn mark_ordering_underline_em_link_preserved() {
11457 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11459 {"type":"text","text":"italic underlined link","marks":[
11460 {"type":"underline"},
11461 {"type":"em"},
11462 {"type":"link","attrs":{"href":"https://example.com/page"}}
11463 ]}
11464 ]}]}"#;
11465 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11466 let md = adf_to_markdown(&doc).unwrap();
11467 let round_tripped = markdown_to_adf(&md).unwrap();
11468 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11469 let mark_types: Vec<&str> = node
11470 .marks
11471 .as_ref()
11472 .unwrap()
11473 .iter()
11474 .map(|m| m.mark_type.as_str())
11475 .collect();
11476 assert_eq!(
11477 mark_types,
11478 vec!["underline", "em", "link"],
11479 "mark order should be preserved, got: {mark_types:?}"
11480 );
11481 }
11482
11483 #[test]
11484 fn mark_ordering_underline_strike_link_preserved() {
11485 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11487 {"type":"text","text":"struck underlined link","marks":[
11488 {"type":"underline"},
11489 {"type":"strike"},
11490 {"type":"link","attrs":{"href":"https://example.com/page"}}
11491 ]}
11492 ]}]}"#;
11493 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11494 let md = adf_to_markdown(&doc).unwrap();
11495 let round_tripped = markdown_to_adf(&md).unwrap();
11496 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11497 let mark_types: Vec<&str> = node
11498 .marks
11499 .as_ref()
11500 .unwrap()
11501 .iter()
11502 .map(|m| m.mark_type.as_str())
11503 .collect();
11504 assert_eq!(
11505 mark_types,
11506 vec!["underline", "strike", "link"],
11507 "mark order should be preserved, got: {mark_types:?}"
11508 );
11509 }
11510
11511 #[test]
11512 fn mark_ordering_underline_strong_em_link_preserved() {
11513 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11515 {"type":"text","text":"all the marks","marks":[
11516 {"type":"underline"},
11517 {"type":"strong"},
11518 {"type":"em"},
11519 {"type":"link","attrs":{"href":"https://example.com/page"}}
11520 ]}
11521 ]}]}"#;
11522 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11523 let md = adf_to_markdown(&doc).unwrap();
11524 let round_tripped = markdown_to_adf(&md).unwrap();
11525 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11526 let mark_types: Vec<&str> = node
11527 .marks
11528 .as_ref()
11529 .unwrap()
11530 .iter()
11531 .map(|m| m.mark_type.as_str())
11532 .collect();
11533 assert_eq!(
11534 mark_types,
11535 vec!["underline", "strong", "em", "link"],
11536 "mark order should be preserved, got: {mark_types:?}"
11537 );
11538 }
11539
11540 #[test]
11541 fn em_strong_round_trip() {
11542 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11544 {"type":"text","text":"bold and italic","marks":[{"type":"strong"},{"type":"em"}]}
11545 ]}]}"#;
11546 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11547 let md = adf_to_markdown(&doc).unwrap();
11548 assert_eq!(md.trim(), "***bold and italic***");
11549 let round_tripped = markdown_to_adf(&md).unwrap();
11550 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11551 assert_eq!(node.text.as_deref(), Some("bold and italic"));
11552 let mark_types: Vec<&str> = node
11553 .marks
11554 .as_ref()
11555 .unwrap()
11556 .iter()
11557 .map(|m| m.mark_type.as_str())
11558 .collect();
11559 assert_eq!(
11560 mark_types,
11561 vec!["strong", "em"],
11562 "both strong and em marks should be preserved, got: {mark_types:?}"
11563 );
11564 }
11565
11566 #[test]
11567 fn em_strong_round_trip_em_first() {
11568 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11570 {"type":"text","text":"italic and bold","marks":[{"type":"em"},{"type":"strong"}]}
11571 ]}]}"#;
11572 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11573 let md = adf_to_markdown(&doc).unwrap();
11574 let round_tripped = markdown_to_adf(&md).unwrap();
11575 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11576 assert_eq!(node.text.as_deref(), Some("italic and bold"));
11577 let mark_types: Vec<&str> = node
11578 .marks
11579 .as_ref()
11580 .unwrap()
11581 .iter()
11582 .map(|m| m.mark_type.as_str())
11583 .collect();
11584 assert_eq!(
11585 mark_types,
11586 vec!["em", "strong"],
11587 "mark order [em, strong] should be preserved, got: {mark_types:?}"
11588 );
11589 }
11590
11591 fn assert_mark_order_round_trip(marks: Vec<AdfMark>, expected: &[&str]) {
11594 let doc = AdfDocument {
11595 version: 1,
11596 doc_type: "doc".to_string(),
11597 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11598 "text", marks,
11599 )])],
11600 };
11601 let md = adf_to_markdown(&doc).unwrap();
11602 let round_tripped = markdown_to_adf(&md).unwrap();
11603 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11604 let mark_types: Vec<&str> = node
11605 .marks
11606 .as_ref()
11607 .expect("round-tripped node should have marks")
11608 .iter()
11609 .map(|m| m.mark_type.as_str())
11610 .collect();
11611 assert_eq!(
11612 mark_types, expected,
11613 "marks should round-trip in order via {md:?}"
11614 );
11615 }
11616
11617 #[test]
11618 fn round_trip_em_strong_mark_order() {
11619 assert_mark_order_round_trip(vec![AdfMark::em(), AdfMark::strong()], &["em", "strong"]);
11621 assert_mark_order_round_trip(vec![AdfMark::strong(), AdfMark::em()], &["strong", "em"]);
11622 }
11623
11624 #[test]
11625 fn round_trip_strong_underline_mark_order() {
11626 assert_mark_order_round_trip(
11628 vec![AdfMark::strong(), AdfMark::underline()],
11629 &["strong", "underline"],
11630 );
11631 assert_mark_order_round_trip(
11632 vec![AdfMark::underline(), AdfMark::strong()],
11633 &["underline", "strong"],
11634 );
11635 }
11636
11637 #[test]
11638 fn round_trip_em_underline_mark_order() {
11639 assert_mark_order_round_trip(
11640 vec![AdfMark::em(), AdfMark::underline()],
11641 &["em", "underline"],
11642 );
11643 assert_mark_order_round_trip(
11644 vec![AdfMark::underline(), AdfMark::em()],
11645 &["underline", "em"],
11646 );
11647 }
11648
11649 #[test]
11650 fn round_trip_strike_strong_em_permutations() {
11651 assert_mark_order_round_trip(
11655 vec![AdfMark::strike(), AdfMark::strong(), AdfMark::em()],
11656 &["strike", "strong", "em"],
11657 );
11658 assert_mark_order_round_trip(
11659 vec![AdfMark::strike(), AdfMark::em(), AdfMark::strong()],
11660 &["strike", "em", "strong"],
11661 );
11662 assert_mark_order_round_trip(
11663 vec![AdfMark::strong(), AdfMark::strike(), AdfMark::em()],
11664 &["strong", "strike", "em"],
11665 );
11666 assert_mark_order_round_trip(
11667 vec![AdfMark::strong(), AdfMark::em(), AdfMark::strike()],
11668 &["strong", "em", "strike"],
11669 );
11670 assert_mark_order_round_trip(
11671 vec![AdfMark::em(), AdfMark::strike(), AdfMark::strong()],
11672 &["em", "strike", "strong"],
11673 );
11674 assert_mark_order_round_trip(
11675 vec![AdfMark::em(), AdfMark::strong(), AdfMark::strike()],
11676 &["em", "strong", "strike"],
11677 );
11678 }
11679
11680 #[test]
11681 fn round_trip_underline_nested_with_strong_em() {
11682 assert_mark_order_round_trip(
11685 vec![AdfMark::underline(), AdfMark::strong(), AdfMark::em()],
11686 &["underline", "strong", "em"],
11687 );
11688 assert_mark_order_round_trip(
11689 vec![AdfMark::strong(), AdfMark::underline(), AdfMark::em()],
11690 &["strong", "underline", "em"],
11691 );
11692 assert_mark_order_round_trip(
11693 vec![AdfMark::strong(), AdfMark::em(), AdfMark::underline()],
11694 &["strong", "em", "underline"],
11695 );
11696 }
11697
11698 #[test]
11699 fn round_trip_span_attr_order_preserved() {
11700 assert_mark_order_round_trip(
11704 vec![
11705 AdfMark::background_color("#ffff00"),
11706 AdfMark::text_color("#ff0000"),
11707 ],
11708 &["backgroundColor", "textColor"],
11709 );
11710 assert_mark_order_round_trip(
11711 vec![AdfMark::subsup("sub"), AdfMark::text_color("#ff0000")],
11712 &["subsup", "textColor"],
11713 );
11714 assert_mark_order_round_trip(
11715 vec![
11716 AdfMark::text_color("#ff0000"),
11717 AdfMark::background_color("#ffff00"),
11718 ],
11719 &["textColor", "backgroundColor"],
11720 );
11721 }
11722
11723 #[test]
11724 fn round_trip_annotation_before_underline() {
11725 assert_mark_order_round_trip(
11729 vec![
11730 AdfMark::annotation("ann-1", "inlineComment"),
11731 AdfMark::underline(),
11732 ],
11733 &["annotation", "underline"],
11734 );
11735 assert_mark_order_round_trip(
11736 vec![
11737 AdfMark::annotation("ann-1", "inlineComment"),
11738 AdfMark::underline(),
11739 AdfMark::annotation("ann-2", "inlineComment"),
11740 ],
11741 &["annotation", "underline", "annotation"],
11742 );
11743 }
11744
11745 #[test]
11746 fn round_trip_em_content_with_underscores() {
11747 let doc = AdfDocument {
11752 version: 1,
11753 doc_type: "doc".to_string(),
11754 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11755 "foo _bar_ baz",
11756 vec![AdfMark::em(), AdfMark::strong()],
11757 )])],
11758 };
11759 let md = adf_to_markdown(&doc).unwrap();
11760 let round_tripped = markdown_to_adf(&md).unwrap();
11761 let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11762 assert_eq!(node.text.as_deref(), Some("foo _bar_ baz"));
11763 let mark_types: Vec<&str> = node
11764 .marks
11765 .as_ref()
11766 .unwrap()
11767 .iter()
11768 .map(|m| m.mark_type.as_str())
11769 .collect();
11770 assert_eq!(mark_types, vec!["em", "strong"]);
11771 }
11772
11773 #[test]
11774 fn round_trip_link_nested_with_formatting_marks() {
11775 assert_mark_order_round_trip(
11778 vec![
11779 AdfMark::link("https://example.com"),
11780 AdfMark::strong(),
11781 AdfMark::em(),
11782 ],
11783 &["link", "strong", "em"],
11784 );
11785 assert_mark_order_round_trip(
11786 vec![
11787 AdfMark::em(),
11788 AdfMark::strong(),
11789 AdfMark::link("https://example.com"),
11790 ],
11791 &["em", "strong", "link"],
11792 );
11793 assert_mark_order_round_trip(
11794 vec![
11795 AdfMark::underline(),
11796 AdfMark::link("https://example.com"),
11797 AdfMark::strong(),
11798 ],
11799 &["underline", "link", "strong"],
11800 );
11801 }
11802
11803 fn bare_mark(mark_type: &str) -> AdfMark {
11807 AdfMark {
11808 mark_type: mark_type.to_string(),
11809 attrs: None,
11810 }
11811 }
11812
11813 #[test]
11814 fn collect_span_attr_handles_missing_attrs() {
11815 let mut attrs = Vec::new();
11820 collect_span_attr(&bare_mark("textColor"), &mut attrs);
11821 collect_span_attr(&bare_mark("backgroundColor"), &mut attrs);
11822 collect_span_attr(&bare_mark("subsup"), &mut attrs);
11823 collect_span_attr(&bare_mark("link"), &mut attrs);
11824 assert!(attrs.is_empty(), "got: {attrs:?}");
11825 }
11826
11827 #[test]
11828 fn collect_bracketed_attr_handles_missing_attrs() {
11829 let mut attrs = Vec::new();
11832 collect_bracketed_attr(&bare_mark("annotation"), &mut attrs);
11833 collect_bracketed_attr(&bare_mark("strong"), &mut attrs);
11834 assert!(attrs.is_empty(), "got: {attrs:?}");
11835 }
11836
11837 #[test]
11838 fn collect_bracketed_attr_handles_annotation_without_id() {
11839 let mark = AdfMark {
11843 mark_type: "annotation".to_string(),
11844 attrs: Some(serde_json::json!({})),
11845 };
11846 let mut attrs = Vec::new();
11847 collect_bracketed_attr(&mark, &mut attrs);
11848 assert!(attrs.is_empty(), "got: {attrs:?}");
11849 }
11850
11851 #[test]
11852 fn span_attr_order_rejects_unknown_types() {
11853 assert_eq!(span_attr_order("textColor"), 0);
11857 assert_eq!(span_attr_order("backgroundColor"), 1);
11858 assert_eq!(span_attr_order("subsup"), 2);
11859 assert_eq!(span_attr_order("strong"), u8::MAX);
11860 assert!(!span_run_is_canonical(&[bare_mark("strong")]));
11861 }
11862
11863 #[test]
11864 fn bracketed_run_rejects_unknown_types() {
11865 assert!(bracketed_run_is_canonical(&[
11869 AdfMark::underline(),
11870 AdfMark::annotation("x", "inlineComment")
11871 ]));
11872 assert!(!bracketed_run_is_canonical(&[
11873 AdfMark::annotation("x", "inlineComment"),
11874 AdfMark::underline()
11875 ]));
11876 assert!(!bracketed_run_is_canonical(&[bare_mark("strong")]));
11877 }
11878
11879 #[test]
11880 fn render_marked_text_ignores_unknown_mark_types() {
11881 let doc = AdfDocument {
11885 version: 1,
11886 doc_type: "doc".to_string(),
11887 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11888 "hello",
11889 vec![bare_mark("futureMark"), AdfMark::strong()],
11890 )])],
11891 };
11892 let md = adf_to_markdown(&doc).unwrap();
11893 assert_eq!(md.trim(), "**hello**");
11894 let rt = markdown_to_adf(&md).unwrap();
11895 let node = &rt.content[0].content.as_ref().unwrap()[0];
11896 assert_eq!(node.text.as_deref(), Some("hello"));
11897 let mark_types: Vec<&str> = node
11898 .marks
11899 .as_ref()
11900 .unwrap()
11901 .iter()
11902 .map(|m| m.mark_type.as_str())
11903 .collect();
11904 assert_eq!(mark_types, vec!["strong"]);
11905 }
11906
11907 #[test]
11908 fn triple_asterisk_parse_to_adf() {
11909 let md = "***bold and italic***\n";
11911 let doc = markdown_to_adf(md).unwrap();
11912 let node = &doc.content[0].content.as_ref().unwrap()[0];
11913 assert_eq!(node.text.as_deref(), Some("bold and italic"));
11914 let mark_types: Vec<&str> = node
11915 .marks
11916 .as_ref()
11917 .unwrap()
11918 .iter()
11919 .map(|m| m.mark_type.as_str())
11920 .collect();
11921 assert!(
11922 mark_types.contains(&"strong") && mark_types.contains(&"em"),
11923 "***text*** should produce both strong and em marks, got: {mark_types:?}"
11924 );
11925 }
11926
11927 #[test]
11928 fn triple_asterisk_with_surrounding_text() {
11929 let md = "before ***bold italic*** after\n";
11931 let doc = markdown_to_adf(md).unwrap();
11932 let nodes = doc.content[0].content.as_ref().unwrap();
11933 assert!(
11935 nodes.len() >= 3,
11936 "expected at least 3 nodes, got {}",
11937 nodes.len()
11938 );
11939 assert_eq!(nodes[0].text.as_deref(), Some("before "));
11940 assert_eq!(nodes[1].text.as_deref(), Some("bold italic"));
11941 let mark_types: Vec<&str> = nodes[1]
11942 .marks
11943 .as_ref()
11944 .unwrap()
11945 .iter()
11946 .map(|m| m.mark_type.as_str())
11947 .collect();
11948 assert!(
11949 mark_types.contains(&"strong") && mark_types.contains(&"em"),
11950 "middle node should have strong+em, got: {mark_types:?}"
11951 );
11952 assert_eq!(nodes[2].text.as_deref(), Some(" after"));
11953 }
11954
11955 #[test]
11956 fn annotation_mark_round_trip() {
11957 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11959 {"type":"text","text":"highlighted text","marks":[
11960 {"type":"annotation","attrs":{"id":"abc123","annotationType":"inlineComment"}}
11961 ]}
11962 ]}]}"#;
11963 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11964
11965 let md = adf_to_markdown(&doc).unwrap();
11966 assert!(
11967 md.contains("annotation-id="),
11968 "JFM should contain annotation-id, got: {md}"
11969 );
11970
11971 let round_tripped = markdown_to_adf(&md).unwrap();
11972 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11973 assert_eq!(text_node.text.as_deref(), Some("highlighted text"));
11974 let marks = text_node.marks.as_ref().expect("should have marks");
11975 let ann = marks
11976 .iter()
11977 .find(|m| m.mark_type == "annotation")
11978 .expect("should have annotation mark");
11979 let attrs = ann.attrs.as_ref().unwrap();
11980 assert_eq!(attrs["id"], "abc123");
11981 assert_eq!(attrs["annotationType"], "inlineComment");
11982 }
11983
11984 #[test]
11985 fn annotation_mark_with_bold() {
11986 let doc = AdfDocument {
11988 version: 1,
11989 doc_type: "doc".to_string(),
11990 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11991 "bold comment",
11992 vec![
11993 AdfMark::strong(),
11994 AdfMark::annotation("def456", "inlineComment"),
11995 ],
11996 )])],
11997 };
11998 let md = adf_to_markdown(&doc).unwrap();
11999 let round_tripped = markdown_to_adf(&md).unwrap();
12000 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12001 let marks = text_node.marks.as_ref().expect("should have marks");
12002 assert!(
12003 marks.iter().any(|m| m.mark_type == "strong"),
12004 "should have strong mark"
12005 );
12006 assert!(
12007 marks.iter().any(|m| m.mark_type == "annotation"),
12008 "should have annotation mark"
12009 );
12010 }
12011
12012 #[test]
12013 fn annotation_and_link_marks_both_preserved() {
12014 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12016 {"type":"text","text":"HANGUL-8","marks":[
12017 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"5ca7425e-34cd-48d3-b4eb-9873ac8b20e0"}},
12018 {"type":"link","attrs":{"href":"https://zd.atlassian.net/browse/HANG-8"}}
12019 ]}
12020 ]}]}"#;
12021 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12022 let md = adf_to_markdown(&doc).unwrap();
12023 assert!(
12025 md.contains("annotation-id="),
12026 "JFM should contain annotation-id, got: {md}"
12027 );
12028 assert!(
12029 md.contains("](https://"),
12030 "JFM should contain link href, got: {md}"
12031 );
12032 let round_tripped = markdown_to_adf(&md).unwrap();
12033 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12034 let marks = text_node.marks.as_ref().expect("should have marks");
12035 assert!(
12036 marks.iter().any(|m| m.mark_type == "annotation"),
12037 "should have annotation mark, got: {:?}",
12038 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12039 );
12040 assert!(
12041 marks.iter().any(|m| m.mark_type == "link"),
12042 "should have link mark, got: {:?}",
12043 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12044 );
12045 }
12046
12047 #[test]
12048 fn annotation_and_code_marks_both_preserved() {
12049 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12051 {"type":"text","text":"some text with "},
12052 {"type":"text","text":"annotated code","marks":[
12053 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"aabbccdd-1234-5678-abcd-000000000001"}},
12054 {"type":"code"}
12055 ]},
12056 {"type":"text","text":" remaining text"}
12057 ]}]}"#;
12058 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12059 let md = adf_to_markdown(&doc).unwrap();
12060 assert!(
12061 md.contains("annotation-id="),
12062 "JFM should contain annotation-id, got: {md}"
12063 );
12064 assert!(
12065 md.contains('`'),
12066 "JFM should contain backticks for code, got: {md}"
12067 );
12068
12069 let round_tripped = markdown_to_adf(&md).unwrap();
12070 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12071 let code_node = nodes
12073 .iter()
12074 .find(|n| n.text.as_deref() == Some("annotated code"))
12075 .expect("should have 'annotated code' text node");
12076 let marks = code_node.marks.as_ref().expect("should have marks");
12077 assert!(
12078 marks.iter().any(|m| m.mark_type == "annotation"),
12079 "should have annotation mark, got: {:?}",
12080 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12081 );
12082 assert!(
12083 marks.iter().any(|m| m.mark_type == "code"),
12084 "should have code mark, got: {:?}",
12085 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12086 );
12087 let ann = marks.iter().find(|m| m.mark_type == "annotation").unwrap();
12088 let attrs = ann.attrs.as_ref().unwrap();
12089 assert_eq!(attrs["id"], "aabbccdd-1234-5678-abcd-000000000001");
12090 assert_eq!(attrs["annotationType"], "inlineComment");
12091 }
12092
12093 #[test]
12094 fn annotation_and_code_and_link_marks_all_preserved() {
12095 let doc = AdfDocument {
12097 version: 1,
12098 doc_type: "doc".to_string(),
12099 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12100 "linked code",
12101 vec![
12102 AdfMark::annotation("ann-001", "inlineComment"),
12103 AdfMark::code(),
12104 AdfMark::link("https://example.com"),
12105 ],
12106 )])],
12107 };
12108 let md = adf_to_markdown(&doc).unwrap();
12109 assert!(
12110 md.contains("annotation-id="),
12111 "JFM should contain annotation-id, got: {md}"
12112 );
12113 assert!(md.contains('`'), "JFM should contain backticks, got: {md}");
12114 assert!(
12115 md.contains("](https://example.com)"),
12116 "JFM should contain link, got: {md}"
12117 );
12118
12119 let round_tripped = markdown_to_adf(&md).unwrap();
12120 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12121 let marks = text_node.marks.as_ref().expect("should have marks");
12122 assert!(
12123 marks.iter().any(|m| m.mark_type == "annotation"),
12124 "should have annotation mark, got: {:?}",
12125 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12126 );
12127 assert!(
12128 marks.iter().any(|m| m.mark_type == "code"),
12129 "should have code mark, got: {:?}",
12130 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12131 );
12132 assert!(
12133 marks.iter().any(|m| m.mark_type == "link"),
12134 "should have link mark, got: {:?}",
12135 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12136 );
12137 }
12138
12139 #[test]
12140 fn multiple_annotations_and_code_mark_preserved() {
12141 let doc = AdfDocument {
12143 version: 1,
12144 doc_type: "doc".to_string(),
12145 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12146 "doubly annotated",
12147 vec![
12148 AdfMark::annotation("ann-aaa", "inlineComment"),
12149 AdfMark::annotation("ann-bbb", "inlineComment"),
12150 AdfMark::code(),
12151 ],
12152 )])],
12153 };
12154 let md = adf_to_markdown(&doc).unwrap();
12155 assert!(
12156 md.contains("ann-aaa"),
12157 "JFM should contain first annotation id, got: {md}"
12158 );
12159 assert!(
12160 md.contains("ann-bbb"),
12161 "JFM should contain second annotation id, got: {md}"
12162 );
12163
12164 let round_tripped = markdown_to_adf(&md).unwrap();
12165 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12166 let marks = text_node.marks.as_ref().expect("should have marks");
12167 let ann_marks: Vec<_> = marks
12168 .iter()
12169 .filter(|m| m.mark_type == "annotation")
12170 .collect();
12171 assert_eq!(
12172 ann_marks.len(),
12173 2,
12174 "should have 2 annotation marks, got: {}",
12175 ann_marks.len()
12176 );
12177 assert!(
12178 marks.iter().any(|m| m.mark_type == "code"),
12179 "should have code mark"
12180 );
12181 }
12182
12183 #[test]
12184 fn underline_and_link_marks_both_preserved() {
12185 let doc = AdfDocument {
12187 version: 1,
12188 doc_type: "doc".to_string(),
12189 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12190 "click here",
12191 vec![AdfMark::underline(), AdfMark::link("https://example.com")],
12192 )])],
12193 };
12194 let md = adf_to_markdown(&doc).unwrap();
12195 assert!(md.contains("underline"), "should have underline attr: {md}");
12196 assert!(
12197 md.contains("](https://example.com)"),
12198 "should have link: {md}"
12199 );
12200 let round_tripped = markdown_to_adf(&md).unwrap();
12201 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12202 let marks = text_node.marks.as_ref().expect("should have marks");
12203 assert!(marks.iter().any(|m| m.mark_type == "underline"));
12204 assert!(marks.iter().any(|m| m.mark_type == "link"));
12205 }
12206
12207 #[test]
12208 fn annotation_link_and_bold_all_preserved() {
12209 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12211 {"type":"text","text":"important","marks":[
12212 {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"abc"}},
12213 {"type":"link","attrs":{"href":"https://example.com"}},
12214 {"type":"strong"}
12215 ]}
12216 ]}]}"#;
12217 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12218 let md = adf_to_markdown(&doc).unwrap();
12219 let round_tripped = markdown_to_adf(&md).unwrap();
12220 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12221 let marks = text_node.marks.as_ref().expect("should have marks");
12222 assert!(
12223 marks.iter().any(|m| m.mark_type == "annotation"),
12224 "should have annotation"
12225 );
12226 assert!(
12227 marks.iter().any(|m| m.mark_type == "link"),
12228 "should have link"
12229 );
12230 assert!(
12231 marks.iter().any(|m| m.mark_type == "strong"),
12232 "should have strong"
12233 );
12234 }
12235
12236 #[test]
12237 fn multiple_annotation_marks_round_trip() {
12238 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12240 {"type":"text","text":"some annotated text","marks":[
12241 {"type":"annotation","attrs":{"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","annotationType":"inlineComment"}},
12242 {"type":"annotation","attrs":{"id":"ffffffff-1111-2222-3333-444444444444","annotationType":"inlineComment"}}
12243 ]}
12244 ]}]}"#;
12245 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12246
12247 let md = adf_to_markdown(&doc).unwrap();
12248 assert!(
12249 md.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
12250 "JFM should contain first annotation id, got: {md}"
12251 );
12252 assert!(
12253 md.contains("ffffffff-1111-2222-3333-444444444444"),
12254 "JFM should contain second annotation id, got: {md}"
12255 );
12256
12257 let round_tripped = markdown_to_adf(&md).unwrap();
12258 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12259 assert_eq!(text_node.text.as_deref(), Some("some annotated text"));
12260 let marks = text_node.marks.as_ref().expect("should have marks");
12261 let annotations: Vec<_> = marks
12262 .iter()
12263 .filter(|m| m.mark_type == "annotation")
12264 .collect();
12265 assert_eq!(
12266 annotations.len(),
12267 2,
12268 "should have 2 annotation marks, got: {annotations:?}"
12269 );
12270 let ids: Vec<_> = annotations
12271 .iter()
12272 .map(|a| a.attrs.as_ref().unwrap()["id"].as_str().unwrap())
12273 .collect();
12274 assert!(ids.contains(&"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
12275 assert!(ids.contains(&"ffffffff-1111-2222-3333-444444444444"));
12276 }
12277
12278 #[test]
12279 fn three_annotation_marks_round_trip() {
12280 let doc = AdfDocument {
12282 version: 1,
12283 doc_type: "doc".to_string(),
12284 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12285 "triple annotated",
12286 vec![
12287 AdfMark::annotation("id-1", "inlineComment"),
12288 AdfMark::annotation("id-2", "inlineComment"),
12289 AdfMark::annotation("id-3", "inlineComment"),
12290 ],
12291 )])],
12292 };
12293 let md = adf_to_markdown(&doc).unwrap();
12294 let round_tripped = markdown_to_adf(&md).unwrap();
12295 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12296 let marks = text_node.marks.as_ref().expect("should have marks");
12297 let annotations: Vec<_> = marks
12298 .iter()
12299 .filter(|m| m.mark_type == "annotation")
12300 .collect();
12301 assert_eq!(
12302 annotations.len(),
12303 3,
12304 "should have 3 annotation marks, got: {annotations:?}"
12305 );
12306 }
12307
12308 #[test]
12309 fn multiple_annotations_with_bold_round_trip() {
12310 let doc = AdfDocument {
12312 version: 1,
12313 doc_type: "doc".to_string(),
12314 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12315 "bold double annotated",
12316 vec![
12317 AdfMark::strong(),
12318 AdfMark::annotation("ann-a", "inlineComment"),
12319 AdfMark::annotation("ann-b", "inlineComment"),
12320 ],
12321 )])],
12322 };
12323 let md = adf_to_markdown(&doc).unwrap();
12324 let round_tripped = markdown_to_adf(&md).unwrap();
12325 let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12326 let marks = text_node.marks.as_ref().expect("should have marks");
12327 assert!(
12328 marks.iter().any(|m| m.mark_type == "strong"),
12329 "should have strong mark"
12330 );
12331 let annotations: Vec<_> = marks
12332 .iter()
12333 .filter(|m| m.mark_type == "annotation")
12334 .collect();
12335 assert_eq!(
12336 annotations.len(),
12337 2,
12338 "should have 2 annotation marks, got: {annotations:?}"
12339 );
12340 }
12341
12342 #[test]
12343 fn multiple_annotations_with_link_round_trip() {
12344 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12346 {"type":"text","text":"linked text","marks":[
12347 {"type":"annotation","attrs":{"id":"ann-x","annotationType":"inlineComment"}},
12348 {"type":"annotation","attrs":{"id":"ann-y","annotationType":"inlineComment"}},
12349 {"type":"link","attrs":{"href":"https://example.com"}}
12350 ]}
12351 ]}]}"#;
12352 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
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 assert!(
12358 marks.iter().any(|m| m.mark_type == "link"),
12359 "should have link mark"
12360 );
12361 let annotations: Vec<_> = marks
12362 .iter()
12363 .filter(|m| m.mark_type == "annotation")
12364 .collect();
12365 assert_eq!(
12366 annotations.len(),
12367 2,
12368 "should have 2 annotation marks, got: {annotations:?}"
12369 );
12370 }
12371
12372 #[test]
12375 fn annotation_on_emoji_round_trip() {
12376 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12378 {"type":"emoji","attrs":{"id":"1f4dd","shortName":":memo:","text":"📝"},"marks":[
12379 {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12380 ]},
12381 {"type":"text","text":" annotated text","marks":[
12382 {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12383 ]}
12384 ]}]}"#;
12385 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12386 let md = adf_to_markdown(&doc).unwrap();
12387 assert!(
12388 md.contains("annotation-id="),
12389 "JFM should contain annotation-id for emoji, got: {md}"
12390 );
12391
12392 let round_tripped = markdown_to_adf(&md).unwrap();
12393 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12394
12395 let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12397 let emoji_marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12398 assert!(
12399 emoji_marks.iter().any(|m| m.mark_type == "annotation"),
12400 "emoji should have annotation mark, got: {emoji_marks:?}"
12401 );
12402 let ann = emoji_marks
12403 .iter()
12404 .find(|m| m.mark_type == "annotation")
12405 .unwrap();
12406 assert_eq!(
12407 ann.attrs.as_ref().unwrap()["id"],
12408 "ccddee11-2233-4455-aabb-ccddee112233"
12409 );
12410
12411 let text_node = nodes.iter().find(|n| n.node_type == "text").unwrap();
12413 let text_marks = text_node.marks.as_ref().expect("text should have marks");
12414 assert!(
12415 text_marks.iter().any(|m| m.mark_type == "annotation"),
12416 "text should have annotation mark"
12417 );
12418 }
12419
12420 #[test]
12421 fn annotation_on_status_round_trip() {
12422 let mut status = AdfNode::status("In Progress", "blue");
12423 status.marks = Some(vec![AdfMark::annotation("ann-status-1", "inlineComment")]);
12424
12425 let doc = AdfDocument {
12426 version: 1,
12427 doc_type: "doc".to_string(),
12428 content: vec![AdfNode::paragraph(vec![status])],
12429 };
12430 let md = adf_to_markdown(&doc).unwrap();
12431 assert!(
12432 md.contains("annotation-id="),
12433 "JFM should contain annotation-id for status, got: {md}"
12434 );
12435
12436 let round_tripped = markdown_to_adf(&md).unwrap();
12437 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12438 let status_node = nodes.iter().find(|n| n.node_type == "status").unwrap();
12439 let marks = status_node
12440 .marks
12441 .as_ref()
12442 .expect("status should have marks");
12443 assert!(
12444 marks.iter().any(|m| m.mark_type == "annotation"),
12445 "status should have annotation mark, got: {marks:?}"
12446 );
12447 }
12448
12449 #[test]
12450 fn annotation_on_date_round_trip() {
12451 let mut date = AdfNode::date("1704067200000");
12452 date.marks = Some(vec![AdfMark::annotation("ann-date-1", "inlineComment")]);
12453
12454 let doc = AdfDocument {
12455 version: 1,
12456 doc_type: "doc".to_string(),
12457 content: vec![AdfNode::paragraph(vec![date])],
12458 };
12459 let md = adf_to_markdown(&doc).unwrap();
12460 assert!(
12461 md.contains("annotation-id="),
12462 "JFM should contain annotation-id for date, got: {md}"
12463 );
12464
12465 let round_tripped = markdown_to_adf(&md).unwrap();
12466 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12467 let date_node = nodes.iter().find(|n| n.node_type == "date").unwrap();
12468 let marks = date_node.marks.as_ref().expect("date should have marks");
12469 assert!(
12470 marks.iter().any(|m| m.mark_type == "annotation"),
12471 "date should have annotation mark, got: {marks:?}"
12472 );
12473 }
12474
12475 #[test]
12476 fn annotation_on_mention_round_trip() {
12477 let mut mention = AdfNode::mention("user-123", "@Alice");
12478 mention.marks = Some(vec![AdfMark::annotation("ann-mention-1", "inlineComment")]);
12479
12480 let doc = AdfDocument {
12481 version: 1,
12482 doc_type: "doc".to_string(),
12483 content: vec![AdfNode::paragraph(vec![mention])],
12484 };
12485 let md = adf_to_markdown(&doc).unwrap();
12486 assert!(
12487 md.contains("annotation-id="),
12488 "JFM should contain annotation-id for mention, got: {md}"
12489 );
12490
12491 let round_tripped = markdown_to_adf(&md).unwrap();
12492 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12493 let mention_node = nodes.iter().find(|n| n.node_type == "mention").unwrap();
12494 let marks = mention_node
12495 .marks
12496 .as_ref()
12497 .expect("mention should have marks");
12498 assert!(
12499 marks.iter().any(|m| m.mark_type == "annotation"),
12500 "mention should have annotation mark, got: {marks:?}"
12501 );
12502 }
12503
12504 #[test]
12505 fn annotation_on_inline_card_round_trip() {
12506 let mut card = AdfNode::inline_card("https://example.com");
12507 card.marks = Some(vec![AdfMark::annotation("ann-card-1", "inlineComment")]);
12508
12509 let doc = AdfDocument {
12510 version: 1,
12511 doc_type: "doc".to_string(),
12512 content: vec![AdfNode::paragraph(vec![card])],
12513 };
12514 let md = adf_to_markdown(&doc).unwrap();
12515 assert!(
12516 md.contains("annotation-id="),
12517 "JFM should contain annotation-id for inlineCard, got: {md}"
12518 );
12519
12520 let round_tripped = markdown_to_adf(&md).unwrap();
12521 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12522 let card_node = nodes.iter().find(|n| n.node_type == "inlineCard").unwrap();
12523 let marks = card_node
12524 .marks
12525 .as_ref()
12526 .expect("inlineCard should have marks");
12527 assert!(
12528 marks.iter().any(|m| m.mark_type == "annotation"),
12529 "inlineCard should have annotation mark, got: {marks:?}"
12530 );
12531 }
12532
12533 #[test]
12534 fn annotation_on_placeholder_round_trip() {
12535 let mut placeholder = AdfNode::placeholder("Enter text here");
12536 placeholder.marks = Some(vec![AdfMark::annotation("ann-ph-1", "inlineComment")]);
12537
12538 let doc = AdfDocument {
12539 version: 1,
12540 doc_type: "doc".to_string(),
12541 content: vec![AdfNode::paragraph(vec![placeholder])],
12542 };
12543 let md = adf_to_markdown(&doc).unwrap();
12544 assert!(
12545 md.contains("annotation-id="),
12546 "JFM should contain annotation-id for placeholder, got: {md}"
12547 );
12548
12549 let round_tripped = markdown_to_adf(&md).unwrap();
12550 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12551 let ph_node = nodes.iter().find(|n| n.node_type == "placeholder").unwrap();
12552 let marks = ph_node
12553 .marks
12554 .as_ref()
12555 .expect("placeholder should have marks");
12556 assert!(
12557 marks.iter().any(|m| m.mark_type == "annotation"),
12558 "placeholder should have annotation mark, got: {marks:?}"
12559 );
12560 }
12561
12562 #[test]
12563 fn multiple_annotations_on_emoji_round_trip() {
12564 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12566 {"type":"emoji","attrs":{"shortName":":fire:","text":"🔥"},"marks":[
12567 {"type":"annotation","attrs":{"id":"ann-1","annotationType":"inlineComment"}},
12568 {"type":"annotation","attrs":{"id":"ann-2","annotationType":"inlineComment"}}
12569 ]}
12570 ]}]}"#;
12571 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12572 let md = adf_to_markdown(&doc).unwrap();
12573
12574 let round_tripped = markdown_to_adf(&md).unwrap();
12575 let nodes = round_tripped.content[0].content.as_ref().unwrap();
12576 let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12577 let marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12578 let annotations: Vec<_> = marks
12579 .iter()
12580 .filter(|m| m.mark_type == "annotation")
12581 .collect();
12582 assert_eq!(
12583 annotations.len(),
12584 2,
12585 "emoji should have 2 annotation marks, got: {annotations:?}"
12586 );
12587 }
12588
12589 #[test]
12590 fn emoji_without_annotation_unchanged() {
12591 let doc = AdfDocument {
12593 version: 1,
12594 doc_type: "doc".to_string(),
12595 content: vec![AdfNode::paragraph(vec![AdfNode::emoji(":fire:")])],
12596 };
12597 let md = adf_to_markdown(&doc).unwrap();
12598 assert!(
12600 !md.contains('['),
12601 "emoji without annotation should not be wrapped in brackets, got: {md}"
12602 );
12603 assert!(md.contains(":fire:"));
12604 }
12605
12606 #[test]
12609 fn status_directive() {
12610 let doc = markdown_to_adf("The ticket is :status[In Progress]{color=blue}.").unwrap();
12611 let content = doc.content[0].content.as_ref().unwrap();
12612 assert_eq!(content[1].node_type, "status");
12613 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "In Progress");
12614 assert_eq!(content[1].attrs.as_ref().unwrap()["color"], "blue");
12615 }
12616
12617 #[test]
12618 fn adf_status_to_markdown() {
12619 let doc = AdfDocument {
12620 version: 1,
12621 doc_type: "doc".to_string(),
12622 content: vec![AdfNode::paragraph(vec![AdfNode::status("Done", "green")])],
12623 };
12624 let md = adf_to_markdown(&doc).unwrap();
12625 assert!(md.contains(":status[Done]{color=green}"));
12626 }
12627
12628 #[test]
12629 fn round_trip_status() {
12630 let md = "The ticket is :status[In Progress]{color=blue}.\n";
12631 let doc = markdown_to_adf(md).unwrap();
12632 let result = adf_to_markdown(&doc).unwrap();
12633 assert!(result.contains(":status[In Progress]{color=blue}"));
12634 }
12635
12636 #[test]
12637 fn status_with_style_and_localid_roundtrips() {
12638 let adf = AdfDocument {
12639 version: 1,
12640 doc_type: "doc".to_string(),
12641 content: vec![AdfNode::paragraph(vec![{
12642 let mut node = AdfNode::status("open", "green");
12643 node.attrs.as_mut().unwrap()["style"] =
12644 serde_json::Value::String("bold".to_string());
12645 node.attrs.as_mut().unwrap()["localId"] =
12646 serde_json::Value::String("d2205ca5-84b9-4950-a730-bfe550fc146b".to_string());
12647 node
12648 }])],
12649 };
12650
12651 let md = adf_to_markdown(&adf).unwrap();
12652 assert!(
12653 md.contains("style=bold"),
12654 "Markdown should contain style attr: {md}"
12655 );
12656 assert!(
12657 md.contains("localId=d2205ca5"),
12658 "Markdown should contain localId attr: {md}"
12659 );
12660
12661 let rt = markdown_to_adf(&md).unwrap();
12662 let status = &rt.content[0].content.as_ref().unwrap()[0];
12663 let attrs = status.attrs.as_ref().unwrap();
12664 assert_eq!(attrs["text"], "open");
12665 assert_eq!(attrs["color"], "green");
12666 assert_eq!(attrs["style"], "bold");
12667 assert_eq!(
12668 attrs["localId"], "d2205ca5-84b9-4950-a730-bfe550fc146b",
12669 "localId should be preserved, got: {}",
12670 attrs["localId"]
12671 );
12672 }
12673
12674 #[test]
12675 fn status_without_style_still_works() {
12676 let md = ":status[Done]{color=green}\n";
12677 let doc = markdown_to_adf(md).unwrap();
12678 let status = &doc.content[0].content.as_ref().unwrap()[0];
12679 let attrs = status.attrs.as_ref().unwrap();
12680 assert_eq!(attrs["text"], "Done");
12681 assert_eq!(attrs["color"], "green");
12682 assert!(
12684 attrs.get("style").is_none() || attrs["style"].is_null(),
12685 "style should not be set when not provided"
12686 );
12687 }
12688
12689 #[test]
12690 fn strip_local_ids_removes_localid_from_status() {
12691 let adf = AdfDocument {
12692 version: 1,
12693 doc_type: "doc".to_string(),
12694 content: vec![AdfNode::paragraph(vec![{
12695 let mut node = AdfNode::status("open", "green");
12696 node.attrs.as_mut().unwrap()["localId"] =
12697 serde_json::Value::String("real-uuid-here".to_string());
12698 node
12699 }])],
12700 };
12701 let opts = RenderOptions {
12702 strip_local_ids: true,
12703 };
12704 let md = adf_to_markdown_with_options(&adf, &opts).unwrap();
12705 assert!(
12706 !md.contains("localId"),
12707 "localId should be stripped, got: {md}"
12708 );
12709 assert!(md.contains("color=green"), "color should be preserved");
12710 }
12711
12712 #[test]
12713 fn strip_local_ids_removes_localid_from_table() {
12714 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"}]}]}]}]}]}"#;
12715 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12716 let opts = RenderOptions {
12717 strip_local_ids: true,
12718 };
12719 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12720 assert!(
12721 !md.contains("localId"),
12722 "localId should be stripped from table, got: {md}"
12723 );
12724 assert!(md.contains("layout=default"), "layout should be preserved");
12725 }
12726
12727 #[test]
12728 fn default_options_preserve_localid() {
12729 let adf = AdfDocument {
12730 version: 1,
12731 doc_type: "doc".to_string(),
12732 content: vec![AdfNode::paragraph(vec![{
12733 let mut node = AdfNode::status("open", "green");
12734 node.attrs.as_mut().unwrap()["localId"] =
12735 serde_json::Value::String("real-uuid-here".to_string());
12736 node
12737 }])],
12738 };
12739 let md = adf_to_markdown(&adf).unwrap();
12740 assert!(
12741 md.contains("localId=real-uuid-here"),
12742 "Default should preserve localId, got: {md}"
12743 );
12744 }
12745
12746 #[test]
12747 fn mention_localid_roundtrip() {
12748 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12749 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12750 let md = adf_to_markdown(&doc).unwrap();
12751 assert!(
12752 md.contains("localId=m-001"),
12753 "mention should have localId in md: {md}"
12754 );
12755 let rt = markdown_to_adf(&md).unwrap();
12756 let mention = &rt.content[0].content.as_ref().unwrap()[0];
12757 assert_eq!(mention.attrs.as_ref().unwrap()["localId"], "m-001");
12758 }
12759
12760 #[test]
12761 fn date_localid_roundtrip() {
12762 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
12763 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12764 let md = adf_to_markdown(&doc).unwrap();
12765 assert!(
12766 md.contains("localId=d-001"),
12767 "date should have localId in md: {md}"
12768 );
12769 let rt = markdown_to_adf(&md).unwrap();
12770 let date = &rt.content[0].content.as_ref().unwrap()[0];
12771 assert_eq!(date.attrs.as_ref().unwrap()["localId"], "d-001");
12772 }
12773
12774 #[test]
12775 fn emoji_localid_roundtrip() {
12776 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"emoji","attrs":{"shortName":":smile:","localId":"e-001"}}]}]}"#;
12777 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12778 let md = adf_to_markdown(&doc).unwrap();
12779 assert!(
12780 md.contains("localId=e-001"),
12781 "emoji should have localId in md: {md}"
12782 );
12783 let rt = markdown_to_adf(&md).unwrap();
12784 let emoji = &rt.content[0].content.as_ref().unwrap()[0];
12785 assert_eq!(emoji.attrs.as_ref().unwrap()["localId"], "e-001");
12786 }
12787
12788 #[test]
12789 fn inline_card_localid_roundtrip() {
12790 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"inlineCard","attrs":{"url":"https://example.com","localId":"c-001"}}]}]}"#;
12791 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12792 let md = adf_to_markdown(&doc).unwrap();
12793 assert!(
12794 md.contains("localId=c-001"),
12795 "inlineCard should have localId in md: {md}"
12796 );
12797 let rt = markdown_to_adf(&md).unwrap();
12798 let card = &rt.content[0].content.as_ref().unwrap()[0];
12799 assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-001");
12800 }
12801
12802 #[test]
12803 fn strip_local_ids_removes_from_mention() {
12804 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12805 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12806 let opts = RenderOptions {
12807 strip_local_ids: true,
12808 };
12809 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12810 assert!(
12811 !md.contains("localId"),
12812 "localId should be stripped from mention: {md}"
12813 );
12814 assert!(md.contains("id=user123"), "other attrs should be preserved");
12815 }
12816
12817 #[test]
12818 fn strip_local_ids_removes_from_date() {
12819 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
12820 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12821 let opts = RenderOptions {
12822 strip_local_ids: true,
12823 };
12824 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12825 assert!(
12826 !md.contains("localId"),
12827 "localId should be stripped from date: {md}"
12828 );
12829 }
12830
12831 #[test]
12832 fn strip_local_ids_removes_from_block_attrs() {
12833 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"hello"}]}]}"#;
12834 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12835 let opts = RenderOptions {
12836 strip_local_ids: true,
12837 };
12838 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12839 assert!(
12840 !md.contains("localId"),
12841 "localId should be stripped from block attrs: {md}"
12842 );
12843 }
12844
12845 #[test]
12846 fn table_cell_localid_roundtrip() {
12847 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"}]}]}]}]}]}"#;
12848 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12849 let md = adf_to_markdown(&doc).unwrap();
12850 assert!(
12851 md.contains("localId=tc-001"),
12852 "tableCell should have localId in md: {md}"
12853 );
12854 let rt = markdown_to_adf(&md).unwrap();
12855 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12856 .content
12857 .as_ref()
12858 .unwrap()[0];
12859 assert_eq!(
12860 cell.attrs.as_ref().unwrap()["localId"],
12861 "tc-001",
12862 "tableCell localId should round-trip"
12863 );
12864 }
12865
12866 #[test]
12867 fn table_cell_border_mark_roundtrip() {
12868 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"}]}]}]}]}]}"##;
12869 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12870 let md = adf_to_markdown(&doc).unwrap();
12871 assert!(
12872 md.contains("border-color=#ff000033"),
12873 "tableCell should have border-color in md: {md}"
12874 );
12875 assert!(
12876 md.contains("border-size=2"),
12877 "tableCell should have border-size in md: {md}"
12878 );
12879 let rt = markdown_to_adf(&md).unwrap();
12880 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12881 .content
12882 .as_ref()
12883 .unwrap()[0];
12884 let marks = cell.marks.as_ref().expect("tableCell should have marks");
12885 assert_eq!(marks.len(), 1);
12886 assert_eq!(marks[0].mark_type, "border");
12887 let attrs = marks[0].attrs.as_ref().unwrap();
12888 assert_eq!(attrs["color"], "#ff000033");
12889 assert_eq!(attrs["size"], 2);
12890 }
12891
12892 #[test]
12893 fn table_header_border_mark_roundtrip() {
12894 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"}]}]}]}]}]}"##;
12895 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12896 let md = adf_to_markdown(&doc).unwrap();
12897 assert!(md.contains("border-color=#0000ff"), "md: {md}");
12898 assert!(md.contains("border-size=3"), "md: {md}");
12899 let rt = markdown_to_adf(&md).unwrap();
12900 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12901 .content
12902 .as_ref()
12903 .unwrap()[0];
12904 assert_eq!(cell.node_type, "tableHeader");
12905 let marks = cell.marks.as_ref().expect("tableHeader should have marks");
12906 assert_eq!(marks[0].mark_type, "border");
12907 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#0000ff");
12908 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12909 }
12910
12911 #[test]
12912 fn table_cell_border_mark_with_attrs_roundtrip() {
12913 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"}]}]}]}]}]}"##;
12914 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12915 let md = adf_to_markdown(&doc).unwrap();
12916 assert!(md.contains("bg=#e6fcff"), "md: {md}");
12917 assert!(md.contains("colspan=2"), "md: {md}");
12918 assert!(md.contains("border-color=#ff000033"), "md: {md}");
12919 let rt = markdown_to_adf(&md).unwrap();
12920 let cell = &rt.content[0].content.as_ref().unwrap()[0]
12921 .content
12922 .as_ref()
12923 .unwrap()[0];
12924 assert_eq!(cell.attrs.as_ref().unwrap()["background"], "#e6fcff");
12925 assert_eq!(cell.attrs.as_ref().unwrap()["colspan"], 2);
12926 let marks = cell.marks.as_ref().expect("should have marks");
12927 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff000033");
12928 }
12929
12930 #[test]
12931 fn table_cell_no_border_mark_unchanged() {
12932 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"}]}]}]}]}]}"#;
12933 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12934 let md = adf_to_markdown(&doc).unwrap();
12935 assert!(
12936 !md.contains("border-color"),
12937 "no border attrs expected: {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 assert!(cell.marks.is_none(), "no marks expected on plain cell");
12945 }
12946
12947 #[test]
12948 fn table_cell_border_size_only_defaults_color() {
12949 let md = "::::table\n:::tr\n:::td{border-size=3}\ncell\n:::\n:::\n::::\n";
12952 let doc = markdown_to_adf(md).unwrap();
12953 let cell = &doc.content[0].content.as_ref().unwrap()[0]
12954 .content
12955 .as_ref()
12956 .unwrap()[0];
12957 let marks = cell.marks.as_ref().expect("should have border mark");
12958 assert_eq!(marks[0].mark_type, "border");
12959 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
12960 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12961 }
12962
12963 #[test]
12964 fn table_cell_border_color_only_defaults_size() {
12965 let md = "::::table\n:::tr\n:::td{border-color=#ff0000}\ncell\n:::\n:::\n::::\n";
12967 let doc = markdown_to_adf(md).unwrap();
12968 let cell = &doc.content[0].content.as_ref().unwrap()[0]
12969 .content
12970 .as_ref()
12971 .unwrap()[0];
12972 let marks = cell.marks.as_ref().expect("should have border mark");
12973 assert_eq!(marks[0].mark_type, "border");
12974 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
12975 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
12976 }
12977
12978 #[test]
12979 fn media_file_border_mark_roundtrip() {
12980 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}}]}]}]}"##;
12981 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12982 let md = adf_to_markdown(&doc).unwrap();
12983 assert!(
12984 md.contains("border-color=#091e4224"),
12985 "media should have border-color in md: {md}"
12986 );
12987 assert!(
12988 md.contains("border-size=2"),
12989 "media should have border-size in md: {md}"
12990 );
12991 let rt = markdown_to_adf(&md).unwrap();
12992 let media_single = &rt.content[0];
12993 let media = &media_single.content.as_ref().unwrap()[0];
12994 assert_eq!(media.node_type, "media");
12995 let marks = media.marks.as_ref().expect("media should have marks");
12996 assert_eq!(marks.len(), 1);
12997 assert_eq!(marks[0].mark_type, "border");
12998 let attrs = marks[0].attrs.as_ref().unwrap();
12999 assert_eq!(attrs["color"], "#091e4224");
13000 assert_eq!(attrs["size"], 2);
13001 }
13002
13003 #[test]
13004 fn media_external_border_mark_roundtrip() {
13005 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}}]}]}]}"##;
13006 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13007 let md = adf_to_markdown(&doc).unwrap();
13008 assert!(
13009 md.contains("border-color=#ff0000"),
13010 "external media should have border-color in md: {md}"
13011 );
13012 assert!(
13013 md.contains("border-size=3"),
13014 "external media should have border-size in md: {md}"
13015 );
13016 let rt = markdown_to_adf(&md).unwrap();
13017 let media = &rt.content[0].content.as_ref().unwrap()[0];
13018 let marks = media.marks.as_ref().expect("media should have marks");
13019 assert_eq!(marks[0].mark_type, "border");
13020 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
13021 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
13022 }
13023
13024 #[test]
13025 fn media_file_no_border_mark_unchanged() {
13026 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}}]}]}"#;
13027 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13028 let md = adf_to_markdown(&doc).unwrap();
13029 assert!(
13030 !md.contains("border-color"),
13031 "no border attrs expected: {md}"
13032 );
13033 let rt = markdown_to_adf(&md).unwrap();
13034 let media = &rt.content[0].content.as_ref().unwrap()[0];
13035 assert!(media.marks.is_none(), "no marks expected on plain media");
13036 }
13037
13038 #[test]
13039 fn media_border_size_only_defaults_color() {
13040 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}}]}]}]}"#;
13041 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13042 let md = adf_to_markdown(&doc).unwrap();
13043 assert!(md.contains("border-size=4"), "md: {md}");
13044 let rt = markdown_to_adf(&md).unwrap();
13045 let media = &rt.content[0].content.as_ref().unwrap()[0];
13046 let marks = media.marks.as_ref().expect("should have border mark");
13047 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
13048 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 4);
13049 }
13050
13051 #[test]
13052 fn media_border_color_only_defaults_size() {
13053 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"}}]}]}]}"##;
13054 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13055 let md = adf_to_markdown(&doc).unwrap();
13056 assert!(md.contains("border-color=#00ff00"), "md: {md}");
13057 let rt = markdown_to_adf(&md).unwrap();
13058 let media = &rt.content[0].content.as_ref().unwrap()[0];
13059 let marks = media.marks.as_ref().expect("should have border mark");
13060 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#00ff00");
13061 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
13062 }
13063
13064 #[test]
13065 fn media_border_with_other_attrs_roundtrip() {
13066 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}}]}]}]}"##;
13067 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13068 let md = adf_to_markdown(&doc).unwrap();
13069 assert!(md.contains("layout=wide"), "md: {md}");
13070 assert!(md.contains("mediaWidth=600"), "md: {md}");
13071 assert!(md.contains("border-color=#091e4224"), "md: {md}");
13072 assert!(md.contains("border-size=2"), "md: {md}");
13073 let rt = markdown_to_adf(&md).unwrap();
13074 let ms = &rt.content[0];
13075 assert_eq!(ms.attrs.as_ref().unwrap()["layout"], "wide");
13076 let media = &ms.content.as_ref().unwrap()[0];
13077 let marks = media.marks.as_ref().expect("should have marks");
13078 assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#091e4224");
13079 assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 2);
13080 }
13081
13082 #[test]
13083 fn table_row_localid_roundtrip() {
13084 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"}]}]}]}]}]}"#;
13085 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13086 let md = adf_to_markdown(&doc).unwrap();
13087 assert!(
13088 md.contains("localId=tr-001"),
13089 "tableRow should have localId in md: {md}"
13090 );
13091 let rt = markdown_to_adf(&md).unwrap();
13092 let row = &rt.content[0].content.as_ref().unwrap()[0];
13093 assert_eq!(
13094 row.attrs.as_ref().unwrap()["localId"],
13095 "tr-001",
13096 "tableRow localId should round-trip"
13097 );
13098 }
13099
13100 #[test]
13101 fn list_item_localid_roundtrip() {
13102 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"}]}]}]}]}"#;
13104 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13105 let md = adf_to_markdown(&doc).unwrap();
13106 assert!(
13107 md.contains("localId=li-001"),
13108 "listItem should have localId in md: {md}"
13109 );
13110 let rt = markdown_to_adf(&md).unwrap();
13112 let list = &rt.content[0];
13113 assert!(
13114 list.attrs.is_none() || list.attrs.as_ref().unwrap().get("localId").is_none(),
13115 "bulletList should NOT have localId: {:?}",
13116 list.attrs
13117 );
13118 let item = &list.content.as_ref().unwrap()[0];
13119 assert_eq!(
13120 item.attrs.as_ref().unwrap()["localId"],
13121 "li-001",
13122 "listItem should have localId=li-001"
13123 );
13124 }
13125
13126 #[test]
13127 fn list_item_localid_not_promoted_to_parent() {
13128 let md = "- item {localId=li-002}\n";
13130 let doc = markdown_to_adf(md).unwrap();
13131 let list = &doc.content[0];
13132 assert!(
13133 list.attrs.is_none(),
13134 "bulletList should have no attrs: {:?}",
13135 list.attrs
13136 );
13137 let item = &list.content.as_ref().unwrap()[0];
13138 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "li-002");
13139 }
13140
13141 #[test]
13142 fn ordered_list_item_localid_roundtrip() {
13143 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"}]}]}]}]}"#;
13144 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13145 let md = adf_to_markdown(&doc).unwrap();
13146 assert!(md.contains("localId=oli-001"), "md: {md}");
13147 let rt = markdown_to_adf(&md).unwrap();
13148 let item = &rt.content[0].content.as_ref().unwrap()[0];
13149 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
13150 }
13151
13152 #[test]
13153 fn task_item_localid_roundtrip() {
13154 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"}]}]}]}]}"#;
13155 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13156 let md = adf_to_markdown(&doc).unwrap();
13157 assert!(md.contains("localId=ti-001"), "md: {md}");
13158 let rt = markdown_to_adf(&md).unwrap();
13159 let item = &rt.content[0].content.as_ref().unwrap()[0];
13160 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "ti-001");
13161 }
13162
13163 #[test]
13166 fn task_list_short_localid_roundtrip() {
13167 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"}]}]}]}"#;
13168 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13169 let md = adf_to_markdown(&doc).unwrap();
13170 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13172 assert!(md.contains("localId=99"), "localId=99 missing: {md}");
13173 assert!(
13175 !md.contains("localId=}"),
13176 "empty localId should not be emitted: {md}"
13177 );
13178 let rt = markdown_to_adf(&md).unwrap();
13179 let task_list = &rt.content[0];
13180 assert_eq!(task_list.node_type, "taskList");
13181 assert_eq!(rt.content.len(), 1, "should be exactly one top-level node");
13183 let items = task_list.content.as_ref().unwrap();
13184 assert_eq!(items.len(), 2);
13185 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13187 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
13188 assert!(
13189 items[0].content.is_none(),
13190 "empty taskItem should have no content: {:?}",
13191 items[0].content
13192 );
13193 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "99");
13195 assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
13196 let content = items[1].content.as_ref().unwrap();
13197 assert_eq!(content.len(), 1);
13198 assert_eq!(content[0].text.as_deref(), Some("done task"));
13199 }
13200
13201 #[test]
13205 fn task_item_numeric_localid_with_hardbreak_roundtrip() {
13206 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!!)"}]}]}]}]}"#;
13207 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13208 let md = adf_to_markdown(&doc).unwrap();
13209 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13211 let rt = markdown_to_adf(&md).unwrap();
13213 assert_eq!(rt.content.len(), 1, "exactly one top-level node");
13214 let task_list = &rt.content[0];
13215 assert_eq!(task_list.node_type, "taskList");
13216 let items = task_list.content.as_ref().unwrap();
13217 assert_eq!(items.len(), 1);
13218 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13220 assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
13221 let para = &items[0].content.as_ref().unwrap()[0];
13223 assert_eq!(para.node_type, "paragraph");
13224 let inlines = para.content.as_ref().unwrap();
13225 assert_eq!(inlines[0].node_type, "text");
13226 assert_eq!(
13227 inlines[0].text.as_deref(),
13228 Some("Engineering Onboarding Link")
13229 );
13230 assert_eq!(inlines[1].node_type, "hardBreak");
13231 assert_eq!(inlines[2].node_type, "text");
13232 assert_eq!(
13233 inlines[2].text.as_deref(),
13234 Some("(This has links to all the various useful tools!!)")
13235 );
13236 let rt_json = serde_json::to_string(&rt).unwrap();
13238 assert!(
13239 !rt_json.contains("{localId="),
13240 "localId attr syntax should not leak into ADF text: {rt_json}"
13241 );
13242 }
13243
13244 #[test]
13246 fn task_item_multiple_hardbreak_localids_roundtrip() {
13247 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"}]}]}]}]}"#;
13248 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13249 let md = adf_to_markdown(&doc).unwrap();
13250 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13251 assert!(md.contains("localId=67"), "localId=67 missing: {md}");
13252 let rt = markdown_to_adf(&md).unwrap();
13253 let items = rt.content[0].content.as_ref().unwrap();
13254 assert_eq!(items.len(), 2);
13255 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13256 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "67");
13257 for item in items {
13259 let para = &item.content.as_ref().unwrap()[0];
13260 assert_eq!(para.node_type, "paragraph");
13261 let inlines = para.content.as_ref().unwrap();
13262 assert_eq!(inlines[1].node_type, "hardBreak");
13263 }
13264 }
13265
13266 #[test]
13271 fn task_item_sibling_localid_hardbreak_unwrapped_roundtrip() {
13272 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"}]}]}]}"#;
13273 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13274 let md = adf_to_markdown(&doc).unwrap();
13275 assert!(
13277 md.contains(" (parenthetical"),
13278 "continuation line should be 2-space indented: {md}"
13279 );
13280 assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13281 assert!(md.contains("localId=69"), "localId=69 missing: {md}");
13282 let rt = markdown_to_adf(&md).unwrap();
13283 assert_eq!(
13285 rt.content.len(),
13286 1,
13287 "should be one taskList: {:#?}",
13288 rt.content
13289 );
13290 assert_eq!(rt.content[0].node_type, "taskList");
13291 let items = rt.content[0].content.as_ref().unwrap();
13292 assert_eq!(items.len(), 2, "should have 2 taskItems");
13293 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13294 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13295 let first_content = items[0].content.as_ref().unwrap();
13297 assert!(
13298 first_content.iter().any(|n| n.node_type == "hardBreak"),
13299 "first item should contain hardBreak"
13300 );
13301 let second_content = items[1].content.as_ref().unwrap();
13303 assert_eq!(second_content[0].node_type, "text");
13304 assert_eq!(
13305 second_content[0].text.as_deref().unwrap(),
13306 "second task item"
13307 );
13308 }
13309
13310 #[test]
13313 fn task_item_sibling_localid_hardbreak_paragraph_roundtrip() {
13314 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"}]}]}]}]}"#;
13315 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13316 let md = adf_to_markdown(&doc).unwrap();
13317 let rt = markdown_to_adf(&md).unwrap();
13318 assert_eq!(
13319 rt.content.len(),
13320 1,
13321 "should be one taskList: {:#?}",
13322 rt.content
13323 );
13324 let items = rt.content[0].content.as_ref().unwrap();
13325 assert_eq!(items.len(), 2);
13326 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13327 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13328 }
13329
13330 #[test]
13333 fn task_item_three_siblings_middle_hardbreak_roundtrip() {
13334 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"}]}]}]}"#;
13335 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13336 let md = adf_to_markdown(&doc).unwrap();
13337 let rt = markdown_to_adf(&md).unwrap();
13338 assert_eq!(rt.content.len(), 1);
13339 let items = rt.content[0].content.as_ref().unwrap();
13340 assert_eq!(items.len(), 3);
13341 assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "10");
13342 assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "20");
13343 assert_eq!(items[2].attrs.as_ref().unwrap()["localId"], "30");
13344 let mid_content = items[1].content.as_ref().unwrap();
13346 assert!(mid_content.iter().any(|n| n.node_type == "hardBreak"));
13347 }
13348
13349 #[test]
13352 fn task_list_empty_localid_no_spurious_paragraph() {
13353 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"}]}]}]}"#;
13354 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13355 let md = adf_to_markdown(&doc).unwrap();
13356 assert!(
13357 !md.contains("{localId=}"),
13358 "empty localId should not be emitted: {md}"
13359 );
13360 let rt = markdown_to_adf(&md).unwrap();
13361 assert_eq!(
13362 rt.content.len(),
13363 1,
13364 "no spurious paragraph: {:#?}",
13365 rt.content
13366 );
13367 assert_eq!(rt.content[0].node_type, "taskList");
13368 }
13369
13370 #[test]
13372 fn task_list_localid_stripped() {
13373 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"}]}]}]}]}"#;
13374 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13375 let opts = RenderOptions {
13376 strip_local_ids: true,
13377 };
13378 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13379 assert!(!md.contains("localId"), "localId should be stripped: {md}");
13380 }
13381
13382 #[test]
13384 fn task_item_no_content_emits_localid() {
13385 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"}}]}]}"#;
13386 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13387 let md = adf_to_markdown(&doc).unwrap();
13388 assert!(
13389 md.contains("localId=abc"),
13390 "localId should be emitted even without content: {md}"
13391 );
13392 let rt = markdown_to_adf(&md).unwrap();
13393 let item = &rt.content[0].content.as_ref().unwrap()[0];
13394 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "abc");
13395 assert!(item.content.is_none(), "should have no content");
13396 }
13397
13398 #[test]
13400 fn task_list_localid_roundtrip() {
13401 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"}]}]}]}]}"#;
13402 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13403 let md = adf_to_markdown(&doc).unwrap();
13404 assert!(
13405 md.contains("localId=tl-xyz"),
13406 "taskList localId missing: {md}"
13407 );
13408 let rt = markdown_to_adf(&md).unwrap();
13409 assert_eq!(
13410 rt.content[0].attrs.as_ref().unwrap()["localId"],
13411 "tl-xyz",
13412 "taskList localId should survive round-trip"
13413 );
13414 }
13415
13416 #[test]
13418 fn task_item_paragraph_wrapper_roundtrip_no_localid() {
13419 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"}]}]}]}]}"#;
13420 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13421 let md = adf_to_markdown(&doc).unwrap();
13422 assert!(
13423 md.contains("paraLocalId=_"),
13424 "should emit paraLocalId=_ sentinel: {md}"
13425 );
13426 let rt = markdown_to_adf(&md).unwrap();
13427 let item = &rt.content[0].content.as_ref().unwrap()[0];
13428 let content = item.content.as_ref().unwrap();
13429 assert_eq!(content.len(), 1, "should have one child: {content:#?}");
13430 assert_eq!(
13431 content[0].node_type, "paragraph",
13432 "child should be a paragraph: {content:#?}"
13433 );
13434 let para_content = content[0].content.as_ref().unwrap();
13435 assert_eq!(
13436 para_content[0].text.as_deref(),
13437 Some("A task with paragraph wrapper")
13438 );
13439 assert!(
13441 content[0].attrs.is_none(),
13442 "paragraph should have no attrs: {:?}",
13443 content[0].attrs
13444 );
13445 }
13446
13447 #[test]
13449 fn task_item_paragraph_wrapper_roundtrip_with_localid() {
13450 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"}]}]}]}]}"#;
13451 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13452 let md = adf_to_markdown(&doc).unwrap();
13453 assert!(
13454 md.contains("paraLocalId=p-001"),
13455 "should emit paraLocalId=p-001: {md}"
13456 );
13457 let rt = markdown_to_adf(&md).unwrap();
13458 let item = &rt.content[0].content.as_ref().unwrap()[0];
13459 let content = item.content.as_ref().unwrap();
13460 assert_eq!(content[0].node_type, "paragraph");
13461 assert_eq!(
13462 content[0].attrs.as_ref().unwrap()["localId"],
13463 "p-001",
13464 "paragraph localId should be preserved"
13465 );
13466 }
13467
13468 #[test]
13470 fn task_item_unwrapped_inline_no_paragraph_on_roundtrip() {
13471 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"}]}]}]}"#;
13472 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13473 let md = adf_to_markdown(&doc).unwrap();
13474 assert!(
13475 !md.contains("paraLocalId"),
13476 "should NOT emit paraLocalId for unwrapped inline: {md}"
13477 );
13478 let rt = markdown_to_adf(&md).unwrap();
13479 let item = &rt.content[0].content.as_ref().unwrap()[0];
13480 let content = item.content.as_ref().unwrap();
13481 assert_eq!(
13482 content[0].node_type, "text",
13483 "should remain unwrapped: {content:#?}"
13484 );
13485 }
13486
13487 #[test]
13489 fn task_item_done_paragraph_wrapper_roundtrip() {
13490 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"}]}]}]}]}"#;
13491 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13492 let md = adf_to_markdown(&doc).unwrap();
13493 assert!(md.contains("- [x]"), "should render as done: {md}");
13494 let rt = markdown_to_adf(&md).unwrap();
13495 let item = &rt.content[0].content.as_ref().unwrap()[0];
13496 assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
13497 let content = item.content.as_ref().unwrap();
13498 assert_eq!(content[0].node_type, "paragraph");
13499 }
13500
13501 #[test]
13503 fn task_item_mixed_paragraph_and_unwrapped_roundtrip() {
13504 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"}]}]}]}"#;
13505 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13506 let md = adf_to_markdown(&doc).unwrap();
13507 let rt = markdown_to_adf(&md).unwrap();
13508 let items = rt.content[0].content.as_ref().unwrap();
13509 assert_eq!(items.len(), 2);
13510 let c1 = items[0].content.as_ref().unwrap();
13512 assert_eq!(
13513 c1[0].node_type, "paragraph",
13514 "first item should have paragraph wrapper"
13515 );
13516 let c2 = items[1].content.as_ref().unwrap();
13518 assert_eq!(
13519 c2[0].node_type, "text",
13520 "second item should remain unwrapped"
13521 );
13522 }
13523
13524 #[test]
13526 fn task_item_paragraph_wrapper_with_marks_roundtrip() {
13527 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"}]}]}]}]}]}"#;
13528 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13529 let md = adf_to_markdown(&doc).unwrap();
13530 let rt = markdown_to_adf(&md).unwrap();
13531 let item = &rt.content[0].content.as_ref().unwrap()[0];
13532 let content = item.content.as_ref().unwrap();
13533 assert_eq!(content[0].node_type, "paragraph");
13534 let para_children = content[0].content.as_ref().unwrap();
13535 assert!(
13536 para_children.len() >= 2,
13537 "paragraph should contain multiple inline nodes"
13538 );
13539 }
13540
13541 #[test]
13543 fn task_item_paragraph_wrapper_stripped_with_option() {
13544 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"}]}]}]}]}"#;
13545 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13546 let opts = RenderOptions {
13547 strip_local_ids: true,
13548 };
13549 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13550 assert!(
13551 !md.contains("paraLocalId"),
13552 "paraLocalId should be stripped: {md}"
13553 );
13554 assert!(
13555 !md.contains("localId"),
13556 "all localIds should be stripped: {md}"
13557 );
13558 }
13559
13560 #[test]
13561 fn trailing_space_preserved_with_hex_localid() {
13562 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 "}]}]}]}]}"#;
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 item = &rt.content[0].content.as_ref().unwrap()[0];
13569 assert_eq!(
13570 item.attrs.as_ref().unwrap()["localId"],
13571 "aabb112233cc",
13572 "localId should round-trip"
13573 );
13574 let para = &item.content.as_ref().unwrap()[0];
13575 let inlines = para.content.as_ref().unwrap();
13576 let last = inlines.last().unwrap();
13577 assert!(
13578 last.text.as_deref().unwrap_or("").ends_with(' '),
13579 "trailing space should be preserved, got nodes: {:?}",
13580 inlines
13581 .iter()
13582 .map(|n| (&n.node_type, &n.text))
13583 .collect::<Vec<_>>()
13584 );
13585 }
13586
13587 #[test]
13588 fn extract_trailing_local_id_preserves_trailing_space() {
13589 let (before, lid, _) = extract_trailing_local_id("trailing space {localId=aabb112233cc}");
13591 assert_eq!(before, "trailing space ");
13592 assert_eq!(lid.as_deref(), Some("aabb112233cc"));
13593 }
13594
13595 #[test]
13596 fn extract_trailing_local_id_no_trailing_space() {
13597 let (before, lid, _) = extract_trailing_local_id("text {localId=abc123}");
13598 assert_eq!(before, "text");
13599 assert_eq!(lid.as_deref(), Some("abc123"));
13600 }
13601
13602 #[test]
13603 fn extract_trailing_local_id_no_attrs() {
13604 let (before, lid, pid) = extract_trailing_local_id("plain text");
13605 assert_eq!(before, "plain text");
13606 assert!(lid.is_none());
13607 assert!(pid.is_none());
13608 }
13609
13610 #[test]
13611 fn list_item_localid_stripped() {
13612 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"}]}]}]}]}"#;
13613 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13614 let opts = RenderOptions {
13615 strip_local_ids: true,
13616 };
13617 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13618 assert!(!md.contains("localId"), "localId should be stripped: {md}");
13619 }
13620
13621 #[test]
13622 fn paragraph_localid_in_list_item_roundtrip() {
13623 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"}]}]}]}]}"#;
13625 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13626 let md = adf_to_markdown(&doc).unwrap();
13627 assert!(
13628 md.contains("paraLocalId=para-001"),
13629 "paragraph localId should be in md: {md}"
13630 );
13631 let rt = markdown_to_adf(&md).unwrap();
13632 let item = &rt.content[0].content.as_ref().unwrap()[0];
13633 assert_eq!(
13634 item.attrs.as_ref().unwrap()["localId"],
13635 "item-001",
13636 "listItem localId should survive"
13637 );
13638 let para = &item.content.as_ref().unwrap()[0];
13639 assert_eq!(
13640 para.attrs.as_ref().unwrap()["localId"],
13641 "para-001",
13642 "paragraph localId should survive round-trip"
13643 );
13644 }
13645
13646 #[test]
13647 fn paragraph_localid_in_ordered_list_item_roundtrip() {
13648 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"}]}]}]}]}"#;
13650 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13651 let md = adf_to_markdown(&doc).unwrap();
13652 assert!(md.contains("paraLocalId=op-001"), "md: {md}");
13653 let rt = markdown_to_adf(&md).unwrap();
13654 let item = &rt.content[0].content.as_ref().unwrap()[0];
13655 assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
13656 let para = &item.content.as_ref().unwrap()[0];
13657 assert_eq!(para.attrs.as_ref().unwrap()["localId"], "op-001");
13658 }
13659
13660 #[test]
13661 fn paragraph_localid_only_in_list_item() {
13662 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"}]}]}]}]}"#;
13664 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13665 let md = adf_to_markdown(&doc).unwrap();
13666 assert!(
13667 md.contains("paraLocalId=para-only"),
13668 "paragraph localId should be emitted: {md}"
13669 );
13670 let rt = markdown_to_adf(&md).unwrap();
13671 let item = &rt.content[0].content.as_ref().unwrap()[0];
13672 assert!(item.attrs.is_none(), "listItem should have no attrs");
13673 let para = &item.content.as_ref().unwrap()[0];
13674 assert_eq!(para.attrs.as_ref().unwrap()["localId"], "para-only");
13675 }
13676
13677 #[test]
13678 fn paragraph_localid_in_table_header_roundtrip() {
13679 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"}]}]}]}]}]}"#;
13681 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13682 let md = adf_to_markdown(&doc).unwrap();
13683 assert!(
13685 md.contains("localId=aaaa-aaaa"),
13686 "paragraph localId should be in md: {md}"
13687 );
13688 let rt = markdown_to_adf(&md).unwrap();
13689 let cell = &rt.content[0].content.as_ref().unwrap()[0]
13690 .content
13691 .as_ref()
13692 .unwrap()[0];
13693 let para = &cell.content.as_ref().unwrap()[0];
13694 assert_eq!(
13695 para.attrs.as_ref().unwrap()["localId"],
13696 "aaaa-aaaa",
13697 "paragraph localId should survive round-trip in tableHeader"
13698 );
13699 }
13700
13701 #[test]
13702 fn paragraph_localid_in_table_cell_roundtrip() {
13703 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"}]}]}]}]}]}"#;
13705 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13706 let md = adf_to_markdown(&doc).unwrap();
13707 assert!(
13708 md.contains("localId=cell-para"),
13709 "paragraph localId should be in md: {md}"
13710 );
13711 let rt = markdown_to_adf(&md).unwrap();
13712 let cell = &rt.content[0].content.as_ref().unwrap()[1]
13714 .content
13715 .as_ref()
13716 .unwrap()[0];
13717 let para = &cell.content.as_ref().unwrap()[0];
13718 assert_eq!(
13719 para.attrs.as_ref().unwrap()["localId"],
13720 "cell-para",
13721 "paragraph localId should survive round-trip in tableCell"
13722 );
13723 }
13724
13725 #[test]
13726 fn nbsp_paragraph_with_localid_roundtrip() {
13727 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"nbsp-para"},"content":[{"type":"text","text":"\u00a0"}]}]}"#;
13729 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13730 let md = adf_to_markdown(&doc).unwrap();
13731 assert!(
13732 md.contains("::paragraph["),
13733 "nbsp should use directive form: {md}"
13734 );
13735 assert!(
13736 md.contains("localId=nbsp-para"),
13737 "localId should be in directive: {md}"
13738 );
13739 let rt = markdown_to_adf(&md).unwrap();
13740 let para = &rt.content[0];
13741 assert_eq!(
13742 para.attrs.as_ref().unwrap()["localId"],
13743 "nbsp-para",
13744 "localId should survive round-trip"
13745 );
13746 let text = para.content.as_ref().unwrap()[0].text.as_ref().unwrap();
13747 assert_eq!(text, "\u{00a0}", "nbsp should survive");
13748 }
13749
13750 #[test]
13751 fn empty_paragraph_with_localid_roundtrip() {
13752 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"empty-para"}}]}"#;
13754 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13755 let md = adf_to_markdown(&doc).unwrap();
13756 assert!(
13757 md.contains("::paragraph{localId=empty-para}"),
13758 "empty paragraph should include localId in directive: {md}"
13759 );
13760 let rt = markdown_to_adf(&md).unwrap();
13761 assert_eq!(
13762 rt.content[0].attrs.as_ref().unwrap()["localId"],
13763 "empty-para"
13764 );
13765 }
13766
13767 #[test]
13768 fn paragraph_localid_stripped_from_list_item() {
13769 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"}]}]}]}]}"#;
13771 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13772 let opts = RenderOptions {
13773 strip_local_ids: true,
13774 };
13775 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13776 assert!(!md.contains("localId"), "localId should be stripped: {md}");
13777 assert!(
13778 !md.contains("paraLocalId"),
13779 "paraLocalId should be stripped: {md}"
13780 );
13781 }
13782
13783 #[test]
13784 fn date_directive() {
13785 let doc = markdown_to_adf("Due by :date[2026-04-15].").unwrap();
13786 let content = doc.content[0].content.as_ref().unwrap();
13787 assert_eq!(content[1].node_type, "date");
13788 assert_eq!(
13790 content[1].attrs.as_ref().unwrap()["timestamp"],
13791 "1776211200000"
13792 );
13793 }
13794
13795 #[test]
13796 fn adf_date_to_markdown() {
13797 let doc = AdfDocument {
13799 version: 1,
13800 doc_type: "doc".to_string(),
13801 content: vec![AdfNode::paragraph(vec![AdfNode::date("1776211200000")])],
13802 };
13803 let md = adf_to_markdown(&doc).unwrap();
13804 assert!(md.contains(":date[2026-04-15]{timestamp=1776211200000}"));
13805 }
13806
13807 #[test]
13808 fn adf_date_iso_passthrough() {
13809 let doc = AdfDocument {
13811 version: 1,
13812 doc_type: "doc".to_string(),
13813 content: vec![AdfNode::paragraph(vec![AdfNode::date("2026-04-15")])],
13814 };
13815 let md = adf_to_markdown(&doc).unwrap();
13816 assert!(md.contains(":date[2026-04-15]{timestamp=2026-04-15}"));
13817 }
13818
13819 #[test]
13820 fn round_trip_date() {
13821 let md = "Due by :date[2026-04-15].\n";
13822 let doc = markdown_to_adf(md).unwrap();
13823 let result = adf_to_markdown(&doc).unwrap();
13824 assert!(result.contains(":date[2026-04-15]"));
13825 }
13826
13827 #[test]
13828 fn round_trip_date_non_midnight_timestamp() {
13829 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000"}}]}]}"#;
13831 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13832 let md = adf_to_markdown(&doc).unwrap();
13833 assert!(
13835 md.contains("timestamp=1700000000000"),
13836 "JFM should preserve original timestamp: {md}"
13837 );
13838 let doc2 = markdown_to_adf(&md).unwrap();
13840 let content = doc2.content[0].content.as_ref().unwrap();
13841 assert_eq!(
13842 content[0].attrs.as_ref().unwrap()["timestamp"],
13843 "1700000000000",
13844 "Round-trip must preserve original non-midnight timestamp"
13845 );
13846 }
13847
13848 #[test]
13849 fn date_epoch_ms_passthrough() {
13850 let doc = markdown_to_adf("Due by :date[1776211200000].").unwrap();
13852 let content = doc.content[0].content.as_ref().unwrap();
13853 assert_eq!(
13854 content[1].attrs.as_ref().unwrap()["timestamp"],
13855 "1776211200000"
13856 );
13857 }
13858
13859 #[test]
13860 fn date_timestamp_attr_preferred_over_content() {
13861 let md = ":date[2023-11-14]{timestamp=1700000000000}\n";
13863 let doc = markdown_to_adf(md).unwrap();
13864 let content = doc.content[0].content.as_ref().unwrap();
13865 assert_eq!(
13866 content[0].attrs.as_ref().unwrap()["timestamp"],
13867 "1700000000000",
13868 "timestamp attr should be used directly"
13869 );
13870 }
13871
13872 #[test]
13873 fn date_without_timestamp_attr_backward_compat() {
13874 let md = ":date[2026-04-15]\n";
13876 let doc = markdown_to_adf(md).unwrap();
13877 let content = doc.content[0].content.as_ref().unwrap();
13878 assert_eq!(
13879 content[0].attrs.as_ref().unwrap()["timestamp"],
13880 "1776211200000",
13881 "Should fall back to computing timestamp from date string"
13882 );
13883 }
13884
13885 #[test]
13886 fn date_with_local_id_and_timestamp() {
13887 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
13889 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13890 let md = adf_to_markdown(&doc).unwrap();
13891 assert!(
13892 md.contains("timestamp=1700000000000"),
13893 "Should contain timestamp: {md}"
13894 );
13895 assert!(md.contains("localId=d-001"), "Should contain localId: {md}");
13896 let doc2 = markdown_to_adf(&md).unwrap();
13898 let content = doc2.content[0].content.as_ref().unwrap();
13899 let attrs = content[0].attrs.as_ref().unwrap();
13900 assert_eq!(attrs["timestamp"], "1700000000000");
13901 assert_eq!(attrs["localId"], "d-001");
13902 }
13903
13904 #[test]
13905 fn mention_directive() {
13906 let doc = markdown_to_adf("Assigned to :mention[Alice]{id=abc123}.").unwrap();
13907 let content = doc.content[0].content.as_ref().unwrap();
13908 assert_eq!(content[1].node_type, "mention");
13909 assert_eq!(content[1].attrs.as_ref().unwrap()["id"], "abc123");
13910 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "Alice");
13911 }
13912
13913 #[test]
13914 fn adf_mention_to_markdown() {
13915 let doc = AdfDocument {
13916 version: 1,
13917 doc_type: "doc".to_string(),
13918 content: vec![AdfNode::paragraph(vec![AdfNode::mention(
13919 "abc123", "Alice",
13920 )])],
13921 };
13922 let md = adf_to_markdown(&doc).unwrap();
13923 assert!(md.contains(":mention[Alice]{id=abc123}"));
13924 }
13925
13926 #[test]
13927 fn round_trip_mention() {
13928 let md = "Assigned to :mention[Alice]{id=abc123}.\n";
13929 let doc = markdown_to_adf(md).unwrap();
13930 let result = adf_to_markdown(&doc).unwrap();
13931 assert!(result.contains(":mention[Alice]{id=abc123}"));
13932 }
13933
13934 #[test]
13935 fn mention_with_empty_access_level_round_trips() {
13936 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13938 {"type":"mention","attrs":{"id":"61921b41c15977006af2b1d1","text":"@Javier Inchausti","accessLevel":""}}
13939 ]}]}"#;
13940 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13941
13942 let md = adf_to_markdown(&doc).unwrap();
13943 let round_tripped = markdown_to_adf(&md).unwrap();
13944 let mention = &round_tripped.content[0].content.as_ref().unwrap()[0];
13945 assert_eq!(
13946 mention.node_type, "mention",
13947 "mention with empty accessLevel was not parsed as mention, got: {}",
13948 mention.node_type
13949 );
13950 }
13951
13952 #[test]
13953 fn span_with_color() {
13954 let doc = markdown_to_adf("This is :span[red text]{color=#ff5630}.").unwrap();
13955 let content = doc.content[0].content.as_ref().unwrap();
13956 assert_eq!(content[1].node_type, "text");
13957 assert_eq!(content[1].text.as_deref(), Some("red text"));
13958 let marks = content[1].marks.as_ref().unwrap();
13959 assert_eq!(marks[0].mark_type, "textColor");
13960 }
13961
13962 #[test]
13963 fn adf_text_color_to_markdown() {
13964 let doc = AdfDocument {
13965 version: 1,
13966 doc_type: "doc".to_string(),
13967 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
13968 "red text",
13969 vec![AdfMark::text_color("#ff5630")],
13970 )])],
13971 };
13972 let md = adf_to_markdown(&doc).unwrap();
13973 assert!(md.contains(":span[red text]{color=#ff5630}"));
13974 }
13975
13976 #[test]
13977 fn round_trip_span_color() {
13978 let md = "This is :span[red text]{color=#ff5630}.\n";
13979 let doc = markdown_to_adf(md).unwrap();
13980 let result = adf_to_markdown(&doc).unwrap();
13981 assert!(result.contains(":span[red text]{color=#ff5630}"));
13982 }
13983
13984 #[test]
13985 fn text_color_and_link_marks_both_preserved() {
13986 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13988 {"type":"text","text":"red link","marks":[
13989 {"type":"link","attrs":{"href":"https://example.com"}},
13990 {"type":"textColor","attrs":{"color":"#ff0000"}}
13991 ]}
13992 ]}]}"##;
13993 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13994 let md = adf_to_markdown(&doc).unwrap();
13995 assert!(
13996 md.contains(":span[red link]{color=#ff0000}"),
13997 "JFM should contain span with color, got: {md}"
13998 );
13999 assert!(
14000 md.contains("](https://example.com)"),
14001 "JFM should contain link href, got: {md}"
14002 );
14003 let rt = markdown_to_adf(&md).unwrap();
14005 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
14006 let marks = text_node.marks.as_ref().expect("should have marks");
14007 assert!(
14008 marks.iter().any(|m| m.mark_type == "textColor"),
14009 "should have textColor mark, got: {:?}",
14010 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
14011 );
14012 assert!(
14013 marks.iter().any(|m| m.mark_type == "link"),
14014 "should have link mark, got: {:?}",
14015 marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
14016 );
14017 let link_mark = marks.iter().find(|m| m.mark_type == "link").unwrap();
14019 assert_eq!(
14020 link_mark.attrs.as_ref().unwrap()["href"],
14021 "https://example.com"
14022 );
14023 let color_mark = marks.iter().find(|m| m.mark_type == "textColor").unwrap();
14024 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#ff0000");
14025 }
14026
14027 #[test]
14028 fn bg_color_and_link_marks_both_preserved() {
14029 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14030 {"type":"text","text":"highlighted link","marks":[
14031 {"type":"link","attrs":{"href":"https://example.com"}},
14032 {"type":"backgroundColor","attrs":{"color":"#ffff00"}}
14033 ]}
14034 ]}]}"##;
14035 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14036 let md = adf_to_markdown(&doc).unwrap();
14037 assert!(md.contains("bg=#ffff00"), "should have bg color: {md}");
14038 assert!(
14039 md.contains("](https://example.com)"),
14040 "should have link: {md}"
14041 );
14042 let rt = markdown_to_adf(&md).unwrap();
14043 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
14044 let marks = text_node.marks.as_ref().expect("should have marks");
14045 assert!(marks.iter().any(|m| m.mark_type == "backgroundColor"));
14046 assert!(marks.iter().any(|m| m.mark_type == "link"));
14047 }
14048
14049 #[test]
14050 fn text_color_link_and_strong_rendering() {
14051 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14053 {"type":"text","text":"bold red link","marks":[
14054 {"type":"strong"},
14055 {"type":"link","attrs":{"href":"https://example.com"}},
14056 {"type":"textColor","attrs":{"color":"#ff0000"}}
14057 ]}
14058 ]}]}"##;
14059 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14060 let md = adf_to_markdown(&doc).unwrap();
14061 assert!(
14062 md.starts_with("**") && md.trim().ends_with("**"),
14063 "should have bold wrapping: {md}"
14064 );
14065 assert!(md.contains("color=#ff0000"), "should have color: {md}");
14066 assert!(
14067 md.contains("](https://example.com)"),
14068 "should have link: {md}"
14069 );
14070 }
14071
14072 #[test]
14073 fn subsup_and_link_marks_both_preserved() {
14074 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14075 {"type":"text","text":"note","marks":[
14076 {"type":"link","attrs":{"href":"https://example.com"}},
14077 {"type":"subsup","attrs":{"type":"sup"}}
14078 ]}
14079 ]}]}"#;
14080 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14081 let md = adf_to_markdown(&doc).unwrap();
14082 assert!(md.contains("sup"), "should have sup: {md}");
14083 assert!(
14084 md.contains("](https://example.com)"),
14085 "should have link: {md}"
14086 );
14087 let rt = markdown_to_adf(&md).unwrap();
14088 let text_node = &rt.content[0].content.as_ref().unwrap()[0];
14089 let marks = text_node.marks.as_ref().expect("should have marks");
14090 assert!(marks.iter().any(|m| m.mark_type == "subsup"));
14091 assert!(marks.iter().any(|m| m.mark_type == "link"));
14092 }
14093
14094 #[test]
14095 fn text_color_without_link_unchanged() {
14096 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14098 {"type":"text","text":"just red","marks":[
14099 {"type":"textColor","attrs":{"color":"#ff0000"}}
14100 ]}
14101 ]}]}"##;
14102 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14103 let md = adf_to_markdown(&doc).unwrap();
14104 assert!(md.contains(":span[just red]{color=#ff0000}"), "md: {md}");
14105 assert!(!md.contains("](http"), "should NOT have link syntax: {md}");
14106 }
14107
14108 #[test]
14109 fn inline_extension_directive() {
14110 let doc =
14111 markdown_to_adf("See :extension[fallback]{type=com.app key=widget} here.").unwrap();
14112 let content = doc.content[0].content.as_ref().unwrap();
14113 assert_eq!(content[1].node_type, "inlineExtension");
14114 assert_eq!(
14115 content[1].attrs.as_ref().unwrap()["extensionType"],
14116 "com.app"
14117 );
14118 assert_eq!(content[1].attrs.as_ref().unwrap()["extensionKey"], "widget");
14119 }
14120
14121 #[test]
14122 fn adf_inline_extension_to_markdown() {
14123 let doc = AdfDocument {
14124 version: 1,
14125 doc_type: "doc".to_string(),
14126 content: vec![AdfNode::paragraph(vec![AdfNode::inline_extension(
14127 "com.app",
14128 "widget",
14129 Some("fallback"),
14130 )])],
14131 };
14132 let md = adf_to_markdown(&doc).unwrap();
14133 assert!(md.contains(":extension[fallback]{type=com.app key=widget}"));
14134 }
14135
14136 #[test]
14139 fn parse_ordered_list_marker_valid() {
14140 let result = parse_ordered_list_marker("1. Hello");
14141 assert_eq!(result, Some((1, "Hello")));
14142 }
14143
14144 #[test]
14145 fn parse_ordered_list_marker_high_number() {
14146 let result = parse_ordered_list_marker("42. Item");
14147 assert_eq!(result, Some((42, "Item")));
14148 }
14149
14150 #[test]
14151 fn parse_ordered_list_marker_not_a_list() {
14152 assert!(parse_ordered_list_marker("not a list").is_none());
14153 assert!(parse_ordered_list_marker("1.no space").is_none());
14154 }
14155
14156 #[test]
14157 fn is_list_start_various() {
14158 assert!(is_list_start("- item"));
14159 assert!(is_list_start("* item"));
14160 assert!(is_list_start("+ item"));
14161 assert!(is_list_start("1. item"));
14162 assert!(!is_list_start("not a list"));
14163 }
14164
14165 #[test]
14166 fn is_horizontal_rule_various() {
14167 assert!(is_horizontal_rule("---"));
14168 assert!(is_horizontal_rule("***"));
14169 assert!(is_horizontal_rule("___"));
14170 assert!(is_horizontal_rule("------"));
14171 assert!(!is_horizontal_rule("--"));
14172 assert!(!is_horizontal_rule("abc"));
14173 }
14174
14175 #[test]
14176 fn is_table_separator_valid() {
14177 assert!(is_table_separator("| --- | --- |"));
14178 assert!(is_table_separator("|:---:|:---|"));
14179 assert!(!is_table_separator("no pipes here"));
14180 }
14181
14182 #[test]
14183 fn parse_table_row_cells() {
14184 let cells = parse_table_row("| A | B | C |");
14185 assert_eq!(cells, vec!["A", "B", "C"]);
14186 }
14187
14188 #[test]
14189 fn parse_table_row_escaped_pipe_in_cell() {
14190 let cells = parse_table_row(r"| a\|b | c |");
14192 assert_eq!(cells, vec!["a|b", "c"]);
14193 }
14194
14195 #[test]
14196 fn parse_table_row_escaped_pipe_in_code_span() {
14197 let cells = parse_table_row(r"| `parser.decode[T\|json]` | other |");
14199 assert_eq!(cells, vec!["`parser.decode[T|json]`", "other"]);
14200 }
14201
14202 #[test]
14203 fn parse_table_row_preserves_other_backslashes() {
14204 let cells = parse_table_row(r"| a\\b | c\*d |");
14206 assert_eq!(cells, vec![r"a\\b", r"c\*d"]);
14207 }
14208
14209 #[test]
14210 fn parse_image_syntax_valid() {
14211 let result = parse_image_syntax("");
14212 assert_eq!(result, Some(("alt", "url")));
14213 }
14214
14215 #[test]
14216 fn parse_image_syntax_not_image() {
14217 assert!(parse_image_syntax("not an image").is_none());
14218 }
14219
14220 #[test]
14223 fn find_closing_paren_simple() {
14224 assert_eq!(find_closing_paren("(hello)", 0), Some(6));
14225 }
14226
14227 #[test]
14228 fn find_closing_paren_nested() {
14229 assert_eq!(find_closing_paren("(a(b)c)", 0), Some(6));
14230 }
14231
14232 #[test]
14233 fn find_closing_paren_unmatched() {
14234 assert_eq!(find_closing_paren("(no close", 0), None);
14235 }
14236
14237 #[test]
14238 fn find_closing_paren_offset() {
14239 assert_eq!(find_closing_paren("xx(inner)", 2), Some(8));
14241 }
14242
14243 #[test]
14246 fn try_parse_link_url_with_parens() {
14247 let input = "[here](https://example.com/faq#access-(permissions)-rest)";
14248 let result = try_parse_link(input, 0);
14249 assert_eq!(
14250 result,
14251 Some((
14252 input.len(),
14253 "here",
14254 "https://example.com/faq#access-(permissions)-rest"
14255 ))
14256 );
14257 }
14258
14259 #[test]
14260 fn try_parse_link_url_no_parens() {
14261 let input = "[text](https://example.com)";
14262 let result = try_parse_link(input, 0);
14263 assert_eq!(result, Some((input.len(), "text", "https://example.com")));
14264 }
14265
14266 #[test]
14267 fn try_parse_link_url_with_multiple_nested_parens() {
14268 let input = "[x](http://en.wikipedia.org/wiki/Foo_(bar_(baz)))";
14269 let result = try_parse_link(input, 0);
14270 assert_eq!(
14271 result,
14272 Some((
14273 input.len(),
14274 "x",
14275 "http://en.wikipedia.org/wiki/Foo_(bar_(baz))"
14276 ))
14277 );
14278 }
14279
14280 #[test]
14281 fn parse_image_syntax_url_with_parens() {
14282 let result = parse_image_syntax(")");
14283 assert_eq!(result, Some(("alt", "https://example.com/page_(1)")));
14284 }
14285
14286 #[test]
14287 fn parse_image_syntax_url_no_parens() {
14288 let result = parse_image_syntax("");
14289 assert_eq!(result, Some(("alt", "https://example.com")));
14290 }
14291
14292 #[test]
14293 fn link_with_parens_round_trip() {
14294 let href = "https://example.com/faq#I-need-access-(permissions)-added-in-Monitor";
14295 let mut text_node = AdfNode::text("here");
14296 text_node.marks = Some(vec![AdfMark::link(href)]);
14297 let adf_input = AdfDocument {
14298 version: 1,
14299 doc_type: "doc".to_string(),
14300 content: vec![AdfNode::paragraph(vec![text_node])],
14301 };
14302
14303 let jfm = adf_to_markdown(&adf_input).unwrap();
14304 let adf_output = markdown_to_adf(&jfm).unwrap();
14305
14306 let para = &adf_output.content[0];
14308 let text_node = ¶.content.as_ref().unwrap()[0];
14309 let mark = &text_node.marks.as_ref().unwrap()[0];
14310 let result_href = mark.attrs.as_ref().unwrap()["href"].as_str().unwrap();
14311
14312 assert_eq!(result_href, href);
14313 }
14314
14315 #[test]
14316 fn flush_plain_empty_range() {
14317 let mut nodes = Vec::new();
14318 flush_plain("hello", 3, 3, &mut nodes);
14319 assert!(nodes.is_empty());
14320 }
14321
14322 #[test]
14323 fn add_mark_to_unmarked_node() {
14324 let mut node = AdfNode::text("test");
14325 add_mark(&mut node, AdfMark::strong());
14326 assert_eq!(node.marks.as_ref().unwrap().len(), 1);
14327 }
14328
14329 #[test]
14330 fn add_mark_to_marked_node() {
14331 let mut node = AdfNode::text_with_marks("test", vec![AdfMark::strong()]);
14332 add_mark(&mut node, AdfMark::em());
14333 assert_eq!(node.marks.as_ref().unwrap().len(), 2);
14334 }
14335
14336 #[test]
14339 fn directive_table_basic() {
14340 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";
14341 let doc = markdown_to_adf(md).unwrap();
14342 assert_eq!(doc.content[0].node_type, "table");
14343 let rows = doc.content[0].content.as_ref().unwrap();
14344 assert_eq!(rows.len(), 2);
14345 assert_eq!(
14346 rows[0].content.as_ref().unwrap()[0].node_type,
14347 "tableHeader"
14348 );
14349 assert_eq!(rows[1].content.as_ref().unwrap()[0].node_type, "tableCell");
14350 }
14351
14352 #[test]
14353 fn directive_table_with_block_content() {
14354 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";
14355 let doc = markdown_to_adf(md).unwrap();
14356 let rows = doc.content[0].content.as_ref().unwrap();
14357 let cell = &rows[0].content.as_ref().unwrap()[0];
14358 let content = cell.content.as_ref().unwrap();
14360 assert!(content.len() >= 2);
14361 assert_eq!(content[1].node_type, "bulletList");
14362 }
14363
14364 #[test]
14365 fn directive_table_with_cell_attrs() {
14366 let md = "::::table\n:::tr\n:::td{colspan=2 bg=#DEEBFF}\nSpanning cell\n:::\n:::\n::::\n";
14367 let doc = markdown_to_adf(md).unwrap();
14368 let cell = &doc.content[0].content.as_ref().unwrap()[0]
14369 .content
14370 .as_ref()
14371 .unwrap()[0];
14372 let attrs = cell.attrs.as_ref().unwrap();
14373 assert_eq!(attrs["colspan"], 2);
14374 assert_eq!(attrs["background"], "#DEEBFF");
14375 }
14376
14377 #[test]
14378 fn directive_table_with_css_var_background() {
14379 let bg = "var(--ds-background-accent-gray-subtlest, var(--ds-background-accent-gray-subtlest, #F1F2F4))";
14380 let md = format!("::::table\n:::tr\n:::th{{bg=\"{bg}\"}}\nHeader\n:::\n:::\n::::\n");
14381 let doc = markdown_to_adf(&md).unwrap();
14382 let row = &doc.content[0].content.as_ref().unwrap()[0];
14383 let cells = row.content.as_ref().unwrap();
14384 assert_eq!(cells.len(), 1, "row must have at least one cell");
14385 let attrs = cells[0].attrs.as_ref().unwrap();
14386 assert_eq!(attrs["background"], bg);
14387 }
14388
14389 #[test]
14390 fn css_var_background_round_trips() {
14391 let bg = "var(--ds-background-accent-gray-subtlest, #F1F2F4)";
14392 let adf = AdfDocument {
14393 version: 1,
14394 doc_type: "doc".to_string(),
14395 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14396 AdfNode::table_header_with_attrs(
14397 vec![AdfNode::paragraph(vec![AdfNode::text("Header")])],
14398 serde_json::json!({"background": bg}),
14399 ),
14400 ])])],
14401 };
14402 let md = adf_to_markdown(&adf).unwrap();
14403 assert!(
14404 md.contains(&format!("bg=\"{bg}\"")),
14405 "bg value must be quoted in markdown: {md}"
14406 );
14407
14408 let round_tripped = markdown_to_adf(&md).unwrap();
14409 let row = &round_tripped.content[0].content.as_ref().unwrap()[0];
14410 let cells = row.content.as_ref().unwrap();
14411 assert_eq!(cells.len(), 1, "round-tripped row must have one cell");
14412 let rt_attrs = cells[0].attrs.as_ref().unwrap();
14413 assert_eq!(rt_attrs["background"], bg);
14414 }
14415
14416 #[test]
14417 fn directive_table_with_table_attrs() {
14418 let md = "::::table{layout=wide numbered}\n:::tr\n:::td\nCell\n:::\n:::\n::::\n";
14419 let doc = markdown_to_adf(md).unwrap();
14420 let attrs = doc.content[0].attrs.as_ref().unwrap();
14421 assert_eq!(attrs["layout"], "wide");
14422 assert_eq!(attrs["isNumberColumnEnabled"], true);
14423 }
14424
14425 #[test]
14426 fn adf_table_with_block_content_renders_directive_form() {
14427 let doc = AdfDocument {
14429 version: 1,
14430 doc_type: "doc".to_string(),
14431 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14432 AdfNode::table_cell(vec![
14433 AdfNode::paragraph(vec![AdfNode::text("Cell with list:")]),
14434 AdfNode::bullet_list(vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
14435 AdfNode::text("Item 1"),
14436 ])])]),
14437 ]),
14438 ])])],
14439 };
14440 let md = adf_to_markdown(&doc).unwrap();
14441 assert!(md.contains("::::table"));
14442 assert!(md.contains(":::td"));
14443 assert!(md.contains("- Item 1"));
14444 }
14445
14446 #[test]
14447 fn adf_table_inline_only_renders_pipe_form() {
14448 let doc = AdfDocument {
14450 version: 1,
14451 doc_type: "doc".to_string(),
14452 content: vec![AdfNode::table(vec![
14453 AdfNode::table_row(vec![
14454 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
14455 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14456 ]),
14457 AdfNode::table_row(vec![
14458 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
14459 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14460 ]),
14461 ])],
14462 };
14463 let md = adf_to_markdown(&doc).unwrap();
14464 assert!(md.contains("| H1 | H2 |"));
14465 assert!(!md.contains("::::table"));
14466 }
14467
14468 #[test]
14469 fn adf_table_header_outside_first_row_renders_directive() {
14470 let doc = AdfDocument {
14471 version: 1,
14472 doc_type: "doc".to_string(),
14473 content: vec![AdfNode::table(vec![
14474 AdfNode::table_row(vec![
14475 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H")])]),
14476 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C")])]),
14477 ]),
14478 AdfNode::table_row(vec![
14479 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14480 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14481 ]),
14482 ])],
14483 };
14484 let md = adf_to_markdown(&doc).unwrap();
14485 assert!(md.contains("::::table"));
14486 assert!(md.contains(":::th"));
14487 }
14488
14489 #[test]
14490 fn adf_table_cell_attrs_rendered() {
14491 let doc = AdfDocument {
14492 version: 1,
14493 doc_type: "doc".to_string(),
14494 content: vec![AdfNode::table(vec![
14495 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
14496 AdfNode::text("H"),
14497 ])])]),
14498 AdfNode::table_row(vec![AdfNode::table_cell_with_attrs(
14499 vec![AdfNode::paragraph(vec![AdfNode::text("C")])],
14500 serde_json::json!({"background": "#DEEBFF", "colspan": 2}),
14501 )]),
14502 ])],
14503 };
14504 let md = adf_to_markdown(&doc).unwrap();
14505 assert!(md.contains("{colspan=2 bg=#DEEBFF}"));
14506 }
14507
14508 #[test]
14511 fn pipe_table_cell_attrs() {
14512 let md = "| H1 | H2 |\n|---|---|\n| {bg=#DEEBFF} highlighted | normal |\n";
14513 let doc = markdown_to_adf(md).unwrap();
14514 let rows = doc.content[0].content.as_ref().unwrap();
14515 let cell = &rows[1].content.as_ref().unwrap()[0];
14516 let attrs = cell.attrs.as_ref().unwrap();
14517 assert_eq!(attrs["background"], "#DEEBFF");
14518 }
14519
14520 #[test]
14521 fn pipe_table_cell_colspan() {
14522 let md = "| H1 | H2 |\n|---|---|\n| {colspan=2} spanning |\n";
14523 let doc = markdown_to_adf(md).unwrap();
14524 let rows = doc.content[0].content.as_ref().unwrap();
14525 let cell = &rows[1].content.as_ref().unwrap()[0];
14526 let attrs = cell.attrs.as_ref().unwrap();
14527 assert_eq!(attrs["colspan"], 2);
14528 }
14529
14530 #[test]
14531 fn trailing_space_after_mention_in_table_cell_preserved() {
14532 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":[
14534 {"type":"mention","attrs":{"id":"aaa","text":"@Rob"}},
14535 {"type":"text","text":" "}
14536 ]}]}]}]}]}"#;
14537 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14538 let md = adf_to_markdown(&doc).unwrap();
14539 let round_tripped = markdown_to_adf(&md).unwrap();
14540 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14541 .content
14542 .as_ref()
14543 .unwrap()[0];
14544 let para = &cell.content.as_ref().unwrap()[0];
14545 let inlines = para.content.as_ref().unwrap();
14546 assert!(
14547 inlines.len() >= 2,
14548 "expected mention + text(' ') nodes, got {} nodes: {:?}",
14549 inlines.len(),
14550 inlines.iter().map(|n| &n.node_type).collect::<Vec<_>>()
14551 );
14552 assert_eq!(inlines[0].node_type, "mention");
14553 assert_eq!(inlines[1].node_type, "text");
14554 assert_eq!(inlines[1].text.as_deref(), Some(" "));
14555 }
14556
14557 #[test]
14560 fn pipe_table_column_alignment() {
14561 let md = "| Left | Center | Right |\n|:---|:---:|---:|\n| L | C | R |\n";
14562 let doc = markdown_to_adf(md).unwrap();
14563 let rows = doc.content[0].content.as_ref().unwrap();
14564 let h_cells = rows[0].content.as_ref().unwrap();
14566 assert!(h_cells[0].content.as_ref().unwrap()[0].marks.is_none());
14568 let center_marks = h_cells[1].content.as_ref().unwrap()[0]
14570 .marks
14571 .as_ref()
14572 .unwrap();
14573 assert_eq!(center_marks[0].attrs.as_ref().unwrap()["align"], "center");
14574 let right_marks = h_cells[2].content.as_ref().unwrap()[0]
14576 .marks
14577 .as_ref()
14578 .unwrap();
14579 assert_eq!(right_marks[0].attrs.as_ref().unwrap()["align"], "end");
14580 }
14581
14582 #[test]
14583 fn adf_table_alignment_roundtrip() {
14584 let doc = AdfDocument {
14585 version: 1,
14586 doc_type: "doc".to_string(),
14587 content: vec![AdfNode::table(vec![
14588 AdfNode::table_row(vec![
14589 AdfNode::table_header(vec![{
14590 let mut p = AdfNode::paragraph(vec![AdfNode::text("Center")]);
14591 p.marks = Some(vec![AdfMark::alignment("center")]);
14592 p
14593 }]),
14594 AdfNode::table_header(vec![{
14595 let mut p = AdfNode::paragraph(vec![AdfNode::text("Right")]);
14596 p.marks = Some(vec![AdfMark::alignment("end")]);
14597 p
14598 }]),
14599 ]),
14600 AdfNode::table_row(vec![
14601 AdfNode::table_cell(vec![{
14602 let mut p = AdfNode::paragraph(vec![AdfNode::text("C")]);
14603 p.marks = Some(vec![AdfMark::alignment("center")]);
14604 p
14605 }]),
14606 AdfNode::table_cell(vec![{
14607 let mut p = AdfNode::paragraph(vec![AdfNode::text("R")]);
14608 p.marks = Some(vec![AdfMark::alignment("end")]);
14609 p
14610 }]),
14611 ]),
14612 ])],
14613 };
14614 let md = adf_to_markdown(&doc).unwrap();
14615 assert!(md.contains(":---:"));
14616 assert!(md.contains("---:"));
14617 }
14618
14619 #[test]
14622 fn panel_custom_attrs_round_trip() {
14623 let md = ":::panel{type=custom icon=\":star:\" color=\"#DEEBFF\"}\nContent\n:::\n";
14624 let doc = markdown_to_adf(md).unwrap();
14625 let panel = &doc.content[0];
14626 let attrs = panel.attrs.as_ref().unwrap();
14627 assert_eq!(attrs["panelType"], "custom");
14628 assert_eq!(attrs["panelIcon"], ":star:");
14629 assert_eq!(attrs["panelColor"], "#DEEBFF");
14630
14631 let result = adf_to_markdown(&doc).unwrap();
14632 assert!(result.contains("type=custom"));
14633 assert!(result.contains("icon="));
14634 assert!(result.contains("color="));
14635 }
14636
14637 #[test]
14640 fn block_card_with_layout() {
14641 let md = "::card[https://example.com]{layout=wide}\n";
14642 let doc = markdown_to_adf(md).unwrap();
14643 let attrs = doc.content[0].attrs.as_ref().unwrap();
14644 assert_eq!(attrs["layout"], "wide");
14645
14646 let result = adf_to_markdown(&doc).unwrap();
14647 assert!(result.contains("::card[https://example.com]{layout=wide}"));
14648 }
14649
14650 #[test]
14653 fn extension_with_params() {
14654 let md = r#"::extension{type=com.atlassian.macro key=jira-chart params='{"jql":"project=PROJ"}'}"#;
14655 let doc = markdown_to_adf(&format!("{md}\n")).unwrap();
14656 let attrs = doc.content[0].attrs.as_ref().unwrap();
14657 assert_eq!(attrs["parameters"]["jql"], "project=PROJ");
14658 }
14659
14660 #[test]
14661 fn leaf_extension_layout_preserved_in_roundtrip() {
14662 let adf_json = r#"{"version":1,"type":"doc","content":[
14664 {"type":"extension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"toc","layout":"default","parameters":{}}}
14665 ]}"#;
14666 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14667 let md = adf_to_markdown(&doc).unwrap();
14668 assert!(
14669 md.contains("layout=default"),
14670 "JFM should contain layout=default, got: {md}"
14671 );
14672 let round_tripped = markdown_to_adf(&md).unwrap();
14673 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14674 assert_eq!(attrs["layout"], "default", "layout should be preserved");
14675 assert_eq!(attrs["extensionKey"], "toc");
14676 }
14677
14678 #[test]
14679 fn bodied_extension_layout_preserved_in_roundtrip() {
14680 let adf_json = r#"{"version":1,"type":"doc","content":[
14682 {"type":"bodiedExtension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"expand","layout":"wide"},
14683 "content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}
14684 ]}"#;
14685 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14686 let md = adf_to_markdown(&doc).unwrap();
14687 assert!(
14688 md.contains("layout=wide"),
14689 "JFM should contain layout=wide, got: {md}"
14690 );
14691 let round_tripped = markdown_to_adf(&md).unwrap();
14692 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14693 assert_eq!(attrs["layout"], "wide", "layout should be preserved");
14694 }
14695
14696 #[test]
14697 fn bodied_extension_parameters_preserved_in_roundtrip() {
14698 let adf_json = r#"{"version":1,"type":"doc","content":[
14700 {"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":{}}},
14701 "content":[{"type":"paragraph","content":[{"type":"text","text":"Content inside bodied extension"}]}]}
14702 ]}"#;
14703 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14704 let md = adf_to_markdown(&doc).unwrap();
14705 assert!(
14706 md.contains("params="),
14707 "JFM should contain params attribute, got: {md}"
14708 );
14709 let round_tripped = markdown_to_adf(&md).unwrap();
14710 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14711 assert_eq!(
14712 attrs["parameters"]["macroMetadata"]["title"], "Page Properties",
14713 "parameters should be preserved in round-trip"
14714 );
14715 assert_eq!(attrs["extensionKey"], "details");
14716 assert_eq!(attrs["layout"], "default");
14717 assert_eq!(attrs["localId"], "aabbccdd-1234");
14718 }
14719
14720 #[test]
14721 fn bodied_extension_malformed_params_ignored() {
14722 let md = ":::extension{type=com.atlassian.macro key=details params='not-valid-json'}\nContent\n:::\n";
14724 let doc = markdown_to_adf(md).unwrap();
14725 let attrs = doc.content[0].attrs.as_ref().unwrap();
14726 assert_eq!(attrs["extensionKey"], "details");
14727 assert!(attrs.get("parameters").is_none());
14729 }
14730
14731 #[test]
14732 fn leaf_extension_localid_preserved_in_roundtrip() {
14733 let adf_json = r#"{"version":1,"type":"doc","content":[
14735 {"type":"extension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"toc","layout":"default","localId":"abc-123"}}
14736 ]}"#;
14737 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14738 let md = adf_to_markdown(&doc).unwrap();
14739 let round_tripped = markdown_to_adf(&md).unwrap();
14740 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14741 assert_eq!(attrs["layout"], "default");
14742 assert_eq!(attrs["localId"], "abc-123");
14743 }
14744
14745 #[test]
14748 fn mention_with_user_type() {
14749 let md = "Hi :mention[Alice]{id=abc123 userType=DEFAULT}.\n";
14750 let doc = markdown_to_adf(md).unwrap();
14751 let mention = &doc.content[0].content.as_ref().unwrap()[1];
14752 assert_eq!(mention.attrs.as_ref().unwrap()["userType"], "DEFAULT");
14753
14754 let result = adf_to_markdown(&doc).unwrap();
14755 assert!(result.contains("userType=DEFAULT"));
14756 }
14757
14758 #[test]
14761 fn directive_table_colwidth() {
14762 let md = "::::table\n:::tr\n:::td{colwidth=100,200}\nCell\n:::\n:::\n::::\n";
14763 let doc = markdown_to_adf(md).unwrap();
14764 let cell = &doc.content[0].content.as_ref().unwrap()[0]
14765 .content
14766 .as_ref()
14767 .unwrap()[0];
14768 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14769 assert_eq!(colwidth, &[serde_json::json!(100), serde_json::json!(200)]);
14770 }
14771
14772 #[test]
14773 fn directive_table_colwidth_float_roundtrip() {
14774 let adf_doc = serde_json::json!({
14777 "type": "doc",
14778 "version": 1,
14779 "content": [{
14780 "type": "table",
14781 "content": [{
14782 "type": "tableRow",
14783 "content": [
14784 {
14785 "type": "tableHeader",
14786 "attrs": { "colwidth": [157.0] },
14787 "content": [{ "type": "paragraph" }]
14788 },
14789 {
14790 "type": "tableHeader",
14791 "attrs": { "colwidth": [863.0] },
14792 "content": [{ "type": "paragraph" }]
14793 }
14794 ]
14795 }]
14796 }]
14797 });
14798 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
14799 let md = adf_to_markdown(&doc).unwrap();
14800 assert!(
14801 md.contains("colwidth=157.0"),
14802 "expected colwidth=157.0 in markdown, got: {md}"
14803 );
14804 assert!(
14805 md.contains("colwidth=863.0"),
14806 "expected colwidth=863.0 in markdown, got: {md}"
14807 );
14808 let doc2 = markdown_to_adf(&md).unwrap();
14810 let row = &doc2.content[0].content.as_ref().unwrap()[0];
14811 let header1 = &row.content.as_ref().unwrap()[0];
14812 let header2 = &row.content.as_ref().unwrap()[1];
14813 assert_eq!(
14814 header1.attrs.as_ref().unwrap()["colwidth"]
14815 .as_array()
14816 .unwrap(),
14817 &[serde_json::json!(157.0)]
14818 );
14819 assert_eq!(
14820 header2.attrs.as_ref().unwrap()["colwidth"]
14821 .as_array()
14822 .unwrap(),
14823 &[serde_json::json!(863.0)]
14824 );
14825 }
14826
14827 #[test]
14828 fn colwidth_float_preserved_in_roundtrip() {
14829 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":[]}]}]}]}]}"#;
14831 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14832 let md = adf_to_markdown(&doc).unwrap();
14833 let round_tripped = markdown_to_adf(&md).unwrap();
14834 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14835 .content
14836 .as_ref()
14837 .unwrap()[0];
14838 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14839 assert_eq!(
14840 colwidth,
14841 &[serde_json::json!(254.0), serde_json::json!(416.0)],
14842 "colwidth should preserve float values"
14843 );
14844 }
14845
14846 #[test]
14847 fn colwidth_integer_preserved_in_roundtrip() {
14848 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"}]}]}]}]}]}"#;
14850 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14851 let md = adf_to_markdown(&doc).unwrap();
14852 assert!(
14853 md.contains("colwidth=150"),
14854 "expected colwidth=150 (no decimal) in markdown, got: {md}"
14855 );
14856 assert!(
14857 !md.contains("colwidth=150.0"),
14858 "colwidth should not have .0 suffix for integers, got: {md}"
14859 );
14860 let round_tripped = markdown_to_adf(&md).unwrap();
14862 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14863 .content
14864 .as_ref()
14865 .unwrap()[0];
14866 let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14867 assert_eq!(
14868 colwidth,
14869 &[serde_json::json!(150)],
14870 "colwidth should preserve integer values"
14871 );
14872 let json_output = serde_json::to_string(&round_tripped).unwrap();
14874 assert!(
14875 json_output.contains(r#""colwidth":[150]"#),
14876 "JSON should contain integer colwidth, got: {json_output}"
14877 );
14878 }
14879
14880 #[test]
14881 fn colwidth_mixed_int_and_float_roundtrip() {
14882 let int_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100,200]}}]}]}]}"#;
14885 let float_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100.0,200.0]}}]}]}]}"#;
14886
14887 let int_doc: AdfDocument = serde_json::from_str(int_json).unwrap();
14889 let int_md = adf_to_markdown(&int_doc).unwrap();
14890 assert!(
14891 int_md.contains("colwidth=100,200"),
14892 "integer colwidth in md: {int_md}"
14893 );
14894 let int_rt = markdown_to_adf(&int_md).unwrap();
14895 let int_serial = serde_json::to_string(&int_rt).unwrap();
14896 assert!(
14897 int_serial.contains(r#""colwidth":[100,200]"#),
14898 "integer colwidth in JSON: {int_serial}"
14899 );
14900
14901 let float_doc: AdfDocument = serde_json::from_str(float_json).unwrap();
14903 let float_md = adf_to_markdown(&float_doc).unwrap();
14904 assert!(
14905 float_md.contains("colwidth=100.0,200.0"),
14906 "float colwidth in md: {float_md}"
14907 );
14908 let float_rt = markdown_to_adf(&float_md).unwrap();
14909 let float_serial = serde_json::to_string(&float_rt).unwrap();
14910 assert!(
14911 float_serial.contains(r#""colwidth":[100.0,200.0]"#),
14912 "float colwidth in JSON: {float_serial}"
14913 );
14914 }
14915
14916 #[test]
14917 fn colwidth_fractional_float_preserved() {
14918 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"}]}]}]}]}]}"#;
14920 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14921 let md = adf_to_markdown(&doc).unwrap();
14922 assert!(
14923 md.contains("colwidth=100.5"),
14924 "expected colwidth=100.5 in markdown, got: {md}"
14925 );
14926 }
14927
14928 #[test]
14929 fn colwidth_non_numeric_values_skipped() {
14930 let adf_doc = serde_json::json!({
14932 "type": "doc",
14933 "version": 1,
14934 "content": [{
14935 "type": "table",
14936 "content": [{
14937 "type": "tableRow",
14938 "content": [{
14939 "type": "tableCell",
14940 "attrs": { "colwidth": ["invalid"] },
14941 "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "cell" }] }]
14942 }]
14943 }]
14944 }]
14945 });
14946 let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
14947 let md = adf_to_markdown(&doc).unwrap();
14948 assert!(
14950 !md.contains("colwidth"),
14951 "non-numeric colwidth should be filtered out, got: {md}"
14952 );
14953 }
14954
14955 #[test]
14956 fn default_rowspan_colspan_preserved_in_roundtrip() {
14957 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"}]}]}]}]}]}"#;
14959 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14960 let md = adf_to_markdown(&doc).unwrap();
14961 let round_tripped = markdown_to_adf(&md).unwrap();
14962 let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14963 .content
14964 .as_ref()
14965 .unwrap()[0];
14966 let attrs = cell.attrs.as_ref().unwrap();
14967 assert_eq!(attrs["rowspan"], 1, "rowspan=1 should be preserved");
14968 assert_eq!(attrs["colspan"], 1, "colspan=1 should be preserved");
14969 }
14970
14971 #[test]
14974 fn table_localid_preserved_in_roundtrip() {
14975 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"}]}]}]}]}]}"#;
14977 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14978 let md = adf_to_markdown(&doc).unwrap();
14979 assert!(
14980 md.contains("localId="),
14981 "JFM should contain localId, got: {md}"
14982 );
14983 let round_tripped = markdown_to_adf(&md).unwrap();
14984 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14985 assert_eq!(
14986 attrs["localId"], "7afd4550-e66c-4b12-875f-a91c6c7b62c7",
14987 "localId should be preserved"
14988 );
14989 }
14990
14991 #[test]
14992 fn paragraph_localid_preserved_in_roundtrip() {
14993 let adf_json = r#"{"version":1,"type":"doc","content":[
14995 {"type":"paragraph","attrs":{"localId":"abc-123"},"content":[{"type":"text","text":"hello"}]}
14996 ]}"#;
14997 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14998 let md = adf_to_markdown(&doc).unwrap();
14999 assert!(
15000 md.contains("localId=abc-123"),
15001 "JFM should contain localId, got: {md}"
15002 );
15003 let round_tripped = markdown_to_adf(&md).unwrap();
15004 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15005 assert_eq!(attrs["localId"], "abc-123", "localId should be preserved");
15006 }
15007
15008 #[test]
15009 fn heading_localid_preserved_in_roundtrip() {
15010 let adf_json = r#"{"version":1,"type":"doc","content":[
15011 {"type":"heading","attrs":{"level":2,"localId":"h-456"},"content":[{"type":"text","text":"Title"}]}
15012 ]}"#;
15013 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15014 let md = adf_to_markdown(&doc).unwrap();
15015 let round_tripped = markdown_to_adf(&md).unwrap();
15016 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15017 assert_eq!(attrs["localId"], "h-456");
15018 }
15019
15020 #[test]
15021 fn localid_with_alignment_preserved() {
15022 let adf_json = r#"{"version":1,"type":"doc","content":[
15024 {"type":"paragraph","attrs":{"localId":"p-789"},"marks":[{"type":"alignment","attrs":{"align":"center"}}],
15025 "content":[{"type":"text","text":"centered"}]}
15026 ]}"#;
15027 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15028 let md = adf_to_markdown(&doc).unwrap();
15029 assert!(md.contains("localId=p-789"), "should have localId: {md}");
15030 assert!(md.contains("align=center"), "should have align: {md}");
15031 let round_tripped = markdown_to_adf(&md).unwrap();
15032 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15033 assert_eq!(attrs["localId"], "p-789");
15034 let marks = round_tripped.content[0].marks.as_ref().unwrap();
15035 assert!(marks.iter().any(|m| m.mark_type == "alignment"));
15036 }
15037
15038 #[test]
15039 fn table_layout_default_preserved_in_roundtrip() {
15040 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"}]}]}]}]}]}"#;
15042 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15043 let md = adf_to_markdown(&doc).unwrap();
15044 let round_tripped = markdown_to_adf(&md).unwrap();
15045 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15046 assert_eq!(
15047 attrs["layout"], "default",
15048 "layout='default' should be preserved"
15049 );
15050 }
15051
15052 #[test]
15053 fn table_is_number_column_enabled_false_preserved() {
15054 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"}]}]}]}]}]}"#;
15056 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15057 let md = adf_to_markdown(&doc).unwrap();
15058 let round_tripped = markdown_to_adf(&md).unwrap();
15059 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15060 assert_eq!(
15061 attrs["isNumberColumnEnabled"], false,
15062 "isNumberColumnEnabled=false should be preserved"
15063 );
15064 }
15065
15066 #[test]
15067 fn table_is_number_column_enabled_true_preserved() {
15068 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"}]}]}]}]}]}"#;
15070 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15071 let md = adf_to_markdown(&doc).unwrap();
15072 let round_tripped = markdown_to_adf(&md).unwrap();
15073 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15074 assert_eq!(
15075 attrs["isNumberColumnEnabled"], true,
15076 "isNumberColumnEnabled=true should be preserved"
15077 );
15078 }
15079
15080 #[test]
15081 fn directive_table_is_number_column_enabled_false_preserved() {
15082 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
15085 {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
15086 {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
15087 ]}]}]}]}"#;
15088 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15089 let md = adf_to_markdown(&doc).unwrap();
15090 assert!(md.contains("::::table"), "should use directive table form");
15091 assert!(
15092 md.contains("numbered=false"),
15093 "should contain numbered=false, got: {md}"
15094 );
15095 let round_tripped = markdown_to_adf(&md).unwrap();
15096 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15097 assert_eq!(attrs["isNumberColumnEnabled"], false);
15098 assert_eq!(attrs["layout"], "default");
15099 }
15100
15101 #[test]
15102 fn directive_table_is_number_column_enabled_true_preserved() {
15103 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":true,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
15105 {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
15106 {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
15107 ]}]}]}]}"#;
15108 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15109 let md = adf_to_markdown(&doc).unwrap();
15110 assert!(md.contains("::::table"), "should use directive table form");
15111 assert!(
15112 md.contains("numbered}") || md.contains("numbered "),
15113 "should contain numbered flag, got: {md}"
15114 );
15115 let round_tripped = markdown_to_adf(&md).unwrap();
15116 let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15117 assert_eq!(attrs["isNumberColumnEnabled"], true);
15118 }
15119
15120 #[test]
15121 fn trailing_space_in_bullet_list_item_preserved() {
15122 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15124 {"type":"listItem","content":[{"type":"paragraph","content":[
15125 {"type":"text","text":"Before link "},
15126 {"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
15127 {"type":"text","text":" "}
15128 ]}]}
15129 ]}]}"#;
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 list = &round_tripped.content[0];
15134 let item = &list.content.as_ref().unwrap()[0];
15135 let para = &item.content.as_ref().unwrap()[0];
15136 let inlines = para.content.as_ref().unwrap();
15137 let last = inlines.last().unwrap();
15138 assert_eq!(
15139 last.text.as_deref(),
15140 Some(" "),
15141 "trailing space text node should be preserved, got nodes: {:?}",
15142 inlines
15143 .iter()
15144 .map(|n| (&n.node_type, &n.text))
15145 .collect::<Vec<_>>()
15146 );
15147 }
15148
15149 #[test]
15150 fn trailing_space_after_mention_in_bullet_list_preserved() {
15151 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15153 {"type":"listItem","content":[{"type":"paragraph","content":[
15154 {"type":"mention","attrs":{"id":"abc","text":"@Alice"}},
15155 {"type":"text","text":" "}
15156 ]}]}
15157 ]}]}"#;
15158 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15159 let md = adf_to_markdown(&doc).unwrap();
15160 let round_tripped = markdown_to_adf(&md).unwrap();
15161 let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
15162 .content
15163 .as_ref()
15164 .unwrap()[0];
15165 let inlines = para.content.as_ref().unwrap();
15166 assert!(
15167 inlines.len() >= 2,
15168 "should have mention + trailing space, got {} nodes",
15169 inlines.len()
15170 );
15171 assert_eq!(inlines.last().unwrap().text.as_deref(), Some(" "));
15172 }
15173
15174 #[test]
15175 fn trailing_space_in_ordered_list_item_preserved() {
15176 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15178 {"type":"listItem","content":[{"type":"paragraph","content":[
15179 {"type":"text","text":"item "},
15180 {"type":"text","text":"link","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
15181 {"type":"text","text":" "}
15182 ]}]}
15183 ]}]}"#;
15184 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15185 let md = adf_to_markdown(&doc).unwrap();
15186 let round_tripped = markdown_to_adf(&md).unwrap();
15187 let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
15188 .content
15189 .as_ref()
15190 .unwrap()[0];
15191 let inlines = para.content.as_ref().unwrap();
15192 let last = inlines.last().unwrap();
15193 assert_eq!(
15194 last.text.as_deref(),
15195 Some(" "),
15196 "trailing space should be preserved in ordered list item"
15197 );
15198 }
15199
15200 #[test]
15201 fn trailing_space_in_heading_text_preserved() {
15202 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[
15204 {"type":"text","text":"Firefighting Engineers "}
15205 ]}]}"#;
15206 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15207 let md = adf_to_markdown(&doc).unwrap();
15208 let round_tripped = markdown_to_adf(&md).unwrap();
15209 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15210 assert_eq!(
15211 inlines[0].text.as_deref(),
15212 Some("Firefighting Engineers "),
15213 "trailing space in heading should be preserved"
15214 );
15215 }
15216
15217 #[test]
15218 fn trailing_space_in_heading_before_bold_preserved() {
15219 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
15221 {"type":"text","text":"Classic "},
15222 {"type":"text","text":"bold","marks":[{"type":"strong"}]}
15223 ]}]}"#;
15224 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15225 let md = adf_to_markdown(&doc).unwrap();
15226 let round_tripped = markdown_to_adf(&md).unwrap();
15227 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15228 assert_eq!(
15229 inlines[0].text.as_deref(),
15230 Some("Classic "),
15231 "trailing space in heading text before bold should be preserved"
15232 );
15233 }
15234
15235 #[test]
15236 fn leading_space_in_heading_text_preserved() {
15237 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":3},"content":[
15239 {"type":"text","text":" #general-channel"}
15240 ]}]}"#;
15241 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15242 let md = adf_to_markdown(&doc).unwrap();
15243 let round_tripped = markdown_to_adf(&md).unwrap();
15244 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15245 assert_eq!(
15246 inlines[0].text.as_deref(),
15247 Some(" #general-channel"),
15248 "leading spaces in heading text should be preserved"
15249 );
15250 }
15251
15252 #[test]
15253 fn leading_space_in_heading_before_bold_preserved() {
15254 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
15256 {"type":"text","text":" indented"},
15257 {"type":"text","text":" bold","marks":[{"type":"strong"}]}
15258 ]}]}"#;
15259 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15260 let md = adf_to_markdown(&doc).unwrap();
15261 let round_tripped = markdown_to_adf(&md).unwrap();
15262 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15263 assert_eq!(
15264 inlines[0].text.as_deref(),
15265 Some(" indented"),
15266 "leading spaces in heading text before bold should be preserved"
15267 );
15268 }
15269
15270 #[test]
15271 fn heading_multiple_leading_spaces_markdown_parse() {
15272 let md = "### \t #general-channel";
15274 let doc = markdown_to_adf(md).unwrap();
15275 let inlines = doc.content[0].content.as_ref().unwrap();
15276 assert_eq!(
15277 inlines[0].text.as_deref(),
15278 Some("\t #general-channel"),
15279 "leading whitespace in heading text should be preserved during JFM parsing"
15280 );
15281 }
15282
15283 #[test]
15284 fn trailing_space_in_paragraph_text_preserved() {
15285 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15287 {"type":"text","text":"word followed by space "},
15288 {"type":"text","text":"next node","marks":[{"type":"strong"}]}
15289 ]}]}"#;
15290 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15291 let md = adf_to_markdown(&doc).unwrap();
15292 let round_tripped = markdown_to_adf(&md).unwrap();
15293 let inlines = round_tripped.content[0].content.as_ref().unwrap();
15294 assert_eq!(
15295 inlines[0].text.as_deref(),
15296 Some("word followed by space "),
15297 "trailing space in paragraph text should be preserved"
15298 );
15299 }
15300
15301 #[test]
15302 fn nested_bullet_list_roundtrip() {
15303 let adf_doc = serde_json::json!({
15305 "type": "doc",
15306 "version": 1,
15307 "content": [{
15308 "type": "bulletList",
15309 "content": [{
15310 "type": "listItem",
15311 "content": [
15312 {
15313 "type": "paragraph",
15314 "content": [{"type": "text", "text": "parent item"}]
15315 },
15316 {
15317 "type": "bulletList",
15318 "content": [
15319 {
15320 "type": "listItem",
15321 "content": [{
15322 "type": "paragraph",
15323 "content": [{"type": "text", "text": "sub item 1"}]
15324 }]
15325 },
15326 {
15327 "type": "listItem",
15328 "content": [{
15329 "type": "paragraph",
15330 "content": [{"type": "text", "text": "sub item 2"}]
15331 }]
15332 }
15333 ]
15334 }
15335 ]
15336 }]
15337 }]
15338 });
15339 let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
15340 let md = adf_to_markdown(&doc).unwrap();
15341 assert!(
15342 md.contains("- parent item\n"),
15343 "expected top-level item in markdown, got: {md}"
15344 );
15345 assert!(
15346 md.contains(" - sub item 1\n"),
15347 "expected indented sub item 1 in markdown, got: {md}"
15348 );
15349 assert!(
15350 md.contains(" - sub item 2\n"),
15351 "expected indented sub item 2 in markdown, got: {md}"
15352 );
15353
15354 let doc2 = markdown_to_adf(&md).unwrap();
15356 let list = &doc2.content[0];
15357 assert_eq!(list.node_type, "bulletList");
15358 let item = &list.content.as_ref().unwrap()[0];
15359 assert_eq!(item.node_type, "listItem");
15360 let item_content = item.content.as_ref().unwrap();
15361 assert_eq!(
15362 item_content.len(),
15363 2,
15364 "listItem should have paragraph + nested list"
15365 );
15366 assert_eq!(item_content[0].node_type, "paragraph");
15367 assert_eq!(item_content[1].node_type, "bulletList");
15368 let sub_items = item_content[1].content.as_ref().unwrap();
15369 assert_eq!(sub_items.len(), 2);
15370 }
15371
15372 #[test]
15373 fn nested_bullet_in_table_cell_roundtrip() {
15374 let md = "::::table\n:::tr\n:::td\n- parent\n - child\n:::\n:::\n::::\n";
15375 let doc = markdown_to_adf(md).unwrap();
15376 let table = &doc.content[0];
15377 let row = &table.content.as_ref().unwrap()[0];
15378 let cell = &row.content.as_ref().unwrap()[0];
15379 let list = &cell.content.as_ref().unwrap()[0];
15380 assert_eq!(list.node_type, "bulletList");
15381 let item = &list.content.as_ref().unwrap()[0];
15382 let item_content = item.content.as_ref().unwrap();
15383 assert_eq!(
15384 item_content.len(),
15385 2,
15386 "listItem should have paragraph + nested list"
15387 );
15388 assert_eq!(item_content[1].node_type, "bulletList");
15389
15390 let md2 = adf_to_markdown(&doc).unwrap();
15392 assert!(
15393 md2.contains(" - child"),
15394 "expected indented child in round-tripped markdown, got: {md2}"
15395 );
15396 }
15397
15398 #[test]
15399 fn nested_ordered_list_roundtrip() {
15400 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15402 {"type":"listItem","content":[
15403 {"type":"paragraph","content":[{"type":"text","text":"Top level"}]},
15404 {"type":"orderedList","attrs":{"order":1},"content":[
15405 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 1"}]}]},
15406 {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 2"}]}]}
15407 ]}
15408 ]},
15409 {"type":"listItem","content":[
15410 {"type":"paragraph","content":[{"type":"text","text":"Second top"}]}
15411 ]}
15412 ]}]}"#;
15413 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15414 let md = adf_to_markdown(&doc).unwrap();
15415 let round_tripped = markdown_to_adf(&md).unwrap();
15416
15417 let outer = &round_tripped.content[0];
15419 assert_eq!(outer.node_type, "orderedList");
15420 assert_eq!(
15421 outer.attrs.as_ref().unwrap()["order"],
15422 1,
15423 "explicit order=1 must be preserved via trailing {{order=1}} (issue #547)"
15424 );
15425 let outer_items = outer.content.as_ref().unwrap();
15426 assert_eq!(
15427 outer_items.len(),
15428 2,
15429 "outer list should have 2 items, got {}",
15430 outer_items.len()
15431 );
15432
15433 let first_item = &outer_items[0];
15435 let first_content = first_item.content.as_ref().unwrap();
15436 assert_eq!(
15437 first_content.len(),
15438 2,
15439 "first listItem should have paragraph + nested list, got {}",
15440 first_content.len()
15441 );
15442 assert_eq!(first_content[0].node_type, "paragraph");
15443 assert_eq!(first_content[1].node_type, "orderedList");
15444 let nested_items = first_content[1].content.as_ref().unwrap();
15445 assert_eq!(nested_items.len(), 2, "nested list should have 2 items");
15446 }
15447
15448 #[test]
15449 fn nested_ordered_list_markdown_parsing() {
15450 let md = "1. Top level\n 1. Nested 1\n 2. Nested 2\n2. Second top\n";
15452 let doc = markdown_to_adf(md).unwrap();
15453 let outer = &doc.content[0];
15454 assert_eq!(outer.node_type, "orderedList");
15455 let outer_items = outer.content.as_ref().unwrap();
15456 assert_eq!(outer_items.len(), 2, "should have 2 top-level items");
15457
15458 let first_content = outer_items[0].content.as_ref().unwrap();
15459 assert_eq!(
15460 first_content.len(),
15461 2,
15462 "first item should have paragraph + nested list"
15463 );
15464 assert_eq!(first_content[1].node_type, "orderedList");
15465 }
15466
15467 #[test]
15468 fn bullet_list_nested_inside_ordered_list() {
15469 let md = "1. Ordered item\n - Bullet child 1\n - Bullet child 2\n2. Second ordered\n";
15471 let doc = markdown_to_adf(md).unwrap();
15472 let outer = &doc.content[0];
15473 assert_eq!(outer.node_type, "orderedList");
15474 let outer_items = outer.content.as_ref().unwrap();
15475 assert_eq!(outer_items.len(), 2);
15476
15477 let first_content = outer_items[0].content.as_ref().unwrap();
15478 assert_eq!(
15479 first_content.len(),
15480 2,
15481 "first item should have paragraph + nested list"
15482 );
15483 assert_eq!(first_content[1].node_type, "bulletList");
15484 let sub_items = first_content[1].content.as_ref().unwrap();
15485 assert_eq!(sub_items.len(), 2, "nested bullet list should have 2 items");
15486 }
15487
15488 #[test]
15489 fn ordered_list_order_attr_one_is_elided() {
15490 let md = "1. A\n2. B\n";
15494 let doc = markdown_to_adf(md).unwrap();
15495 assert!(
15496 doc.content[0].attrs.is_none(),
15497 "attrs should be elided when order=1"
15498 );
15499
15500 let md2 = adf_to_markdown(&doc).unwrap();
15502 let doc2 = markdown_to_adf(&md2).unwrap();
15503 assert!(
15504 doc2.content[0].attrs.is_none(),
15505 "attrs should remain elided after round-trip"
15506 );
15507 }
15508
15509 #[test]
15510 fn issue_547_ordered_list_no_attrs_roundtrip_byte_identical() {
15511 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"}]}]}]}]}"#;
15514 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15515 let md = adf_to_markdown(&doc).unwrap();
15516 let rt = markdown_to_adf(&md).unwrap();
15517 assert!(
15518 rt.content[0].attrs.is_none(),
15519 "round-tripped orderedList should not have attrs, got: {:?}",
15520 rt.content[0].attrs
15521 );
15522
15523 let rt_json = serde_json::to_string(&rt).unwrap();
15525 assert!(
15526 !rt_json.contains("\"order\""),
15527 "round-tripped JSON should not contain \"order\", got: {rt_json}"
15528 );
15529 }
15530
15531 fn assert_roundtrip_byte_identical(adf_json: &str) {
15537 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15538 let md = adf_to_markdown(&doc).unwrap();
15539 let rt = markdown_to_adf(&md).unwrap();
15540
15541 let canonical_src: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15542 let canonical_rt: serde_json::Value =
15543 serde_json::from_str(&serde_json::to_string(&rt).unwrap()).unwrap();
15544 assert_eq!(
15545 canonical_src, canonical_rt,
15546 "round-trip diverged\n src: {canonical_src}\n rt: {canonical_rt}\n md: {md:?}"
15547 );
15548 }
15549
15550 #[test]
15551 fn issue_547_single_item_no_attrs_roundtrip() {
15552 assert_roundtrip_byte_identical(
15553 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"only"}]}]}]}]}"#,
15554 );
15555 }
15556
15557 #[test]
15558 fn issue_547_many_items_no_attrs_roundtrip() {
15559 assert_roundtrip_byte_identical(
15560 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"}]}]}]}]}"#,
15561 );
15562 }
15563
15564 #[test]
15565 fn issue_547_non_default_order_preserved() {
15566 assert_roundtrip_byte_identical(
15569 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":5},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"fifth"}]}]}]}]}"#,
15570 );
15571 }
15572
15573 #[test]
15574 fn issue_547_nested_ordered_in_ordered_no_attrs_roundtrip() {
15575 assert_roundtrip_byte_identical(
15577 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"}]}]}]}]}]}]}"#,
15578 );
15579 }
15580
15581 #[test]
15582 fn issue_547_ordered_nested_in_bullet_no_attrs_roundtrip() {
15583 assert_roundtrip_byte_identical(
15584 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"}]}]}]}]}]}]}"#,
15585 );
15586 }
15587
15588 #[test]
15589 fn issue_547_bullet_nested_in_ordered_no_attrs_roundtrip() {
15590 assert_roundtrip_byte_identical(
15591 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"}]}]}]}]}]}]}"#,
15592 );
15593 }
15594
15595 #[test]
15596 fn issue_547_ordered_list_between_paragraphs_roundtrip() {
15597 assert_roundtrip_byte_identical(
15598 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"}]}]}"#,
15599 );
15600 }
15601
15602 #[test]
15603 fn issue_547_ordered_list_with_marked_text_roundtrip() {
15604 assert_roundtrip_byte_identical(
15605 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"bold","marks":[{"type":"strong"}]}]}]}]}]}"#,
15606 );
15607 }
15608
15609 #[test]
15610 fn issue_547_ordered_list_with_link_roundtrip() {
15611 assert_roundtrip_byte_identical(
15612 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"}}]}]}]}]}]}"#,
15613 );
15614 }
15615
15616 #[test]
15617 fn issue_547_ordered_list_with_hardbreak_roundtrip() {
15618 assert_roundtrip_byte_identical(
15619 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"}]}]}]}]}"#,
15620 );
15621 }
15622
15623 #[test]
15624 fn issue_547_triple_nested_ordered_roundtrip() {
15625 assert_roundtrip_byte_identical(
15626 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"}]}]}]}]}]}]}]}]}"#,
15627 );
15628 }
15629
15630 #[test]
15631 fn issue_547_ordered_list_heading_rule_mix_roundtrip() {
15632 assert_roundtrip_byte_identical(
15633 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"}]}"#,
15634 );
15635 }
15636
15637 #[test]
15638 fn issue_547_ordered_list_listitem_localid_roundtrip() {
15639 assert_roundtrip_byte_identical(
15641 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]}]}"#,
15642 );
15643 }
15644
15645 #[test]
15646 fn issue_547_explicit_order_one_preserved_roundtrip() {
15647 assert_roundtrip_byte_identical(
15652 r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"First item"}]}]}]}]}"#,
15653 );
15654 }
15655
15656 #[test]
15657 fn issue_547_explicit_order_one_nested_preserved_roundtrip() {
15658 assert_roundtrip_byte_identical(
15661 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"}]}]}]}]}]}]}"#,
15662 );
15663 }
15664
15665 #[test]
15666 fn issue_547_mixed_explicit_and_implicit_order_roundtrip() {
15667 assert_roundtrip_byte_identical(
15670 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"}]}]}]}]}"#,
15671 );
15672 }
15673
15674 #[test]
15675 fn issue_547_explicit_order_one_with_listitem_localid_roundtrip() {
15676 assert_roundtrip_byte_identical(
15680 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"}]}]}]}]}"#,
15681 );
15682 }
15683
15684 #[test]
15685 fn issue_547_order_attr_signal_appears_only_for_explicit_one() {
15686 let no_attrs = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
15690 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"}]}]}]}]}"#;
15691 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"}]}]}]}]}"#;
15692
15693 let md_no =
15694 adf_to_markdown(&serde_json::from_str::<AdfDocument>(no_attrs).unwrap()).unwrap();
15695 let md_one =
15696 adf_to_markdown(&serde_json::from_str::<AdfDocument>(explicit_one).unwrap()).unwrap();
15697 let md_five =
15698 adf_to_markdown(&serde_json::from_str::<AdfDocument>(order_five).unwrap()).unwrap();
15699
15700 assert!(
15701 !md_no.contains("{order="),
15702 "no-attrs source must not emit order signal, got: {md_no:?}"
15703 );
15704 assert!(
15705 md_one.contains("{order=1}"),
15706 "explicit order=1 must emit trailing signal, got: {md_one:?}"
15707 );
15708 assert!(
15709 !md_five.contains("{order="),
15710 "order=5 is already encoded by marker; must not emit signal, got: {md_five:?}"
15711 );
15712 }
15713
15714 #[test]
15717 fn file_media_roundtrip() {
15718 let adf_doc = serde_json::json!({
15720 "type": "doc",
15721 "version": 1,
15722 "content": [{
15723 "type": "mediaSingle",
15724 "attrs": {"layout": "center"},
15725 "content": [{
15726 "type": "media",
15727 "attrs": {
15728 "type": "file",
15729 "id": "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d",
15730 "collection": "contentId-8220672100",
15731 "height": 56,
15732 "width": 312,
15733 "alt": "Screenshot.png"
15734 }
15735 }]
15736 }]
15737 });
15738 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15739 let md = adf_to_markdown(&doc).unwrap();
15740 assert!(
15741 md.contains("type=file"),
15742 "expected type=file in markdown, got: {md}"
15743 );
15744 assert!(
15745 md.contains("id=6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d"),
15746 "expected id in markdown, got: {md}"
15747 );
15748 assert!(
15749 md.contains("collection=contentId-8220672100"),
15750 "expected collection in markdown, got: {md}"
15751 );
15752 let doc2 = markdown_to_adf(&md).unwrap();
15754 let ms = &doc2.content[0];
15755 assert_eq!(ms.node_type, "mediaSingle");
15756 let media = &ms.content.as_ref().unwrap()[0];
15757 assert_eq!(media.node_type, "media");
15758 let attrs = media.attrs.as_ref().unwrap();
15759 assert_eq!(attrs["type"], "file");
15760 assert_eq!(attrs["id"], "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d");
15761 assert_eq!(attrs["collection"], "contentId-8220672100");
15762 assert_eq!(attrs["height"], 56);
15763 assert_eq!(attrs["width"], 312);
15764 assert_eq!(attrs["alt"], "Screenshot.png");
15765 }
15766
15767 #[test]
15771 fn file_media_roundtrip_issue_550_reproducer() {
15772 let adf_json = r#"{
15773 "version": 1,
15774 "type": "doc",
15775 "content": [
15776 {
15777 "type": "mediaSingle",
15778 "attrs": {"layout": "center"},
15779 "content": [
15780 {
15781 "type": "media",
15782 "attrs": {
15783 "type": "file",
15784 "id": "abc-123-def-456",
15785 "collection": "my-collection",
15786 "width": 941,
15787 "height": 655
15788 }
15789 }
15790 ]
15791 }
15792 ]
15793 }"#;
15794 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15795 let md = adf_to_markdown(&doc).unwrap();
15796 let rt = markdown_to_adf(&md).unwrap();
15797 let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15798 let actual = serde_json::to_value(&rt).unwrap();
15799 assert_eq!(
15800 actual, expected,
15801 "roundtrip should preserve file media attrs; md was:\n{md}"
15802 );
15803 }
15804
15805 #[test]
15811 fn file_media_roundtrip_id_with_spaces() {
15812 let adf_json = r#"{
15813 "version": 1,
15814 "type": "doc",
15815 "content": [
15816 {
15817 "type": "mediaSingle",
15818 "attrs": {"layout": "center"},
15819 "content": [
15820 {
15821 "type": "media",
15822 "attrs": {
15823 "type": "file",
15824 "id": "abc 123 def 456",
15825 "collection": "my-collection",
15826 "width": 800,
15827 "height": 600
15828 }
15829 }
15830 ]
15831 }
15832 ]
15833 }"#;
15834 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15835 let md = adf_to_markdown(&doc).unwrap();
15836 assert!(
15837 md.contains(r#"id="abc 123 def 456""#),
15838 "id with spaces should be quoted in JFM, got:\n{md}"
15839 );
15840 let rt = markdown_to_adf(&md).unwrap();
15841 let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15842 let actual = serde_json::to_value(&rt).unwrap();
15843 assert_eq!(
15844 actual, expected,
15845 "space-containing id must round-trip; md was:\n{md}"
15846 );
15847 }
15848
15849 #[test]
15851 fn file_media_roundtrip_collection_with_spaces() {
15852 let adf_json = r#"{
15853 "version": 1,
15854 "type": "doc",
15855 "content": [
15856 {
15857 "type": "mediaSingle",
15858 "attrs": {"layout": "center"},
15859 "content": [
15860 {
15861 "type": "media",
15862 "attrs": {
15863 "type": "file",
15864 "id": "abc-123",
15865 "collection": "my collection with spaces"
15866 }
15867 }
15868 ]
15869 }
15870 ]
15871 }"#;
15872 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15873 let md = adf_to_markdown(&doc).unwrap();
15874 let rt = markdown_to_adf(&md).unwrap();
15875 let media = &rt.content[0].content.as_ref().unwrap()[0];
15876 assert_eq!(
15877 media.attrs.as_ref().unwrap()["collection"],
15878 "my collection with spaces"
15879 );
15880 }
15881
15882 #[test]
15884 fn file_media_roundtrip_occurrence_key_with_spaces() {
15885 let adf_json = r#"{
15886 "version": 1,
15887 "type": "doc",
15888 "content": [
15889 {
15890 "type": "mediaSingle",
15891 "attrs": {"layout": "center"},
15892 "content": [
15893 {
15894 "type": "media",
15895 "attrs": {
15896 "type": "file",
15897 "id": "x",
15898 "collection": "y",
15899 "occurrenceKey": "key with spaces"
15900 }
15901 }
15902 ]
15903 }
15904 ]
15905 }"#;
15906 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15907 let md = adf_to_markdown(&doc).unwrap();
15908 let rt = markdown_to_adf(&md).unwrap();
15909 let media = &rt.content[0].content.as_ref().unwrap()[0];
15910 assert_eq!(
15911 media.attrs.as_ref().unwrap()["occurrenceKey"],
15912 "key with spaces"
15913 );
15914 }
15915
15916 #[test]
15918 fn file_media_roundtrip_id_with_quote_char() {
15919 let adf_json = r#"{
15920 "version": 1,
15921 "type": "doc",
15922 "content": [
15923 {
15924 "type": "mediaSingle",
15925 "attrs": {"layout": "center"},
15926 "content": [
15927 {
15928 "type": "media",
15929 "attrs": {
15930 "type": "file",
15931 "id": "a\"b\"c",
15932 "collection": "col"
15933 }
15934 }
15935 ]
15936 }
15937 ]
15938 }"#;
15939 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15940 let md = adf_to_markdown(&doc).unwrap();
15941 let rt = markdown_to_adf(&md).unwrap();
15942 let media = &rt.content[0].content.as_ref().unwrap()[0];
15943 assert_eq!(media.attrs.as_ref().unwrap()["id"], "a\"b\"c");
15944 }
15945
15946 #[test]
15949 fn media_inline_roundtrip_id_with_spaces() {
15950 let adf_json = r#"{
15951 "version": 1,
15952 "type": "doc",
15953 "content": [
15954 {
15955 "type": "paragraph",
15956 "content": [
15957 {"type": "text", "text": "before "},
15958 {
15959 "type": "mediaInline",
15960 "attrs": {
15961 "type": "file",
15962 "id": "a b c",
15963 "collection": "my col"
15964 }
15965 },
15966 {"type": "text", "text": " after"}
15967 ]
15968 }
15969 ]
15970 }"#;
15971 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15972 let md = adf_to_markdown(&doc).unwrap();
15973 let rt = markdown_to_adf(&md).unwrap();
15974 let inline = &rt.content[0].content.as_ref().unwrap()[1];
15975 assert_eq!(inline.node_type, "mediaInline");
15976 let attrs = inline.attrs.as_ref().unwrap();
15977 assert_eq!(attrs["id"], "a b c");
15978 assert_eq!(attrs["collection"], "my col");
15979 }
15980
15981 #[test]
15984 fn file_media_roundtrip_preserves_occurrence_key() {
15985 let adf_json = r#"{
15986 "version": 1,
15987 "type": "doc",
15988 "content": [
15989 {
15990 "type": "mediaSingle",
15991 "attrs": {"layout": "center"},
15992 "content": [
15993 {
15994 "type": "media",
15995 "attrs": {
15996 "type": "file",
15997 "id": "abc-123",
15998 "collection": "my-collection",
15999 "occurrenceKey": "unique-key-xyz",
16000 "width": 200,
16001 "height": 100
16002 }
16003 }
16004 ]
16005 }
16006 ]
16007 }"#;
16008 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16009 let md = adf_to_markdown(&doc).unwrap();
16010 assert!(
16011 md.contains("occurrenceKey=unique-key-xyz"),
16012 "expected occurrenceKey in markdown, got: {md}"
16013 );
16014 let rt = markdown_to_adf(&md).unwrap();
16015 let media = &rt.content[0].content.as_ref().unwrap()[0];
16016 let attrs = media.attrs.as_ref().unwrap();
16017 assert_eq!(attrs["occurrenceKey"], "unique-key-xyz");
16018 assert_eq!(attrs["type"], "file");
16019 assert_eq!(attrs["id"], "abc-123");
16020 assert_eq!(attrs["collection"], "my-collection");
16021 }
16022
16023 #[test]
16026 fn media_single_caption_adf_to_markdown() {
16027 let adf_doc = serde_json::json!({
16028 "type": "doc",
16029 "version": 1,
16030 "content": [{
16031 "type": "mediaSingle",
16032 "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
16033 "content": [
16034 {
16035 "type": "media",
16036 "attrs": {
16037 "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
16038 "type": "file",
16039 "collection": "contentId-123456",
16040 "width": 800,
16041 "height": 600
16042 }
16043 },
16044 {
16045 "type": "caption",
16046 "content": [{"type": "text", "text": "An image caption here"}]
16047 }
16048 ]
16049 }]
16050 });
16051 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16052 let md = adf_to_markdown(&doc).unwrap();
16053 assert!(
16054 md.contains(":::caption"),
16055 "expected :::caption in markdown, got: {md}"
16056 );
16057 assert!(
16058 md.contains("An image caption here"),
16059 "expected caption text in markdown, got: {md}"
16060 );
16061 }
16062
16063 #[test]
16064 fn media_single_caption_markdown_to_adf() {
16065 let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nAn image caption here\n:::\n";
16066 let doc = markdown_to_adf(md).unwrap();
16067 let ms = &doc.content[0];
16068 assert_eq!(ms.node_type, "mediaSingle");
16069 let content = ms.content.as_ref().unwrap();
16070 assert_eq!(content.len(), 2, "expected media + caption children");
16071 assert_eq!(content[0].node_type, "media");
16072 assert_eq!(content[1].node_type, "caption");
16073 let caption_content = content[1].content.as_ref().unwrap();
16074 assert_eq!(
16075 caption_content[0].text.as_deref(),
16076 Some("An image caption here")
16077 );
16078 }
16079
16080 #[test]
16081 fn media_single_caption_round_trip() {
16082 let adf_doc = serde_json::json!({
16084 "type": "doc",
16085 "version": 1,
16086 "content": [{
16087 "type": "mediaSingle",
16088 "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
16089 "content": [
16090 {
16091 "type": "media",
16092 "attrs": {
16093 "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
16094 "type": "file",
16095 "collection": "contentId-123456",
16096 "width": 800,
16097 "height": 600
16098 }
16099 },
16100 {
16101 "type": "caption",
16102 "content": [{"type": "text", "text": "An image caption here"}]
16103 }
16104 ]
16105 }]
16106 });
16107 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16108 let md = adf_to_markdown(&doc).unwrap();
16109 let doc2 = markdown_to_adf(&md).unwrap();
16110 let ms = &doc2.content[0];
16111 assert_eq!(ms.node_type, "mediaSingle");
16112 let content = ms.content.as_ref().unwrap();
16113 assert_eq!(
16114 content.len(),
16115 2,
16116 "expected media + caption after round-trip"
16117 );
16118 assert_eq!(content[1].node_type, "caption");
16119 let caption_content = content[1].content.as_ref().unwrap();
16120 assert_eq!(
16121 caption_content[0].text.as_deref(),
16122 Some("An image caption here")
16123 );
16124 }
16125
16126 #[test]
16127 fn media_single_caption_with_inline_marks() {
16128 let adf_doc = serde_json::json!({
16129 "type": "doc",
16130 "version": 1,
16131 "content": [{
16132 "type": "mediaSingle",
16133 "attrs": {"layout": "center"},
16134 "content": [
16135 {
16136 "type": "media",
16137 "attrs": {"type": "external", "url": "https://example.com/img.png"}
16138 },
16139 {
16140 "type": "caption",
16141 "content": [
16142 {"type": "text", "text": "A "},
16143 {"type": "text", "text": "bold", "marks": [{"type": "strong"}]},
16144 {"type": "text", "text": " caption"}
16145 ]
16146 }
16147 ]
16148 }]
16149 });
16150 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16151 let md = adf_to_markdown(&doc).unwrap();
16152 assert!(
16153 md.contains("**bold**"),
16154 "expected bold in caption, got: {md}"
16155 );
16156
16157 let doc2 = markdown_to_adf(&md).unwrap();
16158 let content = doc2.content[0].content.as_ref().unwrap();
16159 assert_eq!(content.len(), 2, "expected media + caption");
16160 assert_eq!(content[1].node_type, "caption");
16161 let caption_inlines = content[1].content.as_ref().unwrap();
16162 let bold_node = caption_inlines
16163 .iter()
16164 .find(|n| n.text.as_deref() == Some("bold"))
16165 .unwrap();
16166 let marks = bold_node.marks.as_ref().unwrap();
16167 assert_eq!(marks[0].mark_type, "strong");
16168 }
16169
16170 #[test]
16171 fn media_single_no_caption_unaffected() {
16172 let adf_doc = serde_json::json!({
16174 "type": "doc",
16175 "version": 1,
16176 "content": [{
16177 "type": "mediaSingle",
16178 "attrs": {"layout": "center"},
16179 "content": [{
16180 "type": "media",
16181 "attrs": {"type": "external", "url": "https://example.com/img.png"}
16182 }]
16183 }]
16184 });
16185 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16186 let md = adf_to_markdown(&doc).unwrap();
16187 assert!(
16188 !md.contains(":::caption"),
16189 "should not emit caption when none present"
16190 );
16191 let doc2 = markdown_to_adf(&md).unwrap();
16192 let content = doc2.content[0].content.as_ref().unwrap();
16193 assert_eq!(content.len(), 1, "should only have media child");
16194 assert_eq!(content[0].node_type, "media");
16195 }
16196
16197 #[test]
16198 fn media_single_empty_caption_round_trip() {
16199 let adf_doc = serde_json::json!({
16201 "type": "doc",
16202 "version": 1,
16203 "content": [{
16204 "type": "mediaSingle",
16205 "attrs": {"layout": "center"},
16206 "content": [
16207 {
16208 "type": "media",
16209 "attrs": {"type": "external", "url": "https://example.com/img.png"}
16210 },
16211 {
16212 "type": "caption"
16213 }
16214 ]
16215 }]
16216 });
16217 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16218 let md = adf_to_markdown(&doc).unwrap();
16219 assert!(
16220 md.contains(":::caption"),
16221 "expected :::caption even for empty caption, got: {md}"
16222 );
16223 assert!(
16224 md.contains(":::\n"),
16225 "expected closing ::: fence, got: {md}"
16226 );
16227 }
16228
16229 #[test]
16230 fn media_single_external_caption_round_trip() {
16231 let md = "\n:::caption\nImage description\n:::\n";
16233 let doc = markdown_to_adf(md).unwrap();
16234 let ms = &doc.content[0];
16235 assert_eq!(ms.node_type, "mediaSingle");
16236 let content = ms.content.as_ref().unwrap();
16237 assert_eq!(content.len(), 2);
16238 assert_eq!(content[0].node_type, "media");
16239 assert_eq!(content[1].node_type, "caption");
16240
16241 let md2 = adf_to_markdown(&doc).unwrap();
16242 let doc2 = markdown_to_adf(&md2).unwrap();
16243 let content2 = doc2.content[0].content.as_ref().unwrap();
16244 assert_eq!(content2.len(), 2);
16245 assert_eq!(content2[1].node_type, "caption");
16246 let caption_text = content2[1].content.as_ref().unwrap();
16247 assert_eq!(caption_text[0].text.as_deref(), Some("Image description"));
16248 }
16249
16250 #[test]
16253 fn media_single_caption_localid_roundtrip() {
16254 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"}]}]}]}"#;
16255 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16256 let md = adf_to_markdown(&doc).unwrap();
16257 assert!(
16258 md.contains("localId=9da8c2104471"),
16259 "caption localId should appear in markdown: {md}"
16260 );
16261 let rt = markdown_to_adf(&md).unwrap();
16262 let content = rt.content[0].content.as_ref().unwrap();
16263 let caption = &content[1];
16264 assert_eq!(caption.node_type, "caption");
16265 assert_eq!(
16266 caption.attrs.as_ref().unwrap()["localId"],
16267 "9da8c2104471",
16268 "caption localId should round-trip"
16269 );
16270 }
16271
16272 #[test]
16273 fn media_single_caption_without_localid() {
16274 let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nPlain caption\n:::\n";
16275 let doc = markdown_to_adf(md).unwrap();
16276 let caption = &doc.content[0].content.as_ref().unwrap()[1];
16277 assert_eq!(caption.node_type, "caption");
16278 assert!(
16279 caption.attrs.is_none(),
16280 "caption without localId should not gain attrs"
16281 );
16282 let md2 = adf_to_markdown(&doc).unwrap();
16283 assert!(
16284 !md2.contains("localId"),
16285 "no localId should appear in output: {md2}"
16286 );
16287 }
16288
16289 #[test]
16290 fn media_single_caption_localid_stripped_when_option_set() {
16291 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"}]}]}]}"#;
16292 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16293 let opts = RenderOptions {
16294 strip_local_ids: true,
16295 ..Default::default()
16296 };
16297 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16298 assert!(!md.contains("localId"), "localId should be stripped: {md}");
16299 }
16300
16301 #[test]
16302 fn table_width_roundtrip() {
16303 let adf_doc = serde_json::json!({
16305 "type": "doc",
16306 "version": 1,
16307 "content": [{
16308 "type": "table",
16309 "attrs": {"layout": "default", "width": 760.0},
16310 "content": [{
16311 "type": "tableRow",
16312 "content": [{
16313 "type": "tableHeader",
16314 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16315 }]
16316 }]
16317 }]
16318 });
16319 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16320 let md = adf_to_markdown(&doc).unwrap();
16321 assert!(
16322 md.contains("width=760.0"),
16323 "expected width=760.0 in markdown (float preserved), got: {md}"
16324 );
16325 let doc2 = markdown_to_adf(&md).unwrap();
16327 let table = &doc2.content[0];
16328 assert_eq!(table.node_type, "table");
16329 let table_attrs = table.attrs.as_ref().unwrap();
16330 assert_eq!(table_attrs["width"], 760.0);
16331 assert!(
16332 table_attrs["width"].is_f64(),
16333 "expected float width to be preserved as f64, got: {:?}",
16334 table_attrs["width"]
16335 );
16336 }
16337
16338 #[test]
16339 fn table_integer_width_roundtrip_preserves_integer() {
16340 let adf_doc = serde_json::json!({
16343 "type": "doc",
16344 "version": 1,
16345 "content": [{
16346 "type": "table",
16347 "attrs": {
16348 "isNumberColumnEnabled": false,
16349 "layout": "center",
16350 "localId": "abc-123",
16351 "width": 1420
16352 },
16353 "content": [{
16354 "type": "tableRow",
16355 "content": [{
16356 "type": "tableCell",
16357 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Cell"}]}]
16358 }]
16359 }]
16360 }]
16361 });
16362 let doc: crate::atlassian::adf::AdfDocument =
16363 serde_json::from_value(adf_doc.clone()).unwrap();
16364 let md = adf_to_markdown(&doc).unwrap();
16365 assert!(
16366 md.contains("width=1420"),
16367 "expected width=1420 in markdown, got: {md}"
16368 );
16369 assert!(
16370 !md.contains("width=1420.0"),
16371 "integer width should not be rendered with decimal: {md}"
16372 );
16373
16374 let doc2 = markdown_to_adf(&md).unwrap();
16375 let table = &doc2.content[0];
16376 assert_eq!(table.node_type, "table");
16377 let table_attrs = table.attrs.as_ref().unwrap();
16378 assert_eq!(table_attrs["width"], 1420);
16379 assert!(
16380 table_attrs["width"].is_u64() || table_attrs["width"].is_i64(),
16381 "width should remain an integer, got: {:?}",
16382 table_attrs["width"]
16383 );
16384 assert!(
16385 !table_attrs["width"].is_f64(),
16386 "width should not be a float, got: {:?}",
16387 table_attrs["width"]
16388 );
16389
16390 let roundtripped = serde_json::to_value(&doc2).unwrap();
16392 let orig_width = &adf_doc["content"][0]["attrs"]["width"];
16393 let rt_width = &roundtripped["content"][0]["attrs"]["width"];
16394 assert_eq!(
16395 orig_width, rt_width,
16396 "width value must roundtrip byte-for-byte"
16397 );
16398 }
16399
16400 #[test]
16401 fn table_fractional_width_roundtrip() {
16402 let adf_doc = serde_json::json!({
16404 "type": "doc",
16405 "version": 1,
16406 "content": [{
16407 "type": "table",
16408 "attrs": {"layout": "default", "width": 760.5},
16409 "content": [{
16410 "type": "tableRow",
16411 "content": [{
16412 "type": "tableHeader",
16413 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16414 }]
16415 }]
16416 }]
16417 });
16418 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16419 let md = adf_to_markdown(&doc).unwrap();
16420 assert!(
16421 md.contains("width=760.5"),
16422 "expected width=760.5 in markdown, got: {md}"
16423 );
16424 let doc2 = markdown_to_adf(&md).unwrap();
16425 let table_attrs = doc2.content[0].attrs.as_ref().unwrap();
16426 assert_eq!(table_attrs["width"], 760.5);
16427 assert!(table_attrs["width"].is_f64());
16428 }
16429
16430 #[test]
16431 fn pipe_table_integer_width_roundtrip() {
16432 let md = "| A | B |\n|---|---|\n| 1 | 2 |\n{layout=default width=1420}\n";
16434 let doc = markdown_to_adf(md).unwrap();
16435 let table = &doc.content[0];
16436 assert_eq!(table.node_type, "table");
16437 let attrs = table.attrs.as_ref().unwrap();
16438 assert_eq!(attrs["width"], 1420);
16439 assert!(
16440 attrs["width"].is_u64() || attrs["width"].is_i64(),
16441 "pipe-table width must stay integer, got: {:?}",
16442 attrs["width"]
16443 );
16444 }
16445
16446 #[test]
16447 fn file_media_width_type_roundtrip() {
16448 let adf_doc = serde_json::json!({
16450 "type": "doc",
16451 "version": 1,
16452 "content": [{
16453 "type": "mediaSingle",
16454 "attrs": {"layout": "center", "width": 312, "widthType": "pixel"},
16455 "content": [{
16456 "type": "media",
16457 "attrs": {
16458 "type": "file",
16459 "id": "abc123",
16460 "collection": "contentId-999",
16461 "height": 56,
16462 "width": 312
16463 }
16464 }]
16465 }]
16466 });
16467 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16468 let md = adf_to_markdown(&doc).unwrap();
16469 assert!(
16470 md.contains("widthType=pixel"),
16471 "expected widthType=pixel in markdown, got: {md}"
16472 );
16473 let doc2 = markdown_to_adf(&md).unwrap();
16474 let ms = &doc2.content[0];
16475 let ms_attrs = ms.attrs.as_ref().unwrap();
16476 assert_eq!(ms_attrs["widthType"], "pixel");
16477 assert_eq!(ms_attrs["width"], 312);
16478 }
16479
16480 #[test]
16481 fn file_media_pixel_width_above_100_passes_validation() {
16482 let md = "![alt](){type=file id=11111111-2222-3333-4444-555555555555 collection=contentId-98765 height=904 width=900 mediaWidth=900 widthType=pixel}\n";
16491 let adf = markdown_to_adf(md).unwrap();
16492
16493 let ms_attrs = adf.content[0].attrs.as_ref().unwrap();
16494 assert_eq!(ms_attrs["width"], 900);
16495 assert_eq!(ms_attrs["widthType"], "pixel");
16496
16497 crate::atlassian::adf_validated::validate(&adf).unwrap();
16498 }
16499
16500 #[test]
16501 fn file_media_mode_roundtrip() {
16502 let adf_doc = serde_json::json!({
16504 "type": "doc",
16505 "version": 1,
16506 "content": [{
16507 "type": "mediaSingle",
16508 "attrs": {"layout": "wide", "mode": "wide", "width": 1200},
16509 "content": [{
16510 "type": "media",
16511 "attrs": {
16512 "type": "file",
16513 "id": "abc123",
16514 "collection": "test",
16515 "width": 1200,
16516 "height": 600
16517 }
16518 }]
16519 }]
16520 });
16521 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16522 let md = adf_to_markdown(&doc).unwrap();
16523 assert!(
16524 md.contains("mode=wide"),
16525 "expected mode=wide in markdown, got: {md}"
16526 );
16527 let doc2 = markdown_to_adf(&md).unwrap();
16528 let ms = &doc2.content[0];
16529 let ms_attrs = ms.attrs.as_ref().unwrap();
16530 assert_eq!(ms_attrs["mode"], "wide");
16531 assert_eq!(ms_attrs["layout"], "wide");
16532 assert_eq!(ms_attrs["width"], 1200);
16533 }
16534
16535 #[test]
16536 fn external_media_mode_roundtrip() {
16537 let adf_doc = serde_json::json!({
16539 "type": "doc",
16540 "version": 1,
16541 "content": [{
16542 "type": "mediaSingle",
16543 "attrs": {"layout": "wide", "mode": "wide"},
16544 "content": [{
16545 "type": "media",
16546 "attrs": {
16547 "type": "external",
16548 "url": "https://example.com/image.png"
16549 }
16550 }]
16551 }]
16552 });
16553 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16554 let md = adf_to_markdown(&doc).unwrap();
16555 assert!(
16556 md.contains("mode=wide"),
16557 "expected mode=wide in markdown, got: {md}"
16558 );
16559 let doc2 = markdown_to_adf(&md).unwrap();
16560 let ms = &doc2.content[0];
16561 let ms_attrs = ms.attrs.as_ref().unwrap();
16562 assert_eq!(ms_attrs["mode"], "wide");
16563 assert_eq!(ms_attrs["layout"], "wide");
16564 }
16565
16566 #[test]
16567 fn media_mode_only_roundtrip() {
16568 let adf_doc = serde_json::json!({
16570 "type": "doc",
16571 "version": 1,
16572 "content": [{
16573 "type": "mediaSingle",
16574 "attrs": {"layout": "center", "mode": "default"},
16575 "content": [{
16576 "type": "media",
16577 "attrs": {
16578 "type": "external",
16579 "url": "https://example.com/image.png"
16580 }
16581 }]
16582 }]
16583 });
16584 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16585 let md = adf_to_markdown(&doc).unwrap();
16586 assert!(
16587 md.contains("mode=default"),
16588 "expected mode=default in markdown, got: {md}"
16589 );
16590 let doc2 = markdown_to_adf(&md).unwrap();
16591 let ms = &doc2.content[0];
16592 let ms_attrs = ms.attrs.as_ref().unwrap();
16593 assert_eq!(ms_attrs["mode"], "default");
16594 }
16595
16596 #[test]
16597 fn file_media_hex_localid_roundtrip() {
16598 let adf_doc = serde_json::json!({
16600 "type": "doc",
16601 "version": 1,
16602 "content": [{
16603 "type": "mediaSingle",
16604 "attrs": {"layout": "wide", "width": 1200, "widthType": "pixel"},
16605 "content": [{
16606 "type": "media",
16607 "attrs": {
16608 "type": "file",
16609 "id": "eb7a9c3b-314e-4458-8200-4b22b67b122e",
16610 "collection": "contentId-123",
16611 "height": 484,
16612 "width": 915,
16613 "alt": "image.png",
16614 "localId": "0e79f58ac382"
16615 }
16616 }]
16617 }]
16618 });
16619 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16620 let md = adf_to_markdown(&doc).unwrap();
16621 assert!(
16622 md.contains("localId=0e79f58ac382"),
16623 "expected localId=0e79f58ac382 in markdown, got: {md}"
16624 );
16625 let doc2 = markdown_to_adf(&md).unwrap();
16626 let ms = &doc2.content[0];
16627 let media = &ms.content.as_ref().unwrap()[0];
16628 let attrs = media.attrs.as_ref().unwrap();
16629 assert_eq!(attrs["localId"], "0e79f58ac382");
16630 }
16631
16632 #[test]
16633 fn file_media_uuid_localid_roundtrip() {
16634 let adf_doc = serde_json::json!({
16636 "type": "doc",
16637 "version": 1,
16638 "content": [{
16639 "type": "mediaSingle",
16640 "attrs": {"layout": "center"},
16641 "content": [{
16642 "type": "media",
16643 "attrs": {
16644 "type": "file",
16645 "id": "abc-123",
16646 "collection": "contentId-456",
16647 "height": 100,
16648 "width": 200,
16649 "localId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
16650 }
16651 }]
16652 }]
16653 });
16654 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16655 let md = adf_to_markdown(&doc).unwrap();
16656 assert!(
16657 md.contains("localId=a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
16658 "expected UUID localId in markdown, got: {md}"
16659 );
16660 let doc2 = markdown_to_adf(&md).unwrap();
16661 let media = &doc2.content[0].content.as_ref().unwrap()[0];
16662 let attrs = media.attrs.as_ref().unwrap();
16663 assert_eq!(attrs["localId"], "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
16664 }
16665
16666 #[test]
16667 fn file_media_null_uuid_localid_stripped() {
16668 let adf_doc = serde_json::json!({
16670 "type": "doc",
16671 "version": 1,
16672 "content": [{
16673 "type": "mediaSingle",
16674 "attrs": {"layout": "center"},
16675 "content": [{
16676 "type": "media",
16677 "attrs": {
16678 "type": "file",
16679 "id": "abc-123",
16680 "collection": "contentId-456",
16681 "height": 100,
16682 "width": 200,
16683 "localId": "00000000-0000-0000-0000-000000000000"
16684 }
16685 }]
16686 }]
16687 });
16688 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16689 let md = adf_to_markdown(&doc).unwrap();
16690 assert!(
16691 !md.contains("localId="),
16692 "null UUID localId should be stripped, got: {md}"
16693 );
16694 }
16695
16696 #[test]
16697 fn file_media_localid_stripped_when_option_set() {
16698 let adf_doc = serde_json::json!({
16700 "type": "doc",
16701 "version": 1,
16702 "content": [{
16703 "type": "mediaSingle",
16704 "attrs": {"layout": "center"},
16705 "content": [{
16706 "type": "media",
16707 "attrs": {
16708 "type": "file",
16709 "id": "abc-123",
16710 "collection": "contentId-456",
16711 "height": 100,
16712 "width": 200,
16713 "localId": "0e79f58ac382"
16714 }
16715 }]
16716 }]
16717 });
16718 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16719 let opts = RenderOptions {
16720 strip_local_ids: true,
16721 ..Default::default()
16722 };
16723 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16724 assert!(
16725 !md.contains("localId="),
16726 "localId should be stripped with strip_local_ids, got: {md}"
16727 );
16728 }
16729
16730 #[test]
16731 fn external_media_localid_roundtrip() {
16732 let adf_doc = serde_json::json!({
16734 "type": "doc",
16735 "version": 1,
16736 "content": [{
16737 "type": "mediaSingle",
16738 "attrs": {"layout": "center"},
16739 "content": [{
16740 "type": "media",
16741 "attrs": {
16742 "type": "external",
16743 "url": "https://example.com/image.png",
16744 "alt": "test",
16745 "localId": "deadbeef1234"
16746 }
16747 }]
16748 }]
16749 });
16750 let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16751 let md = adf_to_markdown(&doc).unwrap();
16752 assert!(
16753 md.contains("localId=deadbeef1234"),
16754 "expected localId in markdown for external media, got: {md}"
16755 );
16756 let doc2 = markdown_to_adf(&md).unwrap();
16757 let media = &doc2.content[0].content.as_ref().unwrap()[0];
16758 let attrs = media.attrs.as_ref().unwrap();
16759 assert_eq!(attrs["localId"], "deadbeef1234");
16760 }
16761
16762 #[test]
16763 fn bracket_in_text_not_parsed_as_link() {
16764 let md = ":check_mark: [Task] Unable to start trial ([Link](https://example.com/link))";
16766 let doc = markdown_to_adf(md).unwrap();
16767 let para = &doc.content[0];
16768 assert_eq!(para.node_type, "paragraph");
16769 let content = para.content.as_ref().unwrap();
16770 let text_nodes: Vec<_> = content.iter().filter(|n| n.node_type == "text").collect();
16772 let has_task_bracket = text_nodes
16773 .iter()
16774 .any(|n| n.text.as_deref().unwrap_or("").contains("[Task]"));
16775 assert!(
16776 has_task_bracket,
16777 "expected [Task] in plain text, nodes: {content:?}"
16778 );
16779 let link_nodes: Vec<_> = content
16781 .iter()
16782 .filter(|n| {
16783 n.marks
16784 .as_ref()
16785 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
16786 })
16787 .collect();
16788 assert!(!link_nodes.is_empty(), "expected a link node");
16789 assert_eq!(
16790 link_nodes[0].text.as_deref(),
16791 Some("Link"),
16792 "link text should be 'Link'"
16793 );
16794 }
16795
16796 #[test]
16797 fn empty_paragraph_roundtrip() {
16798 let mut adf_in = AdfDocument::new();
16800 adf_in.content = vec![
16801 AdfNode::paragraph(vec![AdfNode::text("before")]),
16802 AdfNode::paragraph(vec![]),
16803 AdfNode::paragraph(vec![AdfNode::text("after")]),
16804 ];
16805 let md = adf_to_markdown(&adf_in).unwrap();
16806 let adf_out = markdown_to_adf(&md).unwrap();
16807 assert_eq!(
16808 adf_out.content.len(),
16809 3,
16810 "should have 3 blocks, markdown:\n{md}"
16811 );
16812 assert_eq!(adf_out.content[0].node_type, "paragraph");
16813 assert_eq!(adf_out.content[1].node_type, "paragraph");
16814 assert!(
16815 adf_out.content[1].content.is_none(),
16816 "middle paragraph should be empty"
16817 );
16818 assert_eq!(adf_out.content[2].node_type, "paragraph");
16819 }
16820
16821 #[test]
16822 fn nbsp_paragraph_roundtrip() {
16823 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}";
16825 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16826 let md = adf_to_markdown(&doc).unwrap();
16827 assert!(
16828 md.contains("::paragraph["),
16829 "NBSP paragraph should use directive form: {md}"
16830 );
16831 let rt = markdown_to_adf(&md).unwrap();
16832 assert_eq!(rt.content.len(), 1, "should have 1 block");
16833 assert_eq!(rt.content[0].node_type, "paragraph");
16834 let text = rt.content[0].content.as_ref().unwrap()[0]
16835 .text
16836 .as_deref()
16837 .unwrap_or("");
16838 assert_eq!(text, "\u{00a0}", "NBSP should survive round-trip");
16839 }
16840
16841 #[test]
16842 fn nbsp_in_nested_expand_roundtrip() {
16843 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"nestedExpand\",\"attrs\":{\"title\":\"Section\"},\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}]}";
16845 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16846 let md = adf_to_markdown(&doc).unwrap();
16847 let rt = markdown_to_adf(&md).unwrap();
16848 let ne = &rt.content[0];
16849 assert_eq!(ne.node_type, "nestedExpand");
16850 let inner = ne.content.as_ref().unwrap();
16851 assert_eq!(inner.len(), 1, "should have 1 inner block");
16852 assert_eq!(inner[0].node_type, "paragraph");
16853 let content = inner[0].content.as_ref().unwrap();
16854 assert!(!content.is_empty(), "paragraph should not be empty");
16855 let text = content[0].text.as_deref().unwrap_or("");
16856 assert_eq!(text, "\u{00a0}", "NBSP should survive in nestedExpand");
16857 }
16858
16859 #[test]
16860 fn nbsp_followed_by_content() {
16861 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\"}]}]}";
16863 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16864 let md = adf_to_markdown(&doc).unwrap();
16865 let rt = markdown_to_adf(&md).unwrap();
16866 assert!(rt.content.len() >= 2, "should have at least 2 blocks");
16867 let after_para = rt.content.iter().find(|n| {
16869 n.node_type == "paragraph"
16870 && n.content
16871 .as_ref()
16872 .and_then(|c| c.first())
16873 .and_then(|n| n.text.as_deref())
16874 .is_some_and(|t| t.contains("after"))
16875 });
16876 assert!(after_para.is_some(), "should have paragraph with 'after'");
16877 }
16878
16879 #[test]
16880 fn nbsp_paragraph_with_marks_survives() {
16881 let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\",\"marks\":[{\"type\":\"strong\"}]}]}]}";
16884 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16885 let md = adf_to_markdown(&doc).unwrap();
16886 assert!(md.contains("**"), "should have bold markers: {md}");
16887 let rt = markdown_to_adf(&md).unwrap();
16888 let content = rt.content[0].content.as_ref().unwrap();
16889 assert!(!content.is_empty(), "should preserve content");
16890 }
16891
16892 #[test]
16893 fn regular_paragraph_unchanged() {
16894 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}"#;
16896 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16897 let md = adf_to_markdown(&doc).unwrap();
16898 assert!(
16899 !md.contains("::paragraph"),
16900 "regular paragraphs should not use directive form: {md}"
16901 );
16902 assert!(md.contains("hello"));
16903 }
16904
16905 #[test]
16906 fn paragraph_directive_with_content_parsed() {
16907 let md = "::paragraph[\u{00a0}]\n";
16909 let doc = markdown_to_adf(md).unwrap();
16910 assert_eq!(doc.content.len(), 1);
16911 assert_eq!(doc.content[0].node_type, "paragraph");
16912 let content = doc.content[0].content.as_ref().unwrap();
16913 assert!(!content.is_empty(), "should have inline content");
16914 assert_eq!(content[0].text.as_deref().unwrap(), "\u{00a0}");
16915 }
16916
16917 #[test]
16918 fn nbsp_paragraph_in_list_item_with_nested_list() {
16919 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"}]}]}]}]}]}]}"#;
16921 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16922 let md = adf_to_markdown(&doc).unwrap();
16923 let rt = markdown_to_adf(&md).unwrap();
16924 let list = &rt.content[0];
16925 assert_eq!(list.node_type, "bulletList");
16926 let item = &list.content.as_ref().unwrap()[0];
16927 let item_content = item.content.as_ref().unwrap();
16928 assert_eq!(
16929 item_content.len(),
16930 2,
16931 "listItem should have paragraph + nested list, got: {item_content:?}"
16932 );
16933 let para = &item_content[0];
16934 assert_eq!(para.node_type, "paragraph");
16935 let para_content = para
16936 .content
16937 .as_ref()
16938 .expect("paragraph should have content");
16939 assert!(
16940 !para_content.is_empty(),
16941 "NBSP paragraph content should not be empty"
16942 );
16943 assert_eq!(
16944 para_content[0].text.as_deref().unwrap(),
16945 "\u{00a0}",
16946 "NBSP should survive round-trip inside listItem"
16947 );
16948 }
16949
16950 #[test]
16951 fn nbsp_paragraph_in_list_item_with_local_ids() {
16952 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"}]}]}]}]}]}]}"#;
16954 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16955 let md = adf_to_markdown(&doc).unwrap();
16956 let rt = markdown_to_adf(&md).unwrap();
16957 let list = &rt.content[0];
16958 let item = &list.content.as_ref().unwrap()[0];
16959 assert_eq!(
16961 item.attrs.as_ref().unwrap()["localId"],
16962 "li-001",
16963 "listItem localId should survive"
16964 );
16965 let item_content = item.content.as_ref().unwrap();
16966 assert_eq!(item_content.len(), 2);
16967 let para = &item_content[0];
16969 assert_eq!(
16970 para.attrs.as_ref().unwrap()["localId"],
16971 "p-001",
16972 "paragraph localId should survive"
16973 );
16974 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16975 assert_eq!(text, "\u{00a0}", "NBSP should survive with localIds");
16976 }
16977
16978 #[test]
16979 fn nbsp_paragraph_in_list_item_without_nested_list() {
16980 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"}]}]}]}]}"#;
16982 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16983 let md = adf_to_markdown(&doc).unwrap();
16984 let rt = markdown_to_adf(&md).unwrap();
16985 let list = &rt.content[0];
16986 let item = &list.content.as_ref().unwrap()[0];
16987 let para = &item.content.as_ref().unwrap()[0];
16988 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16989 assert_eq!(text, "\u{00a0}", "NBSP should survive in simple list item");
16990 }
16991
16992 #[test]
16993 fn nbsp_paragraph_in_ordered_list_item_with_nested_list() {
16994 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"}]}]}]}]}]}]}"#;
16996 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16997 let md = adf_to_markdown(&doc).unwrap();
16998 let rt = markdown_to_adf(&md).unwrap();
16999 let list = &rt.content[0];
17000 let item = &list.content.as_ref().unwrap()[0];
17001 let item_content = item.content.as_ref().unwrap();
17002 assert_eq!(item_content.len(), 2);
17003 let para = &item_content[0];
17004 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
17005 assert_eq!(text, "\u{00a0}", "NBSP should survive in ordered list item");
17006 }
17007
17008 #[test]
17009 fn list_item_leading_space_preserved() {
17010 let md = "- hello world\n- - text";
17012 let doc = markdown_to_adf(md).unwrap();
17013 let list = &doc.content[0];
17014 assert_eq!(list.node_type, "bulletList");
17015 let items = list.content.as_ref().unwrap();
17016 let first_para = &items[0].content.as_ref().unwrap()[0];
17018 let first_text = &first_para.content.as_ref().unwrap()[0];
17019 assert_eq!(first_text.text.as_deref(), Some("hello world"));
17020 }
17021
17022 #[test]
17023 fn list_item_leading_space_not_stripped() {
17024 let md = "- leading space text";
17027 let doc = markdown_to_adf(md).unwrap();
17028 let list = &doc.content[0];
17029 let items = list.content.as_ref().unwrap();
17030 let para = &items[0].content.as_ref().unwrap()[0];
17031 let text_node = ¶.content.as_ref().unwrap()[0];
17032 assert_eq!(
17034 text_node.text.as_deref(),
17035 Some(" leading space text"),
17036 "leading space should be preserved"
17037 );
17038 }
17039
17040 #[test]
17045 fn hardbreak_in_cell_uses_directive_table() {
17046 let adf = AdfDocument {
17049 version: 1,
17050 doc_type: "doc".to_string(),
17051 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17052 AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17053 AdfNode::text("before"),
17054 AdfNode::hard_break(),
17055 AdfNode::text("after"),
17056 ])]),
17057 ])])],
17058 };
17059 let md = adf_to_markdown(&adf).unwrap();
17060 assert!(
17062 md.contains(":::td") || md.contains("::::table"),
17063 "Table with hardBreak should use directive form, got:\n{md}"
17064 );
17065 assert!(
17066 !md.contains("| before"),
17067 "Should NOT use pipe syntax with hardBreak"
17068 );
17069 }
17070
17071 #[test]
17072 fn hardbreak_in_cell_roundtrips() {
17073 let adf = AdfDocument {
17075 version: 1,
17076 doc_type: "doc".to_string(),
17077 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17078 AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17079 AdfNode::text("line one"),
17080 AdfNode::hard_break(),
17081 AdfNode::text("line two"),
17082 ])]),
17083 ])])],
17084 };
17085 let md = adf_to_markdown(&adf).unwrap();
17086 let roundtripped = markdown_to_adf(&md).unwrap();
17087
17088 assert_eq!(roundtripped.content.len(), 1);
17090 assert_eq!(roundtripped.content[0].node_type, "table");
17091 let rows = roundtripped.content[0].content.as_ref().unwrap();
17092 assert_eq!(
17093 rows.len(),
17094 1,
17095 "Should have exactly 1 row, got {}",
17096 rows.len()
17097 );
17098 }
17099
17100 #[test]
17101 fn hardbreak_in_paragraph_roundtrips() {
17102 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17104 {"type":"text","text":"line one"},
17105 {"type":"hardBreak"},
17106 {"type":"text","text":"line two"}
17107 ]}]}"#;
17108 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17109 let md = adf_to_markdown(&doc).unwrap();
17110 let round_tripped = markdown_to_adf(&md).unwrap();
17111 let inlines = round_tripped.content[0].content.as_ref().unwrap();
17112 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17113 assert_eq!(
17114 types,
17115 vec!["text", "hardBreak", "text"],
17116 "hardBreak should be preserved, got: {types:?}"
17117 );
17118 assert_eq!(inlines[0].text.as_deref(), Some("line one"));
17119 assert_eq!(inlines[2].text.as_deref(), Some("line two"));
17120 }
17121
17122 #[test]
17123 fn consecutive_hardbreaks_in_paragraph_roundtrip() {
17124 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17126 {"type":"text","text":"before"},
17127 {"type":"hardBreak"},
17128 {"type":"hardBreak"},
17129 {"type":"text","text":"after"}
17130 ]}]}"#;
17131 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17132 let md = adf_to_markdown(&doc).unwrap();
17133 let round_tripped = markdown_to_adf(&md).unwrap();
17134 assert_eq!(
17135 round_tripped.content.len(),
17136 1,
17137 "Should remain a single paragraph, got {} blocks",
17138 round_tripped.content.len()
17139 );
17140 let inlines = round_tripped.content[0].content.as_ref().unwrap();
17141 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17142 assert_eq!(
17143 types,
17144 vec!["text", "hardBreak", "hardBreak", "text"],
17145 "Both hardBreaks should be preserved, got: {types:?}"
17146 );
17147 assert_eq!(inlines[0].text.as_deref(), Some("before"));
17148 assert_eq!(inlines[3].text.as_deref(), Some("after"));
17149 }
17150
17151 #[test]
17152 fn hardbreak_only_paragraph_roundtrips() {
17153 let adf_json = r#"{"version":1,"type":"doc","content":[
17155 {"type":"paragraph","content":[{"type":"hardBreak"}]}
17156 ]}"#;
17157 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17158 let md = adf_to_markdown(&doc).unwrap();
17159 let round_tripped = markdown_to_adf(&md).unwrap();
17160 assert_eq!(
17161 round_tripped.content.len(),
17162 1,
17163 "Paragraph should not be dropped, got {} blocks",
17164 round_tripped.content.len()
17165 );
17166 let inlines = round_tripped.content[0].content.as_ref().unwrap();
17167 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17168 assert_eq!(
17169 types,
17170 vec!["hardBreak"],
17171 "hardBreak-only paragraph should preserve its content, got: {types:?}"
17172 );
17173 }
17174
17175 #[test]
17176 fn issue_410_full_reproducer_roundtrips() {
17177 let adf_json = r#"{"version":1,"type":"doc","content":[
17179 {"type":"paragraph","content":[
17180 {"type":"text","text":"before"},
17181 {"type":"hardBreak"},
17182 {"type":"hardBreak"},
17183 {"type":"text","text":"after"}
17184 ]},
17185 {"type":"paragraph","content":[
17186 {"type":"hardBreak"}
17187 ]}
17188 ]}"#;
17189 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17190 let md = adf_to_markdown(&doc).unwrap();
17191 let round_tripped = markdown_to_adf(&md).unwrap();
17192 assert_eq!(
17193 round_tripped.content.len(),
17194 2,
17195 "Should have exactly 2 paragraphs, got {}",
17196 round_tripped.content.len()
17197 );
17198 let p1 = round_tripped.content[0].content.as_ref().unwrap();
17200 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
17201 assert_eq!(types1, vec!["text", "hardBreak", "hardBreak", "text"]);
17202 let p2 = round_tripped.content[1].content.as_ref().unwrap();
17204 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
17205 assert_eq!(types2, vec!["hardBreak"]);
17206 }
17207
17208 #[test]
17209 fn trailing_space_hardbreak_still_parsed() {
17210 let md = "line one \nline two\n";
17212 let doc = markdown_to_adf(md).unwrap();
17213 let inlines = doc.content[0].content.as_ref().unwrap();
17214 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17215 assert_eq!(
17216 types,
17217 vec!["text", "hardBreak", "text"],
17218 "Trailing-space hardBreak should still parse, got: {types:?}"
17219 );
17220 }
17221
17222 #[test]
17223 fn trailing_hardbreak_at_end_of_paragraph_roundtrips() {
17224 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17226 {"type":"text","text":"text"},
17227 {"type":"hardBreak"}
17228 ]}]}"#;
17229 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17230 let md = adf_to_markdown(&doc).unwrap();
17231 let round_tripped = markdown_to_adf(&md).unwrap();
17232 let inlines = round_tripped.content[0].content.as_ref().unwrap();
17233 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17234 assert_eq!(
17235 types,
17236 vec!["text", "hardBreak"],
17237 "Trailing hardBreak should be preserved, got: {types:?}"
17238 );
17239 }
17240
17241 #[test]
17242 #[test]
17243 fn table_with_header_row_uses_pipe_syntax() {
17244 let adf = AdfDocument {
17246 version: 1,
17247 doc_type: "doc".to_string(),
17248 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17249 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("header cell")])]),
17250 ])])],
17251 };
17252 let md = adf_to_markdown(&adf).unwrap();
17253 assert!(
17254 md.contains("| header cell |"),
17255 "Table with header row should use pipe syntax, got:\n{md}"
17256 );
17257 }
17258
17259 #[test]
17260 fn table_without_header_row_uses_directive_syntax() {
17261 let adf = AdfDocument {
17264 version: 1,
17265 doc_type: "doc".to_string(),
17266 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17267 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("simple cell")])]),
17268 ])])],
17269 };
17270 let md = adf_to_markdown(&adf).unwrap();
17271 assert!(
17272 md.contains("::::table"),
17273 "Table without header row should use directive syntax, got:\n{md}"
17274 );
17275 }
17276
17277 #[test]
17278 fn tablecell_first_row_preserved_on_roundtrip() {
17279 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[
17281 {"type":"tableRow","content":[
17282 {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row1 cell"}]}]}
17283 ]},
17284 {"type":"tableRow","content":[
17285 {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row2 cell"}]}]}
17286 ]}
17287 ]}]}"#;
17288 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17289 let md = adf_to_markdown(&doc).unwrap();
17290 let round_tripped = markdown_to_adf(&md).unwrap();
17291 let rows = round_tripped.content[0].content.as_ref().unwrap();
17292 let row0_cell = &rows[0].content.as_ref().unwrap()[0];
17293 assert_eq!(
17294 row0_cell.node_type, "tableCell",
17295 "first row cell should remain tableCell, got: {}",
17296 row0_cell.node_type
17297 );
17298 let row1_cell = &rows[1].content.as_ref().unwrap()[0];
17299 assert_eq!(row1_cell.node_type, "tableCell");
17300 }
17301
17302 #[test]
17303 fn mixed_header_and_cell_first_row_uses_pipe() {
17304 let adf = AdfDocument {
17306 version: 1,
17307 doc_type: "doc".to_string(),
17308 content: vec![AdfNode::table(vec![
17309 AdfNode::table_row(vec![
17310 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17311 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17312 ]),
17313 AdfNode::table_row(vec![
17314 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
17315 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
17316 ]),
17317 ])],
17318 };
17319 let md = adf_to_markdown(&adf).unwrap();
17320 assert!(
17321 md.contains("| H1 |"),
17322 "Table with header first row should use pipe syntax, got:\n{md}"
17323 );
17324 assert!(!md.contains("::::table"), "should not use directive syntax");
17325 }
17326
17327 #[test]
17330 fn render_pipe_table_escapes_pipe_in_code_span_cell() {
17331 let adf = AdfDocument {
17334 version: 1,
17335 doc_type: "doc".to_string(),
17336 content: vec![AdfNode::table(vec![
17337 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17338 AdfNode::text("Header"),
17339 ])])]),
17340 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17341 AdfNode::text_with_marks("a|b", vec![AdfMark::code()]),
17342 ])])]),
17343 ])],
17344 };
17345 let md = adf_to_markdown(&adf).unwrap();
17346 assert!(
17347 md.contains(r"`a\|b`"),
17348 "Pipe inside code span must be escaped, got:\n{md}"
17349 );
17350 }
17351
17352 #[test]
17353 fn render_pipe_table_escapes_pipe_in_plain_text_cell() {
17354 let adf = AdfDocument {
17355 version: 1,
17356 doc_type: "doc".to_string(),
17357 content: vec![AdfNode::table(vec![
17358 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17359 AdfNode::text("Header"),
17360 ])])]),
17361 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17362 AdfNode::text("x|y"),
17363 ])])]),
17364 ])],
17365 };
17366 let md = adf_to_markdown(&adf).unwrap();
17367 assert!(
17368 md.contains(r"x\|y"),
17369 "Pipe inside plain-text cell must be escaped, got:\n{md}"
17370 );
17371 }
17372
17373 #[test]
17374 fn code_span_with_pipe_in_table_cell_roundtrips() {
17375 let adf_json = r#"{
17377 "version": 1,
17378 "type": "doc",
17379 "content": [{
17380 "type": "table",
17381 "attrs": {"isNumberColumnEnabled": false, "layout": "default", "localId": "abc-789"},
17382 "content": [
17383 {"type": "tableRow", "content": [
17384 {"type": "tableHeader", "attrs": {}, "content": [
17385 {"type": "paragraph", "content": [{"type": "text", "text": "Before"}]}
17386 ]},
17387 {"type": "tableHeader", "attrs": {}, "content": [
17388 {"type": "paragraph", "content": [{"type": "text", "text": "After"}]}
17389 ]}
17390 ]},
17391 {"type": "tableRow", "content": [
17392 {"type": "tableCell", "attrs": {}, "content": [
17393 {"type": "paragraph", "content": [
17394 {"type": "text", "text": "parse(json).extract[T]", "marks": [{"type": "code"}]}
17395 ]}
17396 ]},
17397 {"type": "tableCell", "attrs": {}, "content": [
17398 {"type": "paragraph", "content": [
17399 {"type": "text", "text": "parser.decode[T|json]", "marks": [{"type": "code"}]}
17400 ]}
17401 ]}
17402 ]}
17403 ]
17404 }]
17405 }"#;
17406 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17407 let md = adf_to_markdown(&doc).unwrap();
17408 let round_tripped = markdown_to_adf(&md).unwrap();
17409
17410 let rows = round_tripped.content[0].content.as_ref().unwrap();
17411 assert_eq!(
17412 rows.len(),
17413 2,
17414 "Table should have 2 rows, got: {}",
17415 rows.len()
17416 );
17417
17418 let body_row = rows[1].content.as_ref().unwrap();
17419 assert_eq!(
17420 body_row.len(),
17421 2,
17422 "Body row should have 2 cells (not split by the pipe), got: {}",
17423 body_row.len()
17424 );
17425
17426 let second_cell = &body_row[1];
17427 let para = second_cell.content.as_ref().unwrap().first().unwrap();
17428 let inlines = para.content.as_ref().unwrap();
17429 assert_eq!(inlines.len(), 1, "Cell should have a single text node");
17430 assert_eq!(
17431 inlines[0].text.as_deref(),
17432 Some("parser.decode[T|json]"),
17433 "Code-span text must be preserved with literal pipe"
17434 );
17435 let marks = inlines[0]
17436 .marks
17437 .as_ref()
17438 .expect("code mark must be preserved");
17439 assert!(
17440 marks.iter().any(|m| m.mark_type == "code"),
17441 "text node should carry the code mark"
17442 );
17443 }
17444
17445 #[test]
17446 fn plain_text_pipe_in_table_cell_roundtrips() {
17447 let adf = AdfDocument {
17449 version: 1,
17450 doc_type: "doc".to_string(),
17451 content: vec![AdfNode::table(vec![
17452 AdfNode::table_row(vec![
17453 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17454 AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17455 ]),
17456 AdfNode::table_row(vec![
17457 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a|b")])]),
17458 AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("c")])]),
17459 ]),
17460 ])],
17461 };
17462 let md = adf_to_markdown(&adf).unwrap();
17463 let round_tripped = markdown_to_adf(&md).unwrap();
17464 let rows = round_tripped.content[0].content.as_ref().unwrap();
17465 let body_row = rows[1].content.as_ref().unwrap();
17466 assert_eq!(
17467 body_row.len(),
17468 2,
17469 "Body row should keep 2 cells, got: {}",
17470 body_row.len()
17471 );
17472 let first_cell_text = body_row[0].content.as_ref().unwrap()[0]
17473 .content
17474 .as_ref()
17475 .unwrap()[0]
17476 .text
17477 .as_deref();
17478 assert_eq!(first_cell_text, Some("a|b"));
17479 }
17480
17481 #[test]
17482 fn cell_contains_hard_break_true() {
17483 let para = AdfNode::paragraph(vec![
17484 AdfNode::text("a"),
17485 AdfNode::hard_break(),
17486 AdfNode::text("b"),
17487 ]);
17488 assert!(cell_contains_hard_break(¶));
17489 }
17490
17491 #[test]
17492 fn cell_contains_hard_break_false() {
17493 let para = AdfNode::paragraph(vec![AdfNode::text("no break here")]);
17494 assert!(!cell_contains_hard_break(¶));
17495 }
17496
17497 #[test]
17498 fn cell_contains_hard_break_empty() {
17499 let para = AdfNode::paragraph(vec![]);
17500 assert!(!cell_contains_hard_break(¶));
17501 }
17502
17503 #[test]
17506 fn multi_paragraph_panel_roundtrips() {
17507 let adf = AdfDocument {
17508 version: 1,
17509 doc_type: "doc".to_string(),
17510 content: vec![AdfNode {
17511 node_type: "panel".to_string(),
17512 attrs: Some(serde_json::json!({"panelType": "info"})),
17513 content: Some(vec![
17514 AdfNode::paragraph(vec![AdfNode::text("First paragraph.")]),
17515 AdfNode::paragraph(vec![AdfNode::text("Second paragraph.")]),
17516 ]),
17517 text: None,
17518 marks: None,
17519 local_id: None,
17520 parameters: None,
17521 }],
17522 };
17523
17524 let md = adf_to_markdown(&adf).unwrap();
17525 assert!(
17527 md.contains("First paragraph.\n\nSecond paragraph."),
17528 "Panel should have blank line between paragraphs, got:\n{md}"
17529 );
17530
17531 let roundtripped = markdown_to_adf(&md).unwrap();
17533 assert_eq!(roundtripped.content.len(), 1);
17534 assert_eq!(roundtripped.content[0].node_type, "panel");
17535 let panel_content = roundtripped.content[0].content.as_ref().unwrap();
17536 assert_eq!(
17537 panel_content.len(),
17538 2,
17539 "Panel should have 2 paragraphs after round-trip, got {}",
17540 panel_content.len()
17541 );
17542 }
17543
17544 #[test]
17545 fn multi_paragraph_expand_roundtrips() {
17546 let adf = AdfDocument {
17547 version: 1,
17548 doc_type: "doc".to_string(),
17549 content: vec![AdfNode {
17550 node_type: "expand".to_string(),
17551 attrs: Some(serde_json::json!({"title": "Details"})),
17552 content: Some(vec![
17553 AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17554 AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17555 ]),
17556 text: None,
17557 marks: None,
17558 local_id: None,
17559 parameters: None,
17560 }],
17561 };
17562
17563 let md = adf_to_markdown(&adf).unwrap();
17564 let roundtripped = markdown_to_adf(&md).unwrap();
17565 let expand_content = roundtripped.content[0].content.as_ref().unwrap();
17566 assert_eq!(
17567 expand_content.len(),
17568 2,
17569 "Expand should have 2 paragraphs after round-trip, got {}",
17570 expand_content.len()
17571 );
17572 }
17573
17574 #[test]
17575 fn consecutive_nested_expands_in_table_cell_roundtrip() {
17576 let cell_content = vec![
17577 AdfNode {
17578 node_type: "nestedExpand".to_string(),
17579 attrs: Some(serde_json::json!({"title": "First"})),
17580 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 1")])]),
17581 text: None,
17582 marks: None,
17583 local_id: None,
17584 parameters: None,
17585 },
17586 AdfNode {
17587 node_type: "nestedExpand".to_string(),
17588 attrs: Some(serde_json::json!({"title": "Second"})),
17589 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 2")])]),
17590 text: None,
17591 marks: None,
17592 local_id: None,
17593 parameters: None,
17594 },
17595 ];
17596 let adf = AdfDocument {
17597 version: 1,
17598 doc_type: "doc".to_string(),
17599 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17600 AdfNode::table_cell(cell_content),
17601 ])])],
17602 };
17603
17604 let md = adf_to_markdown(&adf).unwrap();
17605 assert!(
17606 md.contains(":::\n\n:::nested-expand"),
17607 "Should have blank line between consecutive nested-expands in cell, got:\n{md}"
17608 );
17609
17610 let rt = markdown_to_adf(&md).unwrap();
17611 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17612 .content
17613 .as_ref()
17614 .unwrap()[0];
17615 let cell_nodes = cell.content.as_ref().unwrap();
17616 let expand_count = cell_nodes
17617 .iter()
17618 .filter(|n| n.node_type == "nestedExpand")
17619 .count();
17620 assert_eq!(
17621 expand_count, 2,
17622 "Both nested-expands should survive round-trip, got {expand_count}"
17623 );
17624 }
17625
17626 #[test]
17627 fn multi_paragraph_in_table_cell_roundtrip() {
17628 let adf = AdfDocument {
17630 version: 1,
17631 doc_type: "doc".to_string(),
17632 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17633 AdfNode::table_cell(vec![
17634 AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17635 AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17636 ]),
17637 ])])],
17638 };
17639
17640 let md = adf_to_markdown(&adf).unwrap();
17641 assert!(
17642 md.contains("Para one.\n\nPara two."),
17643 "Should have blank line between paragraphs in cell, got:\n{md}"
17644 );
17645
17646 let rt = markdown_to_adf(&md).unwrap();
17647 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17648 .content
17649 .as_ref()
17650 .unwrap()[0];
17651 let para_count = cell
17652 .content
17653 .as_ref()
17654 .unwrap()
17655 .iter()
17656 .filter(|n| n.node_type == "paragraph")
17657 .count();
17658 assert_eq!(para_count, 2, "Both paragraphs should survive round-trip");
17659 }
17660
17661 #[test]
17662 fn panel_inside_table_cell_roundtrip() {
17663 let adf = AdfDocument {
17665 version: 1,
17666 doc_type: "doc".to_string(),
17667 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17668 AdfNode::table_cell(vec![
17669 AdfNode::paragraph(vec![AdfNode::text("Before panel.")]),
17670 AdfNode {
17671 node_type: "panel".to_string(),
17672 attrs: Some(serde_json::json!({"panelType": "info"})),
17673 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text(
17674 "Panel content",
17675 )])]),
17676 text: None,
17677 marks: None,
17678 local_id: None,
17679 parameters: None,
17680 },
17681 ]),
17682 ])])],
17683 };
17684
17685 let md = adf_to_markdown(&adf).unwrap();
17686 assert!(
17687 md.contains(":::panel"),
17688 "Should contain panel directive, got:\n{md}"
17689 );
17690
17691 let rt = markdown_to_adf(&md).unwrap();
17692 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17693 .content
17694 .as_ref()
17695 .unwrap()[0];
17696 let has_panel = cell
17697 .content
17698 .as_ref()
17699 .unwrap()
17700 .iter()
17701 .any(|n| n.node_type == "panel");
17702 assert!(has_panel, "Panel should survive round-trip in table cell");
17703 }
17704
17705 #[test]
17706 fn three_consecutive_expands_in_table_cell() {
17707 let make_expand = |title: &str| AdfNode {
17708 node_type: "nestedExpand".to_string(),
17709 attrs: Some(serde_json::json!({"title": title})),
17710 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("content")])]),
17711 text: None,
17712 marks: None,
17713 local_id: None,
17714 parameters: None,
17715 };
17716 let adf = AdfDocument {
17717 version: 1,
17718 doc_type: "doc".to_string(),
17719 content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17720 AdfNode::table_cell(vec![
17721 make_expand("First"),
17722 make_expand("Second"),
17723 make_expand("Third"),
17724 ]),
17725 ])])],
17726 };
17727
17728 let md = adf_to_markdown(&adf).unwrap();
17729 let rt = markdown_to_adf(&md).unwrap();
17730 let cell = &rt.content[0].content.as_ref().unwrap()[0]
17731 .content
17732 .as_ref()
17733 .unwrap()[0];
17734 let expand_count = cell
17735 .content
17736 .as_ref()
17737 .unwrap()
17738 .iter()
17739 .filter(|n| n.node_type == "nestedExpand")
17740 .count();
17741 assert_eq!(expand_count, 3, "All 3 expands should survive round-trip");
17742 }
17743
17744 #[test]
17747 fn nested_expand_inside_panel() {
17748 let md = ":::panel{type=info}\n:::expand{title=\"Details\"}\nHidden content\n:::\nMore panel content\n:::";
17753 let adf = markdown_to_adf(md).unwrap();
17754
17755 let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
17756 assert!(err.violations.iter().any(|v| matches!(
17757 v,
17758 crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
17759 parent_type, child_type, ..
17760 } if parent_type == "panel" && child_type == "expand"
17761 )));
17762 }
17763
17764 #[test]
17765 fn nested_expand_inside_table_cell() {
17766 let md = "::::table\n:::tr\n:::td\n:::expand{title=\"Details\"}\nExpand content\n:::\n:::\n:::\n::::";
17770 let adf = markdown_to_adf(md).unwrap();
17771
17772 let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
17773 assert!(err.violations.iter().any(|v| matches!(
17774 v,
17775 crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
17776 parent_type, child_type, ..
17777 } if parent_type == "tableCell" && child_type == "expand"
17778 )));
17779 }
17780
17781 #[test]
17782 fn nested_expand_inside_layout_column() {
17783 let md = ":::layout\n:::column{width=50}\n:::expand{title=\"Col Expand\"}\nExpanded\n:::\n:::\n:::column{width=50}\nFiller paragraph.\n:::\n:::";
17788 let adf = markdown_to_adf(md).unwrap();
17789
17790 assert_eq!(adf.content.len(), 1);
17791 assert_eq!(adf.content[0].node_type, "layoutSection");
17792
17793 let columns = adf.content[0].content.as_ref().unwrap();
17794 assert_eq!(columns.len(), 2);
17795 let col_content = columns[0].content.as_ref().unwrap();
17796 assert!(
17797 col_content.iter().any(|n| n.node_type == "expand"),
17798 "Column should contain an expand node, got: {:?}",
17799 col_content.iter().map(|n| &n.node_type).collect::<Vec<_>>()
17800 );
17801
17802 crate::atlassian::adf_validated::validate(&adf).unwrap();
17804 }
17805
17806 #[test]
17807 fn expand_localid_in_directive_attrs() {
17808 let adf_json = r#"{"version":1,"type":"doc","content":[
17810 {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
17811 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17812 ]}
17813 ]}"#;
17814 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17815 let md = adf_to_markdown(&doc).unwrap();
17816 assert!(
17817 md.contains("localId=exp-001"),
17818 "should contain localId: {md}"
17819 );
17820 assert!(
17821 md.contains(":::expand{"),
17822 "should have expand directive with attrs: {md}"
17823 );
17824 assert!(
17825 !md.contains(":::\n{localId="),
17826 "localId should NOT be trailing: {md}"
17827 );
17828 }
17829
17830 #[test]
17831 fn expand_localid_roundtrip() {
17832 let adf_json = r#"{"version":1,"type":"doc","content":[
17833 {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
17834 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17835 ]}
17836 ]}"#;
17837 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17838 let md = adf_to_markdown(&doc).unwrap();
17839 let rt = markdown_to_adf(&md).unwrap();
17840 let expand = &rt.content[0];
17841 assert_eq!(expand.node_type, "expand");
17842 assert_eq!(
17843 expand.local_id.as_deref(),
17844 Some("exp-001"),
17845 "expand localId should survive round-trip"
17846 );
17847 assert_eq!(
17848 expand.attrs.as_ref().unwrap()["title"],
17849 "Details",
17850 "expand title should survive round-trip"
17851 );
17852 }
17853
17854 #[test]
17855 fn nested_expand_localid_roundtrip() {
17856 let adf_json = r#"{"version":1,"type":"doc","content":[
17857 {"type":"nestedExpand","attrs":{"localId":"ne-001","title":"S"},"content":[
17858 {"type":"paragraph","content":[{"type":"text","text":"content"}]}
17859 ]}
17860 ]}"#;
17861 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17862 let md = adf_to_markdown(&doc).unwrap();
17863 assert!(
17864 md.contains(":::nested-expand{"),
17865 "should have directive: {md}"
17866 );
17867 assert!(md.contains("localId=ne-001"), "should have localId: {md}");
17868 let rt = markdown_to_adf(&md).unwrap();
17869 let ne = &rt.content[0];
17870 assert_eq!(ne.node_type, "nestedExpand");
17871 assert_eq!(ne.local_id.as_deref(), Some("ne-001"));
17872 }
17873
17874 #[test]
17875 fn nested_expand_localid_followed_by_content() {
17876 let adf_json = "{\
17878 \"version\":1,\"type\":\"doc\",\"content\":[\
17879 {\"type\":\"nestedExpand\",\"attrs\":{\"localId\":\"exp-001\",\"title\":\"S\"},\"content\":[\
17880 {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}\
17881 ]},\
17882 {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]}\
17883 ]}";
17884 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17885 let md = adf_to_markdown(&doc).unwrap();
17886 let rt = markdown_to_adf(&md).unwrap();
17887 let ne = &rt.content[0];
17889 assert_eq!(ne.node_type, "nestedExpand");
17890 assert_eq!(
17891 ne.local_id.as_deref(),
17892 Some("exp-001"),
17893 "nestedExpand should preserve localId"
17894 );
17895 let para = &rt.content[1];
17897 assert_eq!(para.node_type, "paragraph");
17898 let text = para.content.as_ref().unwrap()[0]
17899 .text
17900 .as_deref()
17901 .unwrap_or("");
17902 assert!(
17903 !text.contains("localId"),
17904 "following paragraph should not contain localId: {text}"
17905 );
17906 assert!(
17907 text.contains("after"),
17908 "following paragraph should contain 'after': {text}"
17909 );
17910 }
17911
17912 #[test]
17913 fn expand_localid_without_title() {
17914 let adf_json = r#"{"version":1,"type":"doc","content":[
17915 {"type":"expand","attrs":{"localId":"exp-002"},"content":[
17916 {"type":"paragraph","content":[{"type":"text","text":"no title"}]}
17917 ]}
17918 ]}"#;
17919 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17920 let md = adf_to_markdown(&doc).unwrap();
17921 assert!(
17922 md.contains(":::expand{localId=exp-002}"),
17923 "should have localId without title: {md}"
17924 );
17925 let rt = markdown_to_adf(&md).unwrap();
17926 assert_eq!(rt.content[0].local_id.as_deref(), Some("exp-002"));
17927 }
17928
17929 #[test]
17930 fn expand_localid_stripped() {
17931 let adf_json = r#"{"version":1,"type":"doc","content":[
17932 {"type":"expand","attrs":{"localId":"exp-001","title":"X"},"content":[
17933 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17934 ]}
17935 ]}"#;
17936 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17937 let opts = RenderOptions {
17938 strip_local_ids: true,
17939 };
17940 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
17941 assert!(!md.contains("localId"), "localId should be stripped: {md}");
17942 assert!(
17943 md.contains(":::expand{title=\"X\"}"),
17944 "title should remain: {md}"
17945 );
17946 }
17947
17948 #[test]
17951 fn expand_top_level_localid_roundtrip() {
17952 let adf_json = r#"{"version":1,"type":"doc","content":[
17954 {"type":"expand","attrs":{"title":"My Section"},"localId":"abc-123","content":[
17955 {"type":"paragraph","content":[{"type":"text","text":"hello"}]}
17956 ]}
17957 ]}"#;
17958 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17959 assert_eq!(doc.content[0].local_id.as_deref(), Some("abc-123"));
17960 let md = adf_to_markdown(&doc).unwrap();
17961 assert!(
17962 md.contains("localId=abc-123"),
17963 "JFM should contain localId: {md}"
17964 );
17965 let rt = markdown_to_adf(&md).unwrap();
17966 let expand = &rt.content[0];
17967 assert_eq!(expand.node_type, "expand");
17968 assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
17969 assert_eq!(
17970 expand.attrs.as_ref().unwrap()["title"],
17971 "My Section",
17972 "title should survive round-trip"
17973 );
17974 }
17975
17976 #[test]
17977 fn expand_parameters_roundtrip() {
17978 let adf_json = r#"{"version":1,"type":"doc","content":[
17980 {"type":"expand","attrs":{"title":"Props"},"parameters":{"macroMetadata":{"macroId":{"value":"m-001"},"schemaVersion":{"value":"1"}}},"content":[
17981 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17982 ]}
17983 ]}"#;
17984 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17985 assert!(doc.content[0].parameters.is_some());
17986 let md = adf_to_markdown(&doc).unwrap();
17987 assert!(md.contains("params="), "JFM should contain params: {md}");
17988 let rt = markdown_to_adf(&md).unwrap();
17989 let expand = &rt.content[0];
17990 let params = expand
17991 .parameters
17992 .as_ref()
17993 .expect("parameters should survive round-trip");
17994 assert_eq!(params["macroMetadata"]["macroId"]["value"], "m-001");
17995 assert_eq!(params["macroMetadata"]["schemaVersion"]["value"], "1");
17996 }
17997
17998 #[test]
17999 fn expand_localid_and_parameters_roundtrip() {
18000 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"}]}]}]}"#;
18002 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18003 let md = adf_to_markdown(&doc).unwrap();
18004 let rt = markdown_to_adf(&md).unwrap();
18005 let expand = &rt.content[0];
18006 assert_eq!(expand.node_type, "expand");
18007 assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
18008 assert_eq!(expand.attrs.as_ref().unwrap()["title"], "My Section");
18009 let params = expand
18010 .parameters
18011 .as_ref()
18012 .expect("parameters should survive");
18013 assert_eq!(params["macroMetadata"]["macroId"]["value"], "macro-001");
18014 assert_eq!(params["macroMetadata"]["title"], "Page Properties");
18015 }
18016
18017 #[test]
18018 fn nested_expand_top_level_localid_and_parameters_roundtrip() {
18019 let adf_json = r#"{"version":1,"type":"doc","content":[
18020 {"type":"nestedExpand","attrs":{"title":"Nested"},"localId":"ne-100","parameters":{"macroMetadata":{"macroId":{"value":"nm-001"}}},"content":[
18021 {"type":"paragraph","content":[{"type":"text","text":"inner"}]}
18022 ]}
18023 ]}"#;
18024 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18025 let md = adf_to_markdown(&doc).unwrap();
18026 assert!(
18027 md.contains(":::nested-expand{"),
18028 "should use nested-expand: {md}"
18029 );
18030 assert!(md.contains("localId=ne-100"), "should have localId: {md}");
18031 assert!(md.contains("params="), "should have params: {md}");
18032 let rt = markdown_to_adf(&md).unwrap();
18033 let ne = &rt.content[0];
18034 assert_eq!(ne.node_type, "nestedExpand");
18035 assert_eq!(ne.local_id.as_deref(), Some("ne-100"));
18036 assert_eq!(
18037 ne.parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
18038 "nm-001"
18039 );
18040 }
18041
18042 #[test]
18043 fn expand_top_level_localid_stripped() {
18044 let adf_json = r#"{"version":1,"type":"doc","content":[
18046 {"type":"expand","attrs":{"title":"X"},"localId":"exp-strip","content":[
18047 {"type":"paragraph","content":[{"type":"text","text":"body"}]}
18048 ]}
18049 ]}"#;
18050 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18051 let opts = RenderOptions {
18052 strip_local_ids: true,
18053 };
18054 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18055 assert!(!md.contains("localId"), "localId should be stripped: {md}");
18056 assert!(
18057 md.contains(":::expand{title=\"X\"}"),
18058 "title should remain: {md}"
18059 );
18060 }
18061
18062 #[test]
18063 fn expand_parameters_without_localid() {
18064 let adf_json = r#"{"version":1,"type":"doc","content":[
18066 {"type":"expand","attrs":{"title":"P"},"parameters":{"macroMetadata":{"macroId":{"value":"solo"}}},"content":[
18067 {"type":"paragraph","content":[{"type":"text","text":"data"}]}
18068 ]}
18069 ]}"#;
18070 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18071 let md = adf_to_markdown(&doc).unwrap();
18072 assert!(!md.contains("localId"), "no localId: {md}");
18073 assert!(md.contains("params="), "has params: {md}");
18074 let rt = markdown_to_adf(&md).unwrap();
18075 assert!(rt.content[0].local_id.is_none());
18076 assert_eq!(
18077 rt.content[0].parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
18078 "solo"
18079 );
18080 }
18081
18082 #[test]
18083 fn expand_localid_without_parameters() {
18084 let adf_json = r#"{"version":1,"type":"doc","content":[
18086 {"type":"expand","attrs":{"title":"L"},"localId":"lid-only","content":[
18087 {"type":"paragraph","content":[{"type":"text","text":"txt"}]}
18088 ]}
18089 ]}"#;
18090 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18091 let md = adf_to_markdown(&doc).unwrap();
18092 assert!(md.contains("localId=lid-only"), "has localId: {md}");
18093 assert!(!md.contains("params="), "no params: {md}");
18094 let rt = markdown_to_adf(&md).unwrap();
18095 assert_eq!(rt.content[0].local_id.as_deref(), Some("lid-only"));
18096 assert!(rt.content[0].parameters.is_none());
18097 }
18098
18099 #[test]
18100 fn nested_panel_inside_panel() {
18101 let md = ":::panel{type=info}\n:::panel{type=warning}\nInner warning\n:::\n:::";
18102 let adf = markdown_to_adf(md).unwrap();
18103
18104 assert_eq!(adf.content.len(), 1);
18106 assert_eq!(adf.content[0].node_type, "panel");
18107
18108 let panel_content = adf.content[0].content.as_ref().unwrap();
18110 assert!(
18111 panel_content.iter().any(|n| n.node_type == "panel"),
18112 "Outer panel should contain an inner panel, got: {:?}",
18113 panel_content
18114 .iter()
18115 .map(|n| &n.node_type)
18116 .collect::<Vec<_>>()
18117 );
18118 }
18119
18120 #[test]
18121 fn content_after_directive_table_is_preserved() {
18122 let md = "\
18124## Before table
18125
18126::::table{layout=default}
18127:::tr
18128:::th{}
18129Cell
18130:::
18131:::
18132::::
18133
18134## After table
18135
18136Paragraph after.";
18137 let adf = markdown_to_adf(md).unwrap();
18138 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18139 assert_eq!(
18140 types,
18141 vec!["heading", "table", "heading", "paragraph"],
18142 "Content after table was dropped: got {types:?}"
18143 );
18144 }
18145
18146 #[test]
18147 fn paragraph_after_directive_table_is_preserved() {
18148 let md = "\
18150::::table{layout=default}
18151:::tr
18152:::th{}
18153Header
18154:::
18155:::
18156::::
18157
18158Just a paragraph.";
18159 let adf = markdown_to_adf(md).unwrap();
18160 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18161 assert_eq!(
18162 types,
18163 vec!["table", "paragraph"],
18164 "Paragraph after table was dropped: got {types:?}"
18165 );
18166 }
18167
18168 #[test]
18169 fn extension_after_directive_table_is_preserved() {
18170 let md = "\
18172::::table{layout=default}
18173:::tr
18174:::th{}
18175Header
18176:::
18177:::
18178::::
18179
18180::extension{type=com.atlassian.confluence.macro.core key=toc}";
18181 let adf = markdown_to_adf(md).unwrap();
18182 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18183 assert_eq!(
18184 types,
18185 vec!["table", "extension"],
18186 "Extension after table was dropped: got {types:?}"
18187 );
18188 }
18189
18190 #[test]
18191 fn multiple_blocks_after_directive_table() {
18192 let md = "\
18194## Heading 1
18195
18196::::table{layout=default}
18197:::tr
18198:::td{}
18199A
18200:::
18201:::td{}
18202B
18203:::
18204:::
18205::::
18206
18207## Heading 2
18208
18209Some text.
18210
18211---
18212
18213::::table{layout=default}
18214:::tr
18215:::th{}
18216C
18217:::
18218:::
18219::::
18220
18221## Heading 3";
18222 let adf = markdown_to_adf(md).unwrap();
18223 let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18224 assert_eq!(
18225 types,
18226 vec![
18227 "heading",
18228 "table",
18229 "heading",
18230 "paragraph",
18231 "rule",
18232 "table",
18233 "heading"
18234 ],
18235 "Content after tables was dropped: got {types:?}"
18236 );
18237 }
18238
18239 #[test]
18242 fn adf_table_caption_to_markdown() {
18243 let doc = AdfDocument {
18244 version: 1,
18245 doc_type: "doc".to_string(),
18246 content: vec![AdfNode::table(vec![
18247 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18248 AdfNode::text("cell"),
18249 ])])]),
18250 AdfNode::caption(vec![AdfNode::text("Table caption")]),
18251 ])],
18252 };
18253 let md = adf_to_markdown(&doc).unwrap();
18254 assert!(
18255 md.contains("::::table"),
18256 "table with caption must use directive form"
18257 );
18258 assert!(
18259 md.contains(":::caption"),
18260 "caption directive missing, got: {md}"
18261 );
18262 assert!(
18263 md.contains("Table caption"),
18264 "caption text missing, got: {md}"
18265 );
18266 }
18267
18268 #[test]
18269 fn directive_table_caption_parses() {
18270 let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nTable caption\n:::\n::::\n";
18271 let doc = markdown_to_adf(md).unwrap();
18272 let table = &doc.content[0];
18273 assert_eq!(table.node_type, "table");
18274 let children = table.content.as_ref().unwrap();
18275 assert_eq!(children.len(), 2, "expected row + caption");
18276 assert_eq!(children[0].node_type, "tableRow");
18277 assert_eq!(children[1].node_type, "caption");
18278 let caption_content = children[1].content.as_ref().unwrap();
18279 assert_eq!(caption_content[0].text.as_deref(), Some("Table caption"));
18280 }
18281
18282 #[test]
18283 fn table_caption_round_trip_from_adf_json() {
18284 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18285 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18286 {"type":"caption","content":[{"type":"text","text":"Table caption"}]}
18287 ]}]}"#;
18288 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18289 let md = adf_to_markdown(&doc).unwrap();
18290 assert!(md.contains("Table caption"), "caption text lost in ADF→JFM");
18291 let round_tripped = markdown_to_adf(&md).unwrap();
18292 let children = round_tripped.content[0].content.as_ref().unwrap();
18293 let caption = children.iter().find(|n| n.node_type == "caption");
18294 assert!(caption.is_some(), "caption lost on round-trip");
18295 let caption_text = caption.unwrap().content.as_ref().unwrap();
18296 assert_eq!(caption_text[0].text.as_deref(), Some("Table caption"));
18297 }
18298
18299 #[test]
18300 fn table_caption_with_inline_marks_round_trips() {
18301 let doc = AdfDocument {
18302 version: 1,
18303 doc_type: "doc".to_string(),
18304 content: vec![AdfNode::table(vec![
18305 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18306 AdfNode::text("data"),
18307 ])])]),
18308 AdfNode::caption(vec![
18309 AdfNode::text("Caption with "),
18310 AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
18311 ]),
18312 ])],
18313 };
18314 let md = adf_to_markdown(&doc).unwrap();
18315 assert!(md.contains("**bold**"), "bold mark missing in caption");
18316 let round_tripped = markdown_to_adf(&md).unwrap();
18317 let caption = round_tripped.content[0]
18318 .content
18319 .as_ref()
18320 .unwrap()
18321 .iter()
18322 .find(|n| n.node_type == "caption")
18323 .expect("caption node missing after round-trip");
18324 let inlines = caption.content.as_ref().unwrap();
18325 let bold_node = inlines.iter().find(|n| {
18326 n.marks
18327 .as_ref()
18328 .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
18329 });
18330 assert!(bold_node.is_some(), "bold mark lost in caption round-trip");
18331 }
18332
18333 #[test]
18336 fn table_caption_localid_roundtrip() {
18337 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18338 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18339 {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Table with localId"}]}
18340 ]}]}"#;
18341 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18342 let md = adf_to_markdown(&doc).unwrap();
18343 assert!(
18344 md.contains("localId=abcdef123456"),
18345 "table caption localId should appear in markdown: {md}"
18346 );
18347 let rt = markdown_to_adf(&md).unwrap();
18348 let caption = rt.content[0]
18349 .content
18350 .as_ref()
18351 .unwrap()
18352 .iter()
18353 .find(|n| n.node_type == "caption")
18354 .expect("caption should survive round-trip");
18355 assert_eq!(
18356 caption.attrs.as_ref().unwrap()["localId"],
18357 "abcdef123456",
18358 "table caption localId should round-trip"
18359 );
18360 }
18361
18362 #[test]
18363 fn table_caption_without_localid_unchanged() {
18364 let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nPlain caption\n:::\n::::\n";
18365 let doc = markdown_to_adf(md).unwrap();
18366 let caption = doc.content[0]
18367 .content
18368 .as_ref()
18369 .unwrap()
18370 .iter()
18371 .find(|n| n.node_type == "caption")
18372 .unwrap();
18373 assert!(
18374 caption.attrs.is_none(),
18375 "table caption without localId should not gain attrs"
18376 );
18377 let md2 = adf_to_markdown(&doc).unwrap();
18378 assert!(!md2.contains("localId"), "no localId should appear: {md2}");
18379 }
18380
18381 #[test]
18382 fn table_caption_localid_stripped_when_option_set() {
18383 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18384 {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18385 {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Stripped"}]}
18386 ]}]}"#;
18387 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18388 let opts = RenderOptions {
18389 strip_local_ids: true,
18390 ..Default::default()
18391 };
18392 let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18393 assert!(
18394 !md.contains("localId"),
18395 "table caption localId should be stripped: {md}"
18396 );
18397 }
18398
18399 #[test]
18400 #[test]
18401 fn tablecell_empty_attrs_preserved_on_roundtrip() {
18402 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"}]}]}]}]}]}"#;
18404 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18405 let md = adf_to_markdown(&doc).unwrap();
18406 let round_tripped = markdown_to_adf(&md).unwrap();
18407 let rows = round_tripped.content[0].content.as_ref().unwrap();
18408 let cell = &rows[0].content.as_ref().unwrap()[0];
18409 assert!(
18410 cell.attrs.is_some(),
18411 "tableCell attrs should be preserved, got None"
18412 );
18413 assert_eq!(
18414 cell.attrs.as_ref().unwrap(),
18415 &serde_json::json!({}),
18416 "tableCell attrs should be an empty object"
18417 );
18418 }
18419
18420 #[test]
18421 fn tablecell_empty_attrs_serialized_in_json() {
18422 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"}]}]}]}]}]}"#;
18424 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18425 let md = adf_to_markdown(&doc).unwrap();
18426 let round_tripped = markdown_to_adf(&md).unwrap();
18427 let json = serde_json::to_string(&round_tripped).unwrap();
18428 assert!(
18429 json.contains(r#""attrs":{}"#),
18430 "serialized JSON should contain \"attrs\":{{}}, got: {json}"
18431 );
18432 }
18433
18434 #[test]
18435 fn tablecell_empty_attrs_renders_braces_in_markdown() {
18436 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"}]}]}]}]}]}"#;
18438 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18439 let md = adf_to_markdown(&doc).unwrap();
18440 assert!(
18442 md.contains("{} hello"),
18443 "cell with empty attrs should render '{{}} hello', got: {md}"
18444 );
18445 assert!(
18446 !md.contains("{} world"),
18447 "cell without attrs should not render '{{}}', got: {md}"
18448 );
18449 }
18450
18451 #[test]
18452 fn tablecell_no_attrs_unchanged_on_roundtrip() {
18453 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"}]}]}]}]}]}"#;
18455 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18456 let md = adf_to_markdown(&doc).unwrap();
18457 let round_tripped = markdown_to_adf(&md).unwrap();
18458 let rows = round_tripped.content[0].content.as_ref().unwrap();
18459 let cell = &rows[0].content.as_ref().unwrap()[0];
18460 assert!(
18461 cell.attrs.is_none(),
18462 "tableCell without attrs should stay None, got: {:?}",
18463 cell.attrs
18464 );
18465 }
18466
18467 #[test]
18468 fn tablecell_nonempty_attrs_preserved_on_roundtrip() {
18469 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"}]}]}]}]}]}"##;
18471 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18472 let md = adf_to_markdown(&doc).unwrap();
18473 let round_tripped = markdown_to_adf(&md).unwrap();
18474 let rows = round_tripped.content[0].content.as_ref().unwrap();
18475 let cell = &rows[1].content.as_ref().unwrap()[0];
18476 let attrs = cell.attrs.as_ref().unwrap();
18477 assert_eq!(attrs["background"], "#DEEBFF");
18478 assert_eq!(attrs["colspan"], 2);
18479 }
18480
18481 #[test]
18482 fn pipe_table_not_used_when_caption_present() {
18483 let doc = AdfDocument {
18484 version: 1,
18485 doc_type: "doc".to_string(),
18486 content: vec![AdfNode::table(vec![
18487 AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
18488 AdfNode::text("H"),
18489 ])])]),
18490 AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18491 AdfNode::text("D"),
18492 ])])]),
18493 AdfNode::caption(vec![AdfNode::text("cap")]),
18494 ])],
18495 };
18496 let md = adf_to_markdown(&doc).unwrap();
18497 assert!(
18498 md.contains("::::table"),
18499 "pipe syntax should not be used when caption is present"
18500 );
18501 }
18502
18503 #[test]
18506 fn hardbreak_with_ordered_marker_in_bullet_item_roundtrips() {
18507 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18510 {"type":"listItem","content":[{"type":"paragraph","content":[
18511 {"type":"text","text":"1. First item"},
18512 {"type":"hardBreak"},
18513 {"type":"text","text":"2. Honouring existing commitments"}
18514 ]}]}
18515 ]}]}"#;
18516 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18517 let md = adf_to_markdown(&doc).unwrap();
18518
18519 assert!(
18521 md.contains(" 2. Honouring"),
18522 "Continuation line should be indented, got:\n{md}"
18523 );
18524
18525 let rt = markdown_to_adf(&md).unwrap();
18527 let list = &rt.content[0];
18528 assert_eq!(list.node_type, "bulletList");
18529 let items = list.content.as_ref().unwrap();
18530 assert_eq!(
18531 items.len(),
18532 1,
18533 "Should be one list item, got {}",
18534 items.len()
18535 );
18536
18537 let para = &items[0].content.as_ref().unwrap()[0];
18538 let inlines = para.content.as_ref().unwrap();
18539 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18540 assert_eq!(
18541 types,
18542 vec!["text", "hardBreak", "text"],
18543 "Expected text+hardBreak+text, got {types:?}"
18544 );
18545 assert_eq!(
18546 inlines[2].text.as_deref().unwrap(),
18547 "2. Honouring existing commitments"
18548 );
18549 }
18550
18551 #[test]
18552 fn hardbreak_with_ordered_marker_in_ordered_item_roundtrips() {
18553 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18555 {"type":"listItem","content":[{"type":"paragraph","content":[
18556 {"type":"text","text":"Introduction "},
18557 {"type":"hardBreak"},
18558 {"type":"text","text":"3. Third point"}
18559 ]}]}
18560 ]}]}"#;
18561 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18562 let md = adf_to_markdown(&doc).unwrap();
18563 let rt = markdown_to_adf(&md).unwrap();
18564
18565 let list = &rt.content[0];
18566 assert_eq!(list.node_type, "orderedList");
18567 let items = list.content.as_ref().unwrap();
18568 assert_eq!(items.len(), 1);
18569
18570 let para = &items[0].content.as_ref().unwrap()[0];
18571 let inlines = para.content.as_ref().unwrap();
18572 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18573 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18574 assert_eq!(inlines[2].text.as_deref().unwrap(), "3. Third point");
18575 }
18576
18577 #[test]
18578 fn hardbreak_with_bullet_marker_in_bullet_item_roundtrips() {
18579 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18581 {"type":"listItem","content":[{"type":"paragraph","content":[
18582 {"type":"text","text":"Header "},
18583 {"type":"hardBreak"},
18584 {"type":"text","text":"- not a sub-item"}
18585 ]}]}
18586 ]}]}"#;
18587 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18588 let md = adf_to_markdown(&doc).unwrap();
18589 let rt = markdown_to_adf(&md).unwrap();
18590
18591 let list = &rt.content[0];
18592 assert_eq!(list.node_type, "bulletList");
18593 let items = list.content.as_ref().unwrap();
18594 assert_eq!(
18595 items.len(),
18596 1,
18597 "Should be one list item, not {}",
18598 items.len()
18599 );
18600
18601 let para = &items[0].content.as_ref().unwrap()[0];
18602 let inlines = para.content.as_ref().unwrap();
18603 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18604 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18605 assert_eq!(inlines[2].text.as_deref().unwrap(), "- not a sub-item");
18606 }
18607
18608 #[test]
18609 fn hardbreak_continuation_followed_by_sub_list() {
18610 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18612 {"type":"listItem","content":[
18613 {"type":"paragraph","content":[
18614 {"type":"text","text":"Main item "},
18615 {"type":"hardBreak"},
18616 {"type":"text","text":"continued here"}
18617 ]},
18618 {"type":"bulletList","content":[
18619 {"type":"listItem","content":[{"type":"paragraph","content":[
18620 {"type":"text","text":"sub-item"}
18621 ]}]}
18622 ]}
18623 ]}
18624 ]}]}"#;
18625 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18626 let md = adf_to_markdown(&doc).unwrap();
18627 let rt = markdown_to_adf(&md).unwrap();
18628
18629 let list = &rt.content[0];
18630 let items = list.content.as_ref().unwrap();
18631 assert_eq!(items.len(), 1);
18632
18633 let item_content = items[0].content.as_ref().unwrap();
18634 assert_eq!(item_content.len(), 2, "Expected paragraph + nested list");
18635 assert_eq!(item_content[0].node_type, "paragraph");
18636 assert_eq!(item_content[1].node_type, "bulletList");
18637
18638 let inlines = item_content[0].content.as_ref().unwrap();
18640 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18641 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18642 }
18643
18644 #[test]
18645 fn multiple_hardbreaks_with_numbered_text_roundtrip() {
18646 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18648 {"type":"listItem","content":[{"type":"paragraph","content":[
18649 {"type":"text","text":"Preamble "},
18650 {"type":"hardBreak"},
18651 {"type":"text","text":"1. Alpha "},
18652 {"type":"hardBreak"},
18653 {"type":"text","text":"2. Bravo"}
18654 ]}]}
18655 ]}]}"#;
18656 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18657 let md = adf_to_markdown(&doc).unwrap();
18658 let rt = markdown_to_adf(&md).unwrap();
18659
18660 let items = rt.content[0].content.as_ref().unwrap();
18661 assert_eq!(items.len(), 1);
18662
18663 let inlines = items[0].content.as_ref().unwrap()[0]
18664 .content
18665 .as_ref()
18666 .unwrap();
18667 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18668 assert_eq!(
18669 types,
18670 vec!["text", "hardBreak", "text", "hardBreak", "text"]
18671 );
18672 }
18673
18674 #[test]
18675 fn trailing_hardbreak_in_bullet_item_roundtrips() {
18676 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18680 {"type":"listItem","content":[{"type":"paragraph","content":[
18681 {"type":"text","text":"ends with break"},
18682 {"type":"hardBreak"}
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 assert_eq!(list.node_type, "bulletList");
18691 let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18692 .content
18693 .as_ref()
18694 .unwrap();
18695 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18696 assert_eq!(types, vec!["text", "hardBreak"]);
18697 }
18698
18699 #[test]
18700 fn trailing_hardbreak_in_ordered_item_roundtrips() {
18701 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18704 {"type":"listItem","content":[{"type":"paragraph","content":[
18705 {"type":"text","text":"ends with break"},
18706 {"type":"hardBreak"}
18707 ]}]}
18708 ]}]}"#;
18709 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18710 let md = adf_to_markdown(&doc).unwrap();
18711 let rt = markdown_to_adf(&md).unwrap();
18712
18713 let list = &rt.content[0];
18714 assert_eq!(list.node_type, "orderedList");
18715 let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18716 .content
18717 .as_ref()
18718 .unwrap();
18719 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18720 assert_eq!(types, vec!["text", "hardBreak"]);
18721 }
18722
18723 #[test]
18724 fn trailing_space_hardbreak_continuation_in_bullet_item() {
18725 let md = "- first line \n 2. continued\n";
18729 let doc = markdown_to_adf(md).unwrap();
18730
18731 let list = &doc.content[0];
18732 assert_eq!(list.node_type, "bulletList");
18733 let items = list.content.as_ref().unwrap();
18734 assert_eq!(
18735 items.len(),
18736 1,
18737 "Should be one list item, got {}",
18738 items.len()
18739 );
18740
18741 let para = &items[0].content.as_ref().unwrap()[0];
18742 let inlines = para.content.as_ref().unwrap();
18743 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18744 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18745 assert_eq!(inlines[2].text.as_deref().unwrap(), "2. continued");
18746 }
18747
18748 #[test]
18749 fn trailing_space_hardbreak_continuation_in_ordered_item() {
18750 let md = "1. first line \n - continued\n";
18753 let doc = markdown_to_adf(md).unwrap();
18754
18755 let list = &doc.content[0];
18756 assert_eq!(list.node_type, "orderedList");
18757 let items = list.content.as_ref().unwrap();
18758 assert_eq!(
18759 items.len(),
18760 1,
18761 "Should be one list item, got {}",
18762 items.len()
18763 );
18764
18765 let para = &items[0].content.as_ref().unwrap()[0];
18766 let inlines = para.content.as_ref().unwrap();
18767 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18768 assert_eq!(types, vec!["text", "hardBreak", "text"]);
18769 assert_eq!(inlines[2].text.as_deref().unwrap(), "- continued");
18770 }
18771
18772 #[test]
18773 fn multi_paragraph_list_item_with_ordered_marker_roundtrips() {
18774 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18777 {"type":"listItem","content":[
18778 {"type":"paragraph","content":[{"type":"text","text":"some preamble"}]},
18779 {"type":"paragraph","content":[{"type":"text","text":"2. Honouring existing commitments"}]}
18780 ]}
18781 ]}]}"#;
18782 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18783 let md = adf_to_markdown(&doc).unwrap();
18784 let rt = markdown_to_adf(&md).unwrap();
18785
18786 assert_eq!(rt.content.len(), 1, "Should be one top-level block");
18787 let list = &rt.content[0];
18788 assert_eq!(list.node_type, "bulletList");
18789 let items = list.content.as_ref().unwrap();
18790 assert_eq!(items.len(), 1);
18791 let item_content = items[0].content.as_ref().unwrap();
18792 assert_eq!(
18793 item_content.len(),
18794 2,
18795 "Expected 2 paragraphs inside the list item, got {}",
18796 item_content.len()
18797 );
18798 assert_eq!(item_content[0].node_type, "paragraph");
18799 assert_eq!(item_content[1].node_type, "paragraph");
18800 let text = item_content[1].content.as_ref().unwrap()[0]
18801 .text
18802 .as_deref()
18803 .unwrap();
18804 assert_eq!(text, "2. Honouring existing commitments");
18805 }
18806
18807 #[test]
18808 fn multi_paragraph_list_item_with_bullet_marker_roundtrips() {
18809 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18811 {"type":"listItem","content":[
18812 {"type":"paragraph","content":[{"type":"text","text":"preamble"}]},
18813 {"type":"paragraph","content":[{"type":"text","text":"- not a sub-item"}]}
18814 ]}
18815 ]}]}"#;
18816 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18817 let md = adf_to_markdown(&doc).unwrap();
18818 let rt = markdown_to_adf(&md).unwrap();
18819
18820 let items = rt.content[0].content.as_ref().unwrap();
18821 assert_eq!(items.len(), 1);
18822 let item_content = items[0].content.as_ref().unwrap();
18823 assert_eq!(item_content.len(), 2);
18824 assert_eq!(item_content[1].node_type, "paragraph");
18825 let text = item_content[1].content.as_ref().unwrap()[0]
18826 .text
18827 .as_deref()
18828 .unwrap();
18829 assert_eq!(text, "- not a sub-item");
18830 }
18831
18832 #[test]
18833 fn backslash_escape_in_inline_text() {
18834 let nodes = parse_inline(r"2\. text");
18836 assert_eq!(nodes.len(), 1, "Should be one text node");
18837 assert_eq!(nodes[0].text.as_deref().unwrap(), "2. text");
18838 }
18839
18840 #[test]
18841 fn escape_list_marker_ordered() {
18842 assert_eq!(escape_list_marker("2. text"), r"2\. text");
18843 assert_eq!(escape_list_marker("10. tenth"), r"10\. tenth");
18844 }
18845
18846 #[test]
18847 fn escape_list_marker_bullet() {
18848 assert_eq!(escape_list_marker("- text"), r"\- text");
18849 assert_eq!(escape_list_marker("* text"), r"\* text");
18850 assert_eq!(escape_list_marker("+ text"), r"\+ text");
18851 }
18852
18853 #[test]
18854 fn escape_list_marker_plain() {
18855 assert_eq!(escape_list_marker("plain text"), "plain text");
18856 assert_eq!(escape_list_marker("no. marker"), "no. marker");
18857 }
18858
18859 #[test]
18860 fn escape_emoji_shortcodes_basic() {
18861 assert_eq!(escape_emoji_shortcodes(":fire:"), r"\:fire:");
18862 assert_eq!(
18863 escape_emoji_shortcodes("hello :wave: world"),
18864 r"hello \:wave: world"
18865 );
18866 }
18867
18868 #[test]
18869 fn escape_emoji_shortcodes_double_colon() {
18870 assert_eq!(
18872 escape_emoji_shortcodes("Status::Active::Running"),
18873 r"Status:\:Active::Running"
18874 );
18875 }
18876
18877 #[test]
18878 fn escape_emoji_shortcodes_no_match() {
18879 assert_eq!(escape_emoji_shortcodes("Time is 10:30"), "Time is 10:30");
18881 assert_eq!(escape_emoji_shortcodes("no colons here"), "no colons here");
18882 assert_eq!(escape_emoji_shortcodes("trailing:"), "trailing:");
18883 assert_eq!(escape_emoji_shortcodes(":"), ":");
18884 }
18885
18886 #[test]
18887 fn escape_emoji_shortcodes_mixed() {
18888 assert_eq!(
18889 escape_emoji_shortcodes("Alert :fire: on pod:pod42"),
18890 r"Alert \:fire: on pod:pod42"
18891 );
18892 }
18893
18894 #[test]
18895 fn escape_emoji_shortcodes_unicode() {
18896 assert_eq!(escape_emoji_shortcodes(":Café:"), r"\:Café:");
18901 assert_eq!(escape_emoji_shortcodes(":über:"), r"\:über:");
18902 assert_eq!(escape_emoji_shortcodes(":配置:"), r"\:配置:");
18903 assert_eq!(
18904 escape_emoji_shortcodes("ZBC::配置::Production"),
18905 r"ZBC:\:配置::Production"
18906 );
18907 }
18908
18909 #[test]
18910 fn escape_emoji_shortcodes_mixed_script_name() {
18911 assert_eq!(escape_emoji_shortcodes(":abc配置:"), r"\:abc配置:");
18914 assert_eq!(escape_emoji_shortcodes(":配置abc:"), r"\:配置abc:");
18915 }
18916
18917 #[test]
18918 fn escape_emoji_shortcodes_unicode_followed_by_non_colon() {
18919 assert_eq!(escape_emoji_shortcodes(":Café world:"), ":Café world:");
18924 }
18925
18926 #[test]
18927 fn escape_emoji_shortcodes_name_runs_to_end() {
18928 assert_eq!(escape_emoji_shortcodes(":abc"), ":abc");
18934 assert_eq!(escape_emoji_shortcodes(":配置"), ":配置");
18935 }
18936
18937 #[test]
18938 fn unicode_shortcode_pattern_text_round_trips_as_text() {
18939 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18942 {"type":"text","text":"Visit :Café: today"}
18943 ]}]}"#;
18944 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18945
18946 let md = adf_to_markdown(&doc).unwrap();
18947 let round_tripped = markdown_to_adf(&md).unwrap();
18948 let content = round_tripped.content[0].content.as_ref().unwrap();
18949
18950 assert_eq!(
18951 content.len(),
18952 1,
18953 "should be a single text node, got: {content:?}"
18954 );
18955 assert_eq!(content[0].node_type, "text");
18956 assert_eq!(content[0].text.as_deref().unwrap(), "Visit :Café: today");
18957 }
18958
18959 #[test]
18960 fn unicode_double_colon_pattern_text_round_trips() {
18961 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18963 {"type":"text","text":"Use ZBC::配置::Production for prod"}
18964 ]}]}"#;
18965 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18966
18967 let md = adf_to_markdown(&doc).unwrap();
18968 let round_tripped = markdown_to_adf(&md).unwrap();
18969 let content = round_tripped.content[0].content.as_ref().unwrap();
18970
18971 assert_eq!(
18972 content.len(),
18973 1,
18974 "should be a single text node, got: {content:?}"
18975 );
18976 assert_eq!(
18977 content[0].text.as_deref().unwrap(),
18978 "Use ZBC::配置::Production for prod"
18979 );
18980 }
18981
18982 #[test]
18983 fn merge_adjacent_text_nodes() {
18984 let mut nodes = vec![AdfNode::text("a"), AdfNode::text("b"), AdfNode::text("c")];
18985 merge_adjacent_text(&mut nodes);
18986 assert_eq!(nodes.len(), 1);
18987 assert_eq!(nodes[0].text.as_deref().unwrap(), "abc");
18988 }
18989
18990 #[test]
18993 fn issue_455_paragraph_hardbreak_ordered_marker_roundtrips() {
18994 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18997 {"type":"text","text":"Introduction: "},
18998 {"type":"hardBreak"},
18999 {"type":"text","text":"1. This text follows a hardBreak"}
19000 ]}]}"#;
19001 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19002 let md = adf_to_markdown(&doc).unwrap();
19003 let rt = markdown_to_adf(&md).unwrap();
19004
19005 assert_eq!(rt.content.len(), 1, "Should remain one block");
19006 assert_eq!(rt.content[0].node_type, "paragraph");
19007 let inlines = rt.content[0].content.as_ref().unwrap();
19008 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19009 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19010 assert_eq!(
19011 inlines[2].text.as_deref(),
19012 Some("1. This text follows a hardBreak")
19013 );
19014 }
19015
19016 #[test]
19017 fn issue_455_paragraph_hardbreak_bullet_marker_roundtrips() {
19018 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19020 {"type":"text","text":"Intro"},
19021 {"type":"hardBreak"},
19022 {"type":"text","text":"- not a list item"}
19023 ]}]}"#;
19024 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19025 let md = adf_to_markdown(&doc).unwrap();
19026 let rt = markdown_to_adf(&md).unwrap();
19027
19028 assert_eq!(rt.content.len(), 1);
19029 assert_eq!(rt.content[0].node_type, "paragraph");
19030 let inlines = rt.content[0].content.as_ref().unwrap();
19031 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19032 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19033 assert_eq!(inlines[2].text.as_deref(), Some("- not a list item"));
19034 }
19035
19036 #[test]
19037 fn issue_455_paragraph_hardbreak_heading_marker_roundtrips() {
19038 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19040 {"type":"text","text":"Intro"},
19041 {"type":"hardBreak"},
19042 {"type":"text","text":"# not a heading"}
19043 ]}]}"##;
19044 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19045 let md = adf_to_markdown(&doc).unwrap();
19046 let rt = markdown_to_adf(&md).unwrap();
19047
19048 assert_eq!(rt.content.len(), 1);
19049 assert_eq!(rt.content[0].node_type, "paragraph");
19050 let inlines = rt.content[0].content.as_ref().unwrap();
19051 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19052 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19053 assert_eq!(inlines[2].text.as_deref(), Some("# not a heading"));
19054 }
19055
19056 #[test]
19057 fn issue_455_paragraph_hardbreak_blockquote_marker_roundtrips() {
19058 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19060 {"type":"text","text":"Intro"},
19061 {"type":"hardBreak"},
19062 {"type":"text","text":"> not a blockquote"}
19063 ]}]}"#;
19064 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19065 let md = adf_to_markdown(&doc).unwrap();
19066 let rt = markdown_to_adf(&md).unwrap();
19067
19068 assert_eq!(rt.content.len(), 1);
19069 assert_eq!(rt.content[0].node_type, "paragraph");
19070 let inlines = rt.content[0].content.as_ref().unwrap();
19071 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19072 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19073 assert_eq!(inlines[2].text.as_deref(), Some("> not a blockquote"));
19074 }
19075
19076 #[test]
19077 fn issue_455_paragraph_multiple_hardbreaks_with_ordered_markers() {
19078 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19080 {"type":"text","text":"Preamble"},
19081 {"type":"hardBreak"},
19082 {"type":"text","text":"1. First"},
19083 {"type":"hardBreak"},
19084 {"type":"text","text":"2. Second"},
19085 {"type":"hardBreak"},
19086 {"type":"text","text":"3. Third"}
19087 ]}]}"#;
19088 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19089 let md = adf_to_markdown(&doc).unwrap();
19090 let rt = markdown_to_adf(&md).unwrap();
19091
19092 assert_eq!(rt.content.len(), 1);
19093 assert_eq!(rt.content[0].node_type, "paragraph");
19094 let inlines = rt.content[0].content.as_ref().unwrap();
19095 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19096 assert_eq!(
19097 types,
19098 vec![
19099 "text",
19100 "hardBreak",
19101 "text",
19102 "hardBreak",
19103 "text",
19104 "hardBreak",
19105 "text"
19106 ]
19107 );
19108 assert_eq!(inlines[2].text.as_deref(), Some("1. First"));
19109 assert_eq!(inlines[4].text.as_deref(), Some("2. Second"));
19110 assert_eq!(inlines[6].text.as_deref(), Some("3. Third"));
19111 }
19112
19113 #[test]
19114 fn issue_455_paragraph_hardbreak_jfm_indentation() {
19115 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19117 {"type":"text","text":"Intro"},
19118 {"type":"hardBreak"},
19119 {"type":"text","text":"1. continued"}
19120 ]}]}"#;
19121 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19122 let md = adf_to_markdown(&doc).unwrap();
19123 assert!(
19124 md.contains("Intro\\\n 1. continued"),
19125 "Continuation should be 2-space-indented, got: {md:?}"
19126 );
19127 }
19128
19129 #[test]
19130 fn issue_455_paragraph_hardbreak_from_jfm() {
19131 let md = "Intro\\\n 1. This is continuation text\n";
19134 let doc = markdown_to_adf(md).unwrap();
19135
19136 assert_eq!(doc.content.len(), 1);
19137 assert_eq!(doc.content[0].node_type, "paragraph");
19138 let inlines = doc.content[0].content.as_ref().unwrap();
19139 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19140 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19141 assert_eq!(
19142 inlines[2].text.as_deref(),
19143 Some("1. This is continuation text")
19144 );
19145 }
19146
19147 #[test]
19148 fn issue_455_paragraph_starts_with_ordered_marker_and_hardbreak() {
19149 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19153 {"type":"text","text":"1. Starting with a number"},
19154 {"type":"hardBreak"},
19155 {"type":"text","text":"continuation after break"}
19156 ]}]}"#;
19157 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19158 let md = adf_to_markdown(&doc).unwrap();
19159 assert!(
19161 md.contains(r"1\. Starting with a number"),
19162 "First line should have escaped list marker, got: {md:?}"
19163 );
19164 let rt = markdown_to_adf(&md).unwrap();
19165
19166 assert_eq!(rt.content.len(), 1);
19167 assert_eq!(rt.content[0].node_type, "paragraph");
19168 let inlines = rt.content[0].content.as_ref().unwrap();
19169 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19170 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19171 assert_eq!(
19172 inlines[0].text.as_deref(),
19173 Some("1. Starting with a number")
19174 );
19175 assert_eq!(inlines[2].text.as_deref(), Some("continuation after break"));
19176 }
19177
19178 #[test]
19179 fn ordered_marker_paragraph_in_table_cell_roundtrips() {
19180 let adf_json = r#"{"version":1,"type":"doc","content":[{
19183 "type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},
19184 "content":[{"type":"tableRow","content":[{
19185 "type":"tableCell","attrs":{"colspan":1,"rowspan":1},
19186 "content":[{"type":"paragraph","content":[
19187 {"type":"text","text":"2. Honouring existing commitments"}
19188 ]}]
19189 }]}]
19190 }]}"#;
19191 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19192 let md = adf_to_markdown(&doc).unwrap();
19193 let rt = markdown_to_adf(&md).unwrap();
19194
19195 let table = &rt.content[0];
19196 let cell = &table.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0];
19197 let para = &cell.content.as_ref().unwrap()[0];
19198 assert_eq!(para.node_type, "paragraph");
19199 let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
19200 assert_eq!(text, "2. Honouring existing commitments");
19201 }
19202
19203 #[test]
19204 fn bullet_marker_paragraph_standalone_roundtrips() {
19205 let adf_json = r#"{"version":1,"type":"doc","content":[
19208 {"type":"paragraph","content":[
19209 {"type":"text","text":"- not a list item"}
19210 ]}
19211 ]}"#;
19212 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19213 let md = adf_to_markdown(&doc).unwrap();
19214 assert!(
19215 md.contains(r"\- not a list item"),
19216 "Should escape the leading dash, got:\n{md}"
19217 );
19218 let rt = markdown_to_adf(&md).unwrap();
19219 assert_eq!(rt.content[0].node_type, "paragraph");
19220 let text = rt.content[0].content.as_ref().unwrap()[0]
19221 .text
19222 .as_deref()
19223 .unwrap();
19224 assert_eq!(text, "- not a list item");
19225 }
19226
19227 #[test]
19228 fn merge_adjacent_text_skips_non_text_nodes() {
19229 let mut nodes = vec![
19232 AdfNode::text("a"),
19233 AdfNode::hard_break(),
19234 AdfNode::text("b"),
19235 ];
19236 merge_adjacent_text(&mut nodes);
19237 assert_eq!(nodes.len(), 3);
19238 }
19239
19240 #[test]
19241 fn star_bullet_paragraph_roundtrips() {
19242 let adf_json = r#"{"version":1,"type":"doc","content":[
19245 {"type":"paragraph","content":[
19246 {"type":"text","text":"* starred"}
19247 ]}
19248 ]}"#;
19249 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19250 let md = adf_to_markdown(&doc).unwrap();
19251 let rt = markdown_to_adf(&md).unwrap();
19252 assert_eq!(rt.content[0].node_type, "paragraph");
19253 assert_eq!(
19254 rt.content[0].content.as_ref().unwrap()[0]
19255 .text
19256 .as_deref()
19257 .unwrap(),
19258 "* starred"
19259 );
19260 }
19261
19262 #[test]
19265 fn issue_388_ordered_list_with_strong_hardbreak_roundtrips() {
19266 let adf_json = r#"{"version":1,"type":"doc","content":[
19269 {"type":"orderedList","attrs":{"order":1},"content":[
19270 {"type":"listItem","content":[
19271 {"type":"paragraph","content":[
19272 {"type":"text","text":"Bold heading","marks":[{"type":"strong"}]},
19273 {"type":"hardBreak"},
19274 {"type":"text","text":"Content after break"}
19275 ]}
19276 ]},
19277 {"type":"listItem","content":[
19278 {"type":"paragraph","content":[
19279 {"type":"text","text":"Second item","marks":[{"type":"strong"}]},
19280 {"type":"hardBreak"},
19281 {"type":"text","text":"More content"}
19282 ]}
19283 ]}
19284 ]}
19285 ]}"#;
19286 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19287 let md = adf_to_markdown(&doc).unwrap();
19288 let rt = markdown_to_adf(&md).unwrap();
19289
19290 assert_eq!(
19292 rt.content.len(),
19293 1,
19294 "Should be 1 block (orderedList), got {}",
19295 rt.content.len()
19296 );
19297 assert_eq!(rt.content[0].node_type, "orderedList");
19298 let items = rt.content[0].content.as_ref().unwrap();
19299 assert_eq!(
19300 items.len(),
19301 2,
19302 "Should have 2 listItems, got {}",
19303 items.len()
19304 );
19305
19306 let p1 = items[0].content.as_ref().unwrap()[0]
19308 .content
19309 .as_ref()
19310 .unwrap();
19311 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19312 assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19313 assert_eq!(p1[0].text.as_deref(), Some("Bold heading"));
19314 assert_eq!(p1[2].text.as_deref(), Some("Content after break"));
19315
19316 let p2 = items[1].content.as_ref().unwrap()[0]
19318 .content
19319 .as_ref()
19320 .unwrap();
19321 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19322 assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19323 assert_eq!(p2[0].text.as_deref(), Some("Second item"));
19324 assert_eq!(p2[2].text.as_deref(), Some("More content"));
19325 }
19326
19327 #[test]
19328 fn issue_388_bullet_list_with_strong_hardbreak_roundtrips() {
19329 let adf_json = r#"{"version":1,"type":"doc","content":[
19331 {"type":"bulletList","content":[
19332 {"type":"listItem","content":[
19333 {"type":"paragraph","content":[
19334 {"type":"text","text":"First","marks":[{"type":"strong"}]},
19335 {"type":"hardBreak"},
19336 {"type":"text","text":"details"}
19337 ]}
19338 ]},
19339 {"type":"listItem","content":[
19340 {"type":"paragraph","content":[
19341 {"type":"text","text":"Second","marks":[{"type":"em"}]},
19342 {"type":"hardBreak"},
19343 {"type":"text","text":"more details"}
19344 ]}
19345 ]}
19346 ]}
19347 ]}"#;
19348 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19349 let md = adf_to_markdown(&doc).unwrap();
19350 let rt = markdown_to_adf(&md).unwrap();
19351
19352 assert_eq!(rt.content.len(), 1);
19353 assert_eq!(rt.content[0].node_type, "bulletList");
19354 let items = rt.content[0].content.as_ref().unwrap();
19355 assert_eq!(items.len(), 2);
19356
19357 let p1 = items[0].content.as_ref().unwrap()[0]
19358 .content
19359 .as_ref()
19360 .unwrap();
19361 assert_eq!(p1[0].text.as_deref(), Some("First"));
19362 assert_eq!(p1[2].text.as_deref(), Some("details"));
19363
19364 let p2 = items[1].content.as_ref().unwrap()[0]
19365 .content
19366 .as_ref()
19367 .unwrap();
19368 assert_eq!(p2[0].text.as_deref(), Some("Second"));
19369 assert_eq!(p2[2].text.as_deref(), Some("more details"));
19370 }
19371
19372 #[test]
19373 fn issue_388_ordered_list_hardbreak_jfm_indentation() {
19374 let adf_json = r#"{"version":1,"type":"doc","content":[
19376 {"type":"orderedList","attrs":{"order":1},"content":[
19377 {"type":"listItem","content":[
19378 {"type":"paragraph","content":[
19379 {"type":"text","text":"heading","marks":[{"type":"strong"}]},
19380 {"type":"hardBreak"},
19381 {"type":"text","text":"body"}
19382 ]}
19383 ]}
19384 ]}
19385 ]}"#;
19386 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19387 let md = adf_to_markdown(&doc).unwrap();
19388 assert!(
19389 md.contains("1. **heading**\\\n body"),
19390 "Continuation should be indented, got:\n{md}"
19391 );
19392 }
19393
19394 #[test]
19395 fn issue_388_ordered_list_hardbreak_from_jfm() {
19396 let md = "1. **bold**\\\n continued\n2. **also bold**\\\n also continued\n";
19398 let doc = markdown_to_adf(md).unwrap();
19399
19400 assert_eq!(doc.content.len(), 1);
19401 assert_eq!(doc.content[0].node_type, "orderedList");
19402 let items = doc.content[0].content.as_ref().unwrap();
19403 assert_eq!(items.len(), 2);
19404
19405 let p1 = items[0].content.as_ref().unwrap()[0]
19406 .content
19407 .as_ref()
19408 .unwrap();
19409 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19410 assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19411 assert_eq!(p1[0].text.as_deref(), Some("bold"));
19412 assert_eq!(p1[2].text.as_deref(), Some("continued"));
19413
19414 let p2 = items[1].content.as_ref().unwrap()[0]
19415 .content
19416 .as_ref()
19417 .unwrap();
19418 let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19419 assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19420 }
19421
19422 #[test]
19423 fn issue_388_bullet_list_hardbreak_from_jfm() {
19424 let md = "- first\\\n second\n- third\\\n fourth\n";
19426 let doc = markdown_to_adf(md).unwrap();
19427
19428 assert_eq!(doc.content.len(), 1);
19429 assert_eq!(doc.content[0].node_type, "bulletList");
19430 let items = doc.content[0].content.as_ref().unwrap();
19431 assert_eq!(items.len(), 2);
19432
19433 for (i, expected) in [("first", "second"), ("third", "fourth")]
19434 .iter()
19435 .enumerate()
19436 {
19437 let p = items[i].content.as_ref().unwrap()[0]
19438 .content
19439 .as_ref()
19440 .unwrap();
19441 let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19442 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19443 assert_eq!(p[0].text.as_deref(), Some(expected.0));
19444 assert_eq!(p[2].text.as_deref(), Some(expected.1));
19445 }
19446 }
19447
19448 #[test]
19449 fn issue_433_heading_hardbreak_roundtrips() {
19450 let adf_json = r#"{"version":1,"type":"doc","content":[{
19452 "type":"heading",
19453 "attrs":{"level":1},
19454 "content":[
19455 {"type":"text","text":"Line one"},
19456 {"type":"hardBreak"},
19457 {"type":"text","text":"Line two"}
19458 ]
19459 }]}"#;
19460 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19461 let md = adf_to_markdown(&doc).unwrap();
19462 let rt = markdown_to_adf(&md).unwrap();
19463
19464 assert_eq!(
19465 rt.content.len(),
19466 1,
19467 "Should remain a single heading, got {} blocks",
19468 rt.content.len()
19469 );
19470 assert_eq!(rt.content[0].node_type, "heading");
19471 let inlines = rt.content[0].content.as_ref().unwrap();
19472 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19473 assert_eq!(
19474 types,
19475 vec!["text", "hardBreak", "text"],
19476 "hardBreak should be preserved, got: {types:?}"
19477 );
19478 assert_eq!(inlines[0].text.as_deref(), Some("Line one"));
19479 assert_eq!(inlines[2].text.as_deref(), Some("Line two"));
19480 }
19481
19482 #[test]
19483 fn issue_433_heading_hardbreak_jfm_indentation() {
19484 let adf_json = r#"{"version":1,"type":"doc","content":[{
19486 "type":"heading",
19487 "attrs":{"level":2},
19488 "content":[
19489 {"type":"text","text":"Title"},
19490 {"type":"hardBreak"},
19491 {"type":"text","text":"Subtitle"}
19492 ]
19493 }]}"#;
19494 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19495 let md = adf_to_markdown(&doc).unwrap();
19496 assert!(
19497 md.contains("## Title\\\n Subtitle"),
19498 "Continuation should be indented, got:\n{md}"
19499 );
19500 }
19501
19502 #[test]
19503 fn issue_433_heading_hardbreak_from_jfm() {
19504 let md = "# First\\\n Second\n";
19506 let doc = markdown_to_adf(md).unwrap();
19507
19508 assert_eq!(doc.content.len(), 1);
19509 assert_eq!(doc.content[0].node_type, "heading");
19510 let inlines = doc.content[0].content.as_ref().unwrap();
19511 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19512 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19513 assert_eq!(inlines[0].text.as_deref(), Some("First"));
19514 assert_eq!(inlines[2].text.as_deref(), Some("Second"));
19515 }
19516
19517 #[test]
19518 fn issue_433_heading_consecutive_hardbreaks_roundtrip() {
19519 let adf_json = r#"{"version":1,"type":"doc","content":[{
19521 "type":"heading",
19522 "attrs":{"level":3},
19523 "content":[
19524 {"type":"text","text":"A"},
19525 {"type":"hardBreak"},
19526 {"type":"hardBreak"},
19527 {"type":"text","text":"B"}
19528 ]
19529 }]}"#;
19530 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19531 let md = adf_to_markdown(&doc).unwrap();
19532 let rt = markdown_to_adf(&md).unwrap();
19533
19534 assert_eq!(rt.content.len(), 1, "Should remain a single heading");
19535 assert_eq!(rt.content[0].node_type, "heading");
19536 let inlines = rt.content[0].content.as_ref().unwrap();
19537 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19538 assert_eq!(types, vec!["text", "hardBreak", "hardBreak", "text"]);
19539 }
19540
19541 #[test]
19542 fn issue_433_heading_with_strong_and_hardbreak_roundtrips() {
19543 let adf_json = r#"{"version":1,"type":"doc","content":[{
19545 "type":"heading",
19546 "attrs":{"level":1},
19547 "content":[
19548 {"type":"text","text":"Bold title","marks":[{"type":"strong"}]},
19549 {"type":"hardBreak"},
19550 {"type":"text","text":"plain continuation"}
19551 ]
19552 }]}"#;
19553 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19554 let md = adf_to_markdown(&doc).unwrap();
19555 let rt = markdown_to_adf(&md).unwrap();
19556
19557 assert_eq!(rt.content.len(), 1);
19558 assert_eq!(rt.content[0].node_type, "heading");
19559 let inlines = rt.content[0].content.as_ref().unwrap();
19560 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19561 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19562 assert_eq!(inlines[0].text.as_deref(), Some("Bold title"));
19563 assert_eq!(inlines[2].text.as_deref(), Some("plain continuation"));
19564 }
19565
19566 #[test]
19567 fn issue_433_heading_with_link_and_hardbreak_roundtrips() {
19568 let adf_json = r#"{"version":1,"type":"doc","content":[{
19570 "type":"heading",
19571 "attrs":{"level":1},
19572 "content":[
19573 {"type":"text","text":"Click here","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
19574 {"type":"hardBreak"},
19575 {"type":"text","text":"Subtitle text"}
19576 ]
19577 }]}"#;
19578 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19579 let md = adf_to_markdown(&doc).unwrap();
19580 let rt = markdown_to_adf(&md).unwrap();
19581
19582 assert_eq!(rt.content.len(), 1);
19583 assert_eq!(rt.content[0].node_type, "heading");
19584 let inlines = rt.content[0].content.as_ref().unwrap();
19585 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19586 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19587 assert_eq!(inlines[2].text.as_deref(), Some("Subtitle text"));
19588 }
19589
19590 #[test]
19591 fn has_trailing_hard_break_backslash() {
19592 assert!(has_trailing_hard_break("text\\"));
19593 assert!(has_trailing_hard_break("**bold**\\"));
19594 }
19595
19596 #[test]
19597 fn has_trailing_hard_break_trailing_spaces() {
19598 assert!(has_trailing_hard_break("text "));
19599 assert!(has_trailing_hard_break("word "));
19600 }
19601
19602 #[test]
19603 fn has_trailing_hard_break_false() {
19604 assert!(!has_trailing_hard_break("plain text"));
19605 assert!(!has_trailing_hard_break("text "));
19606 assert!(!has_trailing_hard_break(""));
19607 }
19608
19609 #[test]
19610 fn collect_hardbreak_continuations_collects_indented() {
19611 let input = "first\\\n second\n third\n";
19614 let mut parser = MarkdownParser::new(input);
19615 parser.advance(); let mut text = "first\\".to_string();
19617 parser.collect_hardbreak_continuations(&mut text);
19618 assert_eq!(text, "first\\\nsecond");
19619 }
19620
19621 #[test]
19622 fn collect_hardbreak_continuations_stops_at_non_indented() {
19623 let input = "first\\\nnot indented\n";
19624 let mut parser = MarkdownParser::new(input);
19625 parser.advance();
19626 let mut text = "first\\".to_string();
19627 parser.collect_hardbreak_continuations(&mut text);
19628 assert_eq!(text, "first\\");
19630 }
19631
19632 #[test]
19633 fn collect_hardbreak_continuations_no_trailing_break() {
19634 let input = "plain\n indented\n";
19636 let mut parser = MarkdownParser::new(input);
19637 parser.advance();
19638 let mut text = "plain".to_string();
19639 parser.collect_hardbreak_continuations(&mut text);
19640 assert_eq!(text, "plain");
19641 }
19642
19643 #[test]
19644 fn collect_hardbreak_continuations_chained() {
19645 let input = "a\\\n b\\\n c\\\n d\n";
19647 let mut parser = MarkdownParser::new(input);
19648 parser.advance();
19649 let mut text = "a\\".to_string();
19650 parser.collect_hardbreak_continuations(&mut text);
19651 assert_eq!(text, "a\\\nb\\\nc\\\nd");
19652 }
19653
19654 #[test]
19655 fn collect_hardbreak_continuations_stops_before_image_line() {
19656 let input = "text\\\n {type=file id=x}\n";
19659 let mut parser = MarkdownParser::new(input);
19660 parser.advance(); let mut text = "text\\".to_string();
19662 parser.collect_hardbreak_continuations(&mut text);
19663 assert_eq!(text, "text\\");
19665 assert!(!parser.at_end());
19667 assert!(parser.current_line().contains(""));
19668 }
19669
19670 #[test]
19671 fn is_block_level_continuation_marker_positive_cases() {
19672 assert!(is_block_level_continuation_marker(""));
19674 assert!(is_block_level_continuation_marker("```ruby"));
19675 assert!(is_block_level_continuation_marker(":::panel{type=info}"));
19676 }
19677
19678 #[test]
19679 fn is_block_level_continuation_marker_negative_cases() {
19680 assert!(!is_block_level_continuation_marker("plain text"));
19682 assert!(!is_block_level_continuation_marker("- nested item"));
19683 assert!(!is_block_level_continuation_marker("continuation\\"));
19684 assert!(!is_block_level_continuation_marker(""));
19685 assert!(!is_block_level_continuation_marker("::partial"));
19687 assert!(!is_block_level_continuation_marker("`inline`"));
19689 }
19690
19691 #[test]
19692 fn collect_hardbreak_continuations_stops_before_code_fence() {
19693 let input = "text\\\n ```ruby\n Foo::Bar::Baz\n ```\n";
19697 let mut parser = MarkdownParser::new(input);
19698 parser.advance();
19699 let mut text = "text\\".to_string();
19700 parser.collect_hardbreak_continuations(&mut text);
19701 assert_eq!(text, "text\\");
19702 assert!(!parser.at_end());
19703 assert!(parser.current_line().starts_with(" ```"));
19704 }
19705
19706 #[test]
19707 fn collect_hardbreak_continuations_stops_before_container_directive() {
19708 let input = "text\\\n :::panel{type=info}\n body\n :::\n";
19712 let mut parser = MarkdownParser::new(input);
19713 parser.advance();
19714 let mut text = "text\\".to_string();
19715 parser.collect_hardbreak_continuations(&mut text);
19716 assert_eq!(text, "text\\");
19717 assert!(!parser.at_end());
19718 assert!(parser.current_line().contains(":::panel"));
19719 }
19720
19721 #[test]
19722 fn collect_hardbreak_continuations_stops_before_indented_code_fence() {
19723 let input = "text\\\n ```text\n :fire:\n ```\n";
19727 let mut parser = MarkdownParser::new(input);
19728 parser.advance();
19729 let mut text = "text\\".to_string();
19730 parser.collect_hardbreak_continuations(&mut text);
19731 assert_eq!(text, "text\\");
19732 assert!(!parser.at_end());
19733 assert!(parser.current_line().contains("```text"));
19734 }
19735
19736 #[test]
19737 fn ordered_list_with_sub_content_after_hardbreak() {
19738 let adf_json = r#"{"version":1,"type":"doc","content":[
19741 {"type":"orderedList","attrs":{"order":1},"content":[
19742 {"type":"listItem","content":[
19743 {"type":"paragraph","content":[
19744 {"type":"text","text":"parent"},
19745 {"type":"hardBreak"},
19746 {"type":"text","text":"continued"}
19747 ]},
19748 {"type":"bulletList","content":[
19749 {"type":"listItem","content":[
19750 {"type":"paragraph","content":[
19751 {"type":"text","text":"child"}
19752 ]}
19753 ]}
19754 ]}
19755 ]}
19756 ]}
19757 ]}"#;
19758 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19759 let md = adf_to_markdown(&doc).unwrap();
19760 let rt = markdown_to_adf(&md).unwrap();
19761
19762 assert_eq!(rt.content.len(), 1);
19763 assert_eq!(rt.content[0].node_type, "orderedList");
19764 let item_content = rt.content[0].content.as_ref().unwrap()[0]
19765 .content
19766 .as_ref()
19767 .unwrap();
19768 let p = item_content[0].content.as_ref().unwrap();
19770 let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19771 assert_eq!(types, vec!["text", "hardBreak", "text"]);
19772 assert_eq!(p[0].text.as_deref(), Some("parent"));
19773 assert_eq!(p[2].text.as_deref(), Some("continued"));
19774 assert_eq!(item_content[1].node_type, "bulletList");
19776 }
19777
19778 #[test]
19779 fn render_list_item_content_no_content() {
19780 let item = AdfNode {
19782 node_type: "listItem".to_string(),
19783 attrs: None,
19784 content: None,
19785 text: None,
19786 marks: None,
19787 local_id: None,
19788 parameters: None,
19789 };
19790 let mut output = String::new();
19791 let opts = RenderOptions::default();
19792 render_list_item_content(&item, &mut output, &opts);
19793 assert_eq!(output, "\n");
19794 }
19795
19796 #[test]
19797 fn render_list_item_content_empty_content() {
19798 let item = AdfNode::list_item(vec![]);
19800 let mut output = String::new();
19801 let opts = RenderOptions::default();
19802 render_list_item_content(&item, &mut output, &opts);
19803 assert_eq!(output, "\n");
19804 }
19805
19806 #[test]
19807 fn plus_bullet_paragraph_roundtrips() {
19808 let adf_json = r#"{"version":1,"type":"doc","content":[
19811 {"type":"paragraph","content":[
19812 {"type":"text","text":"+ plus"}
19813 ]}
19814 ]}"#;
19815 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19816 let md = adf_to_markdown(&doc).unwrap();
19817 let rt = markdown_to_adf(&md).unwrap();
19818 assert_eq!(rt.content[0].node_type, "paragraph");
19819 assert_eq!(
19820 rt.content[0].content.as_ref().unwrap()[0]
19821 .text
19822 .as_deref()
19823 .unwrap(),
19824 "+ plus"
19825 );
19826 }
19827
19828 #[test]
19831 fn issue_430_file_media_in_bullet_list_roundtrip() {
19832 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19835 {"type":"listItem","content":[{
19836 "type":"mediaSingle",
19837 "attrs":{"layout":"center","width":1009,"widthType":"pixel"},
19838 "content":[{
19839 "type":"media",
19840 "attrs":{"collection":"contentId-123","height":576,"id":"00066e8e-554e-4d7e-af59-a0ef2888bdb6","type":"file","width":1009}
19841 }]
19842 }]}
19843 ]}]}"#;
19844 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19845 let md = adf_to_markdown(&doc).unwrap();
19846 let rt = markdown_to_adf(&md).unwrap();
19847
19848 let list = &rt.content[0];
19849 assert_eq!(list.node_type, "bulletList");
19850 let item = &list.content.as_ref().unwrap()[0];
19851 assert_eq!(item.node_type, "listItem");
19852 let ms = &item.content.as_ref().unwrap()[0];
19853 assert_eq!(ms.node_type, "mediaSingle");
19854 let ms_attrs = ms.attrs.as_ref().unwrap();
19855 assert_eq!(ms_attrs["layout"], "center");
19856 assert_eq!(ms_attrs["width"], 1009);
19857 assert_eq!(ms_attrs["widthType"], "pixel");
19858 let media = &ms.content.as_ref().unwrap()[0];
19859 assert_eq!(media.node_type, "media");
19860 let m_attrs = media.attrs.as_ref().unwrap();
19861 assert_eq!(m_attrs["type"], "file");
19862 assert_eq!(m_attrs["id"], "00066e8e-554e-4d7e-af59-a0ef2888bdb6");
19863 assert_eq!(m_attrs["collection"], "contentId-123");
19864 assert_eq!(m_attrs["height"], 576);
19865 assert_eq!(m_attrs["width"], 1009);
19866 }
19867
19868 #[test]
19869 fn issue_430_file_media_in_ordered_list_roundtrip() {
19870 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19872 {"type":"listItem","content":[{
19873 "type":"mediaSingle",
19874 "attrs":{"layout":"center"},
19875 "content":[{
19876 "type":"media",
19877 "attrs":{"type":"file","id":"abc-123","collection":"contentId-456","height":100,"width":200}
19878 }]
19879 }]}
19880 ]}]}"#;
19881 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19882 let md = adf_to_markdown(&doc).unwrap();
19883 let rt = markdown_to_adf(&md).unwrap();
19884
19885 let list = &rt.content[0];
19886 assert_eq!(list.node_type, "orderedList");
19887 let item = &list.content.as_ref().unwrap()[0];
19888 assert_eq!(item.node_type, "listItem");
19889 let ms = &item.content.as_ref().unwrap()[0];
19890 assert_eq!(ms.node_type, "mediaSingle");
19891 let media = &ms.content.as_ref().unwrap()[0];
19892 assert_eq!(media.node_type, "media");
19893 let m_attrs = media.attrs.as_ref().unwrap();
19894 assert_eq!(m_attrs["type"], "file");
19895 assert_eq!(m_attrs["id"], "abc-123");
19896 assert_eq!(m_attrs["collection"], "contentId-456");
19897 }
19898
19899 #[test]
19900 fn issue_430_external_media_in_bullet_list_roundtrip() {
19901 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19903 {"type":"listItem","content":[{
19904 "type":"mediaSingle",
19905 "attrs":{"layout":"center"},
19906 "content":[{
19907 "type":"media",
19908 "attrs":{"type":"external","url":"https://example.com/img.png","alt":"Photo"}
19909 }]
19910 }]}
19911 ]}]}"#;
19912 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19913 let md = adf_to_markdown(&doc).unwrap();
19914 let rt = markdown_to_adf(&md).unwrap();
19915
19916 let list = &rt.content[0];
19917 assert_eq!(list.node_type, "bulletList");
19918 let item = &list.content.as_ref().unwrap()[0];
19919 let ms = &item.content.as_ref().unwrap()[0];
19920 assert_eq!(ms.node_type, "mediaSingle");
19921 let media = &ms.content.as_ref().unwrap()[0];
19922 assert_eq!(media.node_type, "media");
19923 let m_attrs = media.attrs.as_ref().unwrap();
19924 assert_eq!(m_attrs["type"], "external");
19925 assert_eq!(m_attrs["url"], "https://example.com/img.png");
19926 }
19927
19928 #[test]
19929 fn issue_430_media_with_paragraph_siblings_in_list_item() {
19930 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19933 {"type":"listItem","content":[
19934 {"type":"paragraph","content":[{"type":"text","text":"Caption:"}]},
19935 {"type":"mediaSingle","attrs":{"layout":"center"},
19936 "content":[{"type":"media","attrs":{"type":"file","id":"img-001","collection":"col-1","height":50,"width":100}}]}
19937 ]}
19938 ]}]}"#;
19939 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19940 let md = adf_to_markdown(&doc).unwrap();
19941 let rt = markdown_to_adf(&md).unwrap();
19942
19943 let item = &rt.content[0].content.as_ref().unwrap()[0];
19944 let children = item.content.as_ref().unwrap();
19945 assert_eq!(children.len(), 2, "expected 2 children in listItem");
19946 assert_eq!(children[0].node_type, "paragraph");
19947 assert_eq!(children[1].node_type, "mediaSingle");
19948 let media = &children[1].content.as_ref().unwrap()[0];
19949 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-001");
19950 }
19951
19952 #[test]
19953 fn issue_430_multiple_media_in_list_items() {
19954 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19956 {"type":"listItem","content":[{
19957 "type":"mediaSingle","attrs":{"layout":"center"},
19958 "content":[{"type":"media","attrs":{"type":"file","id":"img-a","collection":"c1","height":10,"width":20}}]
19959 }]},
19960 {"type":"listItem","content":[{
19961 "type":"mediaSingle","attrs":{"layout":"center"},
19962 "content":[{"type":"media","attrs":{"type":"file","id":"img-b","collection":"c2","height":30,"width":40}}]
19963 }]}
19964 ]}]}"#;
19965 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19966 let md = adf_to_markdown(&doc).unwrap();
19967 let rt = markdown_to_adf(&md).unwrap();
19968
19969 let items = rt.content[0].content.as_ref().unwrap();
19970 assert_eq!(items.len(), 2);
19971 for (i, expected_id) in [("img-a", "c1"), ("img-b", "c2")].iter().enumerate() {
19972 let ms = &items[i].content.as_ref().unwrap()[0];
19973 assert_eq!(ms.node_type, "mediaSingle");
19974 let m_attrs = ms.content.as_ref().unwrap()[0].attrs.as_ref().unwrap();
19975 assert_eq!(m_attrs["id"], expected_id.0);
19976 assert_eq!(m_attrs["collection"], expected_id.1);
19977 }
19978 }
19979
19980 #[test]
19981 fn issue_430_jfm_to_adf_media_in_bullet_item() {
19982 let md = "- ![](){type=file id=test-id collection=col-1 height=100 width=200}\n";
19985 let doc = markdown_to_adf(md).unwrap();
19986
19987 let list = &doc.content[0];
19988 assert_eq!(list.node_type, "bulletList");
19989 let item = &list.content.as_ref().unwrap()[0];
19990 let ms = &item.content.as_ref().unwrap()[0];
19991 assert_eq!(
19992 ms.node_type, "mediaSingle",
19993 "expected mediaSingle, got {}",
19994 ms.node_type
19995 );
19996 let media = &ms.content.as_ref().unwrap()[0];
19997 assert_eq!(media.node_type, "media");
19998 let m_attrs = media.attrs.as_ref().unwrap();
19999 assert_eq!(m_attrs["type"], "file");
20000 assert_eq!(m_attrs["id"], "test-id");
20001 }
20002
20003 #[test]
20004 fn issue_430_jfm_to_adf_media_in_ordered_item() {
20005 let md = "1. \n";
20007 let doc = markdown_to_adf(md).unwrap();
20008
20009 let list = &doc.content[0];
20010 assert_eq!(list.node_type, "orderedList");
20011 let item = &list.content.as_ref().unwrap()[0];
20012 let ms = &item.content.as_ref().unwrap()[0];
20013 assert_eq!(
20014 ms.node_type, "mediaSingle",
20015 "expected mediaSingle, got {}",
20016 ms.node_type
20017 );
20018 }
20019
20020 #[test]
20021 fn issue_430_media_then_paragraph_in_bullet_list_roundtrip() {
20022 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20025 {"type":"listItem","content":[
20026 {"type":"mediaSingle","attrs":{"layout":"center"},
20027 "content":[{"type":"media","attrs":{"type":"file","id":"img-first","collection":"col-1","height":50,"width":100}}]},
20028 {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
20029 ]}
20030 ]}]}"#;
20031 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20032 let md = adf_to_markdown(&doc).unwrap();
20033 let rt = markdown_to_adf(&md).unwrap();
20034
20035 let item = &rt.content[0].content.as_ref().unwrap()[0];
20036 let children = item.content.as_ref().unwrap();
20037 assert_eq!(children.len(), 2, "expected 2 children in listItem");
20038 assert_eq!(children[0].node_type, "mediaSingle");
20039 let media = &children[0].content.as_ref().unwrap()[0];
20040 assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-first");
20041 assert_eq!(children[1].node_type, "paragraph");
20042 }
20043
20044 #[test]
20045 fn issue_430_media_then_paragraph_in_ordered_list_roundtrip() {
20046 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20048 {"type":"listItem","content":[
20049 {"type":"mediaSingle","attrs":{"layout":"center"},
20050 "content":[{"type":"media","attrs":{"type":"file","id":"img-ord","collection":"col-2","height":60,"width":120}}]},
20051 {"type":"paragraph","content":[{"type":"text","text":"Description"}]}
20052 ]}
20053 ]}]}"#;
20054 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20055 let md = adf_to_markdown(&doc).unwrap();
20056 let rt = markdown_to_adf(&md).unwrap();
20057
20058 let item = &rt.content[0].content.as_ref().unwrap()[0];
20059 let children = item.content.as_ref().unwrap();
20060 assert_eq!(children.len(), 2, "expected 2 children in listItem");
20061 assert_eq!(children[0].node_type, "mediaSingle");
20062 assert_eq!(children[1].node_type, "paragraph");
20063 }
20064
20065 #[test]
20066 fn issue_430_external_media_with_width_type_roundtrip() {
20067 let adf_json = r#"{"version":1,"type":"doc","content":[{
20069 "type":"mediaSingle",
20070 "attrs":{"layout":"wide","width":800,"widthType":"pixel"},
20071 "content":[{
20072 "type":"media",
20073 "attrs":{"type":"external","url":"https://example.com/photo.png","alt":"wide photo"}
20074 }]
20075 }]}"#;
20076 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20077 let md = adf_to_markdown(&doc).unwrap();
20078 assert!(
20079 md.contains("widthType=pixel"),
20080 "expected widthType=pixel in markdown, got: {md}"
20081 );
20082 let rt = markdown_to_adf(&md).unwrap();
20083 let ms = &rt.content[0];
20084 assert_eq!(ms.node_type, "mediaSingle");
20085 let ms_attrs = ms.attrs.as_ref().unwrap();
20086 assert_eq!(ms_attrs["widthType"], "pixel");
20087 assert_eq!(ms_attrs["width"], 800);
20088 assert_eq!(ms_attrs["layout"], "wide");
20089 }
20090
20091 #[test]
20094 fn issue_490_paragraph_with_hardbreak_then_media_single_roundtrip() {
20095 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20098 {"type":"listItem","content":[
20099 {"type":"paragraph","content":[
20100 {"type":"text","text":"Item with image:"},
20101 {"type":"hardBreak"}
20102 ]},
20103 {"type":"mediaSingle","attrs":{"layout":"center","width":400,"widthType":"pixel"},
20104 "content":[{"type":"media","attrs":{
20105 "id":"aabbccdd-1234-5678-abcd-aabbccdd1234",
20106 "type":"file",
20107 "collection":"contentId-123456",
20108 "width":800,
20109 "height":600
20110 }}]}
20111 ]}
20112 ]}]}"#;
20113 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20114 let md = adf_to_markdown(&doc).unwrap();
20115 let rt = markdown_to_adf(&md).unwrap();
20116
20117 let item = &rt.content[0].content.as_ref().unwrap()[0];
20118 let children = item.content.as_ref().unwrap();
20119 assert_eq!(children.len(), 2, "expected 2 children in listItem");
20120 assert_eq!(children[0].node_type, "paragraph");
20121 assert_eq!(
20122 children[1].node_type, "mediaSingle",
20123 "expected mediaSingle, got {:?}",
20124 children[1].node_type
20125 );
20126 let media = &children[1].content.as_ref().unwrap()[0];
20127 let m_attrs = media.attrs.as_ref().unwrap();
20128 assert_eq!(m_attrs["id"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
20129 assert_eq!(m_attrs["collection"], "contentId-123456");
20130 assert_eq!(m_attrs["height"], 600);
20131 assert_eq!(m_attrs["width"], 800);
20132 }
20133
20134 #[test]
20135 fn issue_490_paragraph_with_hardbreak_then_media_single_ordered_list() {
20136 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20138 {"type":"listItem","content":[
20139 {"type":"paragraph","content":[
20140 {"type":"text","text":"Step with screenshot:"},
20141 {"type":"hardBreak"}
20142 ]},
20143 {"type":"mediaSingle","attrs":{"layout":"center"},
20144 "content":[{"type":"media","attrs":{
20145 "id":"ord-media-id","type":"file","collection":"col-ord","width":640,"height":480
20146 }}]}
20147 ]}
20148 ]}]}"#;
20149 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20150 let md = adf_to_markdown(&doc).unwrap();
20151 let rt = markdown_to_adf(&md).unwrap();
20152
20153 let item = &rt.content[0].content.as_ref().unwrap()[0];
20154 let children = item.content.as_ref().unwrap();
20155 assert_eq!(children.len(), 2, "expected 2 children in listItem");
20156 assert_eq!(children[0].node_type, "paragraph");
20157 assert_eq!(children[1].node_type, "mediaSingle");
20158 let media = &children[1].content.as_ref().unwrap()[0];
20159 assert_eq!(media.attrs.as_ref().unwrap()["id"], "ord-media-id");
20160 }
20161
20162 #[test]
20163 fn issue_490_hardbreak_continuation_does_not_swallow_media_line() {
20164 let md = "- Item with image:\\\n ![](){type=file id=test-490 collection=col height=100 width=200}\n";
20167 let doc = markdown_to_adf(md).unwrap();
20168
20169 let item = &doc.content[0].content.as_ref().unwrap()[0];
20170 let children = item.content.as_ref().unwrap();
20171 assert_eq!(children.len(), 2, "expected 2 children in listItem");
20172 assert_eq!(children[0].node_type, "paragraph");
20173 assert_eq!(
20174 children[1].node_type, "mediaSingle",
20175 "expected mediaSingle as second child, got {:?}",
20176 children[1].node_type
20177 );
20178 let media = &children[1].content.as_ref().unwrap()[0];
20179 assert_eq!(media.attrs.as_ref().unwrap()["id"], "test-490");
20180 }
20181
20182 #[test]
20183 fn issue_490_hardbreak_continuation_still_works_for_text() {
20184 let md = "- first line\\\n second line\n";
20186 let doc = markdown_to_adf(md).unwrap();
20187
20188 let item = &doc.content[0].content.as_ref().unwrap()[0];
20189 let children = item.content.as_ref().unwrap();
20190 assert_eq!(
20191 children.len(),
20192 1,
20193 "expected 1 child (paragraph) in listItem"
20194 );
20195 assert_eq!(children[0].node_type, "paragraph");
20196 let inlines = children[0].content.as_ref().unwrap();
20197 assert_eq!(inlines.len(), 3);
20199 assert_eq!(inlines[0].node_type, "text");
20200 assert_eq!(inlines[1].node_type, "hardBreak");
20201 assert_eq!(inlines[2].node_type, "text");
20202 }
20203
20204 #[test]
20205 fn issue_490_external_media_after_hardbreak_roundtrip() {
20206 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20208 {"type":"listItem","content":[
20209 {"type":"paragraph","content":[
20210 {"type":"text","text":"See image:"},
20211 {"type":"hardBreak"}
20212 ]},
20213 {"type":"mediaSingle","attrs":{"layout":"center"},
20214 "content":[{"type":"media","attrs":{
20215 "type":"external","url":"https://example.com/photo.png","alt":"photo"
20216 }}]}
20217 ]}
20218 ]}]}"#;
20219 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20220 let md = adf_to_markdown(&doc).unwrap();
20221 let rt = markdown_to_adf(&md).unwrap();
20222
20223 let item = &rt.content[0].content.as_ref().unwrap()[0];
20224 let children = item.content.as_ref().unwrap();
20225 assert_eq!(children.len(), 2);
20226 assert_eq!(children[0].node_type, "paragraph");
20227 assert_eq!(children[1].node_type, "mediaSingle");
20228 let media = &children[1].content.as_ref().unwrap()[0];
20229 let m_attrs = media.attrs.as_ref().unwrap();
20230 assert_eq!(m_attrs["url"], "https://example.com/photo.png");
20231 }
20232
20233 #[test]
20234 fn issue_490_multiple_hardbreaks_then_media_single() {
20235 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20237 {"type":"listItem","content":[
20238 {"type":"paragraph","content":[
20239 {"type":"text","text":"line one"},
20240 {"type":"hardBreak"},
20241 {"type":"text","text":"line two"},
20242 {"type":"hardBreak"}
20243 ]},
20244 {"type":"mediaSingle","attrs":{"layout":"center"},
20245 "content":[{"type":"media","attrs":{
20246 "type":"file","id":"multi-hb","collection":"col-m","width":320,"height":240
20247 }}]}
20248 ]}
20249 ]}]}"#;
20250 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20251 let md = adf_to_markdown(&doc).unwrap();
20252 let rt = markdown_to_adf(&md).unwrap();
20253
20254 let item = &rt.content[0].content.as_ref().unwrap()[0];
20255 let children = item.content.as_ref().unwrap();
20256 assert_eq!(children.len(), 2, "expected paragraph + mediaSingle");
20257 assert_eq!(children[0].node_type, "paragraph");
20258 assert_eq!(children[1].node_type, "mediaSingle");
20259 let media = &children[1].content.as_ref().unwrap()[0];
20260 assert_eq!(media.attrs.as_ref().unwrap()["id"], "multi-hb");
20261 }
20262
20263 #[test]
20266 fn issue_525_listitem_localid_with_mediasingle_roundtrip() {
20267 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"}]}]}]}]}]}]}"#;
20270 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20271 let md = adf_to_markdown(&doc).unwrap();
20272 let rt = markdown_to_adf(&md).unwrap();
20273
20274 let list = &rt.content[0];
20275 assert_eq!(list.node_type, "bulletList");
20276 let item = &list.content.as_ref().unwrap()[0];
20277 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20279 assert_eq!(
20280 item_attrs["localId"], "aabbccdd-1234-5678-abcd-000000000001",
20281 "listItem localId must survive round-trip"
20282 );
20283 let children = item.content.as_ref().unwrap();
20284 assert_eq!(
20285 children.len(),
20286 3,
20287 "expected mediaSingle + paragraph + bulletList"
20288 );
20289 assert_eq!(children[0].node_type, "mediaSingle");
20290 assert_eq!(children[1].node_type, "paragraph");
20291 assert_eq!(children[2].node_type, "bulletList");
20292 }
20293
20294 #[test]
20295 fn issue_525_listitem_localid_with_mediasingle_only() {
20296 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20298 {"type":"listItem","attrs":{"localId":"li-media-only"},"content":[
20299 {"type":"mediaSingle","attrs":{"layout":"center"},
20300 "content":[{"type":"media","attrs":{"type":"file","id":"m-001","collection":"c1","height":50,"width":100}}]}
20301 ]}
20302 ]}]}"#;
20303 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20304 let md = adf_to_markdown(&doc).unwrap();
20305 let rt = markdown_to_adf(&md).unwrap();
20306
20307 let item = &rt.content[0].content.as_ref().unwrap()[0];
20308 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20309 assert_eq!(
20310 item_attrs["localId"], "li-media-only",
20311 "listItem localId must survive when sole child is mediaSingle"
20312 );
20313 assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20314 }
20315
20316 #[test]
20317 fn issue_525_listitem_localid_with_external_media() {
20318 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20320 {"type":"listItem","attrs":{"localId":"li-ext-media"},"content":[
20321 {"type":"mediaSingle","attrs":{"layout":"center"},
20322 "content":[{"type":"media","attrs":{"type":"external","url":"https://example.com/img.png","alt":"photo"}}]}
20323 ]}
20324 ]}]}"#;
20325 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20326 let md = adf_to_markdown(&doc).unwrap();
20327 let rt = markdown_to_adf(&md).unwrap();
20328
20329 let item = &rt.content[0].content.as_ref().unwrap()[0];
20330 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20331 assert_eq!(
20332 item_attrs["localId"], "li-ext-media",
20333 "listItem localId must survive with external mediaSingle"
20334 );
20335 }
20336
20337 #[test]
20338 fn issue_525_listitem_localid_with_mediasingle_in_ordered_list() {
20339 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20341 {"type":"listItem","attrs":{"localId":"li-ord-media"},"content":[
20342 {"type":"mediaSingle","attrs":{"layout":"center","width":200,"widthType":"pixel"},
20343 "content":[{"type":"media","attrs":{"type":"file","id":"ord-m-001","collection":"col-ord","height":80,"width":160}}]},
20344 {"type":"paragraph","content":[{"type":"text","text":"ordered item text"}]}
20345 ]}
20346 ]}]}"#;
20347 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20348 let md = adf_to_markdown(&doc).unwrap();
20349 let rt = markdown_to_adf(&md).unwrap();
20350
20351 let item = &rt.content[0].content.as_ref().unwrap()[0];
20352 let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20353 assert_eq!(
20354 item_attrs["localId"], "li-ord-media",
20355 "listItem localId must survive in ordered list with mediaSingle"
20356 );
20357 let children = item.content.as_ref().unwrap();
20358 assert_eq!(children[0].node_type, "mediaSingle");
20359 assert_eq!(children[1].node_type, "paragraph");
20360 }
20361
20362 #[test]
20363 fn issue_525_jfm_localid_on_mediasingle_line_parses_correctly() {
20364 let md = "- ![](){type=file id=test-525 collection=col height=100 width=200 mediaWidth=100 widthType=pixel} {localId=li-jfm-525}\n";
20367 let doc = markdown_to_adf(md).unwrap();
20368
20369 let item = &doc.content[0].content.as_ref().unwrap()[0];
20370 let item_attrs = item
20371 .attrs
20372 .as_ref()
20373 .expect("listItem attrs must be present from JFM");
20374 assert_eq!(item_attrs["localId"], "li-jfm-525");
20375 assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20376 }
20377
20378 #[test]
20379 fn issue_525_encoding_emits_localid_on_mediasingle_line() {
20380 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20383 {"type":"listItem","attrs":{"localId":"li-emit-check"},"content":[
20384 {"type":"mediaSingle","attrs":{"layout":"center"},
20385 "content":[{"type":"media","attrs":{"type":"file","id":"m-emit","collection":"c-emit","height":10,"width":20}}]}
20386 ]}
20387 ]}]}"#;
20388 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20389 let md = adf_to_markdown(&doc).unwrap();
20390 assert!(
20391 md.contains("{localId=li-emit-check}"),
20392 "expected localId in JFM output, got: {md}"
20393 );
20394 for line in md.lines() {
20396 if line.contains("![") {
20397 assert!(
20398 line.contains("localId=li-emit-check"),
20399 "localId must be on the same line as the image: {line}"
20400 );
20401 }
20402 }
20403 }
20404
20405 #[test]
20408 fn adf_placeholder_to_markdown() {
20409 let doc = AdfDocument {
20410 version: 1,
20411 doc_type: "doc".to_string(),
20412 content: vec![AdfNode::paragraph(vec![AdfNode::placeholder(
20413 "Type something here",
20414 )])],
20415 };
20416 let md = adf_to_markdown(&doc).unwrap();
20417 assert!(
20418 md.contains(":placeholder[Type something here]"),
20419 "expected :placeholder directive, got: {md}"
20420 );
20421 }
20422
20423 #[test]
20424 fn markdown_placeholder_to_adf() {
20425 let doc = markdown_to_adf("Before :placeholder[Enter name] after").unwrap();
20426 let content = doc.content[0].content.as_ref().unwrap();
20427 assert_eq!(content[1].node_type, "placeholder");
20428 let attrs = content[1].attrs.as_ref().unwrap();
20429 assert_eq!(attrs["text"], "Enter name");
20430 }
20431
20432 #[test]
20433 fn placeholder_round_trip() {
20434 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"placeholder","attrs":{"text":"Type something here"}}]}]}"#;
20435 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20436 let md = adf_to_markdown(&doc).unwrap();
20437 let rt = markdown_to_adf(&md).unwrap();
20438 let content = rt.content[0].content.as_ref().unwrap();
20439 assert_eq!(content.len(), 1);
20440 assert_eq!(content[0].node_type, "placeholder");
20441 let attrs = content[0].attrs.as_ref().unwrap();
20442 assert_eq!(attrs["text"], "Type something here");
20443 }
20444
20445 #[test]
20446 fn placeholder_empty_text() {
20447 let doc = AdfDocument {
20448 version: 1,
20449 doc_type: "doc".to_string(),
20450 content: vec![AdfNode::paragraph(vec![AdfNode::placeholder("")])],
20451 };
20452 let md = adf_to_markdown(&doc).unwrap();
20453 assert!(
20454 md.contains(":placeholder[]"),
20455 "expected empty placeholder directive, got: {md}"
20456 );
20457 let rt = markdown_to_adf(&md).unwrap();
20458 let content = rt.content[0].content.as_ref().unwrap();
20459 assert_eq!(content[0].node_type, "placeholder");
20460 assert_eq!(content[0].attrs.as_ref().unwrap()["text"], "");
20461 }
20462
20463 #[test]
20464 fn placeholder_with_surrounding_text() {
20465 let md = "Click :placeholder[here] to continue\n";
20466 let doc = markdown_to_adf(md).unwrap();
20467 let content = doc.content[0].content.as_ref().unwrap();
20468 assert_eq!(content[0].text.as_deref(), Some("Click "));
20469 assert_eq!(content[1].node_type, "placeholder");
20470 assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "here");
20471 assert_eq!(content[2].text.as_deref(), Some(" to continue"));
20472 }
20473
20474 #[test]
20475 fn placeholder_missing_attrs() {
20476 let doc = AdfDocument {
20478 version: 1,
20479 doc_type: "doc".to_string(),
20480 content: vec![AdfNode::paragraph(vec![AdfNode {
20481 node_type: "placeholder".to_string(),
20482 attrs: None,
20483 content: None,
20484 text: None,
20485 marks: None,
20486 local_id: None,
20487 parameters: None,
20488 }])],
20489 };
20490 let md = adf_to_markdown(&doc).unwrap();
20491 assert!(!md.contains("placeholder"));
20493 }
20494
20495 #[test]
20497 fn mention_in_table_bullet_list_preserves_id_and_local_id() {
20498 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":" "}]}]}]}]}]}]}]}"#;
20499 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20500 let md = adf_to_markdown(&doc).unwrap();
20501 let rt = markdown_to_adf(&md).unwrap();
20502
20503 let cell = &rt.content[0].content.as_ref().unwrap()[0]
20505 .content
20506 .as_ref()
20507 .unwrap()[0];
20508 let list = &cell.content.as_ref().unwrap()[0];
20509 let list_item = &list.content.as_ref().unwrap()[0];
20510
20511 assert!(
20513 list_item
20514 .attrs
20515 .as_ref()
20516 .and_then(|a| a.get("localId"))
20517 .is_none(),
20518 "localId should stay on the mention, not the listItem"
20519 );
20520
20521 let para = &list_item.content.as_ref().unwrap()[0];
20522 let inlines = para.content.as_ref().unwrap();
20523
20524 assert_eq!(inlines.len(), 3, "expected 3 inline nodes, got {inlines:?}");
20526
20527 assert_eq!(inlines[0].node_type, "text");
20528 assert_eq!(inlines[0].text.as_deref(), Some("prefix text "));
20529
20530 assert_eq!(inlines[1].node_type, "mention");
20531 let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20532 assert_eq!(
20533 mention_attrs["id"], "aabbccdd11223344aabbccdd",
20534 "mention id must be preserved"
20535 );
20536 assert_eq!(
20537 mention_attrs["localId"], "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
20538 "mention localId must be preserved"
20539 );
20540 assert_eq!(mention_attrs["text"], "@Alice Example");
20541
20542 assert_eq!(inlines[2].node_type, "text");
20543 assert_eq!(inlines[2].text.as_deref(), Some(" "));
20544 }
20545
20546 #[test]
20547 fn mention_in_bullet_list_preserves_id_and_local_id() {
20548 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":" "}]}]}]}]}"#;
20550 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20551 let md = adf_to_markdown(&doc).unwrap();
20552 let rt = markdown_to_adf(&md).unwrap();
20553
20554 let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20555 assert!(
20556 list_item
20557 .attrs
20558 .as_ref()
20559 .and_then(|a| a.get("localId"))
20560 .is_none(),
20561 "localId should stay on the mention, not the listItem"
20562 );
20563
20564 let para = &list_item.content.as_ref().unwrap()[0];
20565 let inlines = para.content.as_ref().unwrap();
20566 assert_eq!(inlines[0].node_type, "mention");
20567 let mention_attrs = inlines[0].attrs.as_ref().unwrap();
20568 assert_eq!(mention_attrs["id"], "user123");
20569 assert_eq!(
20570 mention_attrs["localId"],
20571 "11111111-2222-3333-4444-555555555555"
20572 );
20573 }
20574
20575 #[test]
20576 fn mention_in_ordered_list_preserves_id_and_local_id() {
20577 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"}}]}]}]}]}"#;
20578 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20579 let md = adf_to_markdown(&doc).unwrap();
20580 let rt = markdown_to_adf(&md).unwrap();
20581
20582 let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20583 assert!(
20584 list_item
20585 .attrs
20586 .as_ref()
20587 .and_then(|a| a.get("localId"))
20588 .is_none(),
20589 "localId should stay on the mention, not the listItem"
20590 );
20591
20592 let para = &list_item.content.as_ref().unwrap()[0];
20593 let inlines = para.content.as_ref().unwrap();
20594 assert_eq!(inlines[1].node_type, "mention");
20595 let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20596 assert_eq!(mention_attrs["id"], "xyz");
20597 assert_eq!(mention_attrs["localId"], "aaaa-bbbb");
20598 }
20599
20600 #[test]
20601 fn list_item_own_local_id_with_mention_both_preserved() {
20602 let md = "- hello :mention[@Eve]{id=e1 localId=mention-lid} {localId=item-lid}\n";
20605 let doc = markdown_to_adf(md).unwrap();
20606 let list_item = &doc.content[0].content.as_ref().unwrap()[0];
20607
20608 let item_attrs = list_item.attrs.as_ref().unwrap();
20610 assert_eq!(item_attrs["localId"], "item-lid");
20611
20612 let para = &list_item.content.as_ref().unwrap()[0];
20614 let inlines = para.content.as_ref().unwrap();
20615 let mention = inlines.iter().find(|n| n.node_type == "mention").unwrap();
20616 let mention_attrs = mention.attrs.as_ref().unwrap();
20617 assert_eq!(mention_attrs["id"], "e1");
20618 assert_eq!(mention_attrs["localId"], "mention-lid");
20619 }
20620
20621 #[test]
20622 fn extract_trailing_local_id_ignores_directive_attrs() {
20623 let line = "text :mention[@X]{id=abc localId=uuid}";
20626 let (text, lid, plid) = extract_trailing_local_id(line);
20627 assert_eq!(text, line, "text should be unchanged");
20628 assert!(
20629 lid.is_none(),
20630 "should not extract localId from directive attrs"
20631 );
20632 assert!(plid.is_none());
20633 }
20634
20635 #[test]
20636 fn extract_trailing_local_id_matches_standalone_block() {
20637 let line = "some text {localId=abc-123}";
20639 let (text, lid, plid) = extract_trailing_local_id(line);
20640 assert_eq!(text, "some text");
20641 assert_eq!(lid.as_deref(), Some("abc-123"));
20642 assert!(plid.is_none());
20643 }
20644
20645 #[test]
20648 fn newline_in_text_node_roundtrips_in_bullet_list() {
20649 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"}]}]}]}]}"#;
20653 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20654 let md = adf_to_markdown(&doc).unwrap();
20655 let rt = markdown_to_adf(&md).unwrap();
20656
20657 assert_eq!(rt.content.len(), 1);
20659 let list = &rt.content[0];
20660 assert_eq!(list.node_type, "bulletList");
20661 let items = list.content.as_ref().unwrap();
20662 assert_eq!(items.len(), 1);
20663
20664 let item_content = items[0].content.as_ref().unwrap();
20666 assert_eq!(
20667 item_content.len(),
20668 1,
20669 "listItem should have exactly one paragraph"
20670 );
20671 assert_eq!(item_content[0].node_type, "paragraph");
20672
20673 let inlines = item_content[0].content.as_ref().unwrap();
20675 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20676 assert_eq!(
20677 types,
20678 vec!["text", "hardBreak", "text"],
20679 "embedded newline should stay in a single text node, not produce extra hardBreaks"
20680 );
20681 assert_eq!(
20682 inlines[2].text.as_deref(),
20683 Some("first command\nsecond command")
20684 );
20685 }
20686
20687 #[test]
20688 fn newline_in_text_node_roundtrips_in_ordered_list() {
20689 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"}]}]}]}]}"#;
20691 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20692 let md = adf_to_markdown(&doc).unwrap();
20693 let rt = markdown_to_adf(&md).unwrap();
20694
20695 let list = &rt.content[0];
20696 assert_eq!(list.node_type, "orderedList");
20697 let items = list.content.as_ref().unwrap();
20698 assert_eq!(items.len(), 1);
20699
20700 let item_content = items[0].content.as_ref().unwrap();
20701 assert_eq!(item_content.len(), 1);
20702 assert_eq!(item_content[0].node_type, "paragraph");
20703
20704 let inlines = item_content[0].content.as_ref().unwrap();
20705 assert_eq!(inlines.len(), 1);
20706 assert_eq!(inlines[0].node_type, "text");
20707 assert_eq!(inlines[0].text.as_deref(), Some("first\nsecond"));
20708 }
20709
20710 #[test]
20711 fn newline_in_text_node_roundtrips_in_paragraph() {
20712 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello\nworld"}]}]}"#;
20715 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20716 let md = adf_to_markdown(&doc).unwrap();
20717 assert!(
20718 md.contains("hello\\nworld"),
20719 "newline in text node should render as escaped \\n: {md:?}"
20720 );
20721
20722 let rt = markdown_to_adf(&md).unwrap();
20723 let inlines = rt.content[0].content.as_ref().unwrap();
20724 assert_eq!(inlines.len(), 1);
20725 assert_eq!(inlines[0].text.as_deref(), Some("hello\nworld"));
20726 }
20727
20728 #[test]
20729 fn multiple_newlines_in_text_node_roundtrip() {
20730 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a\nb\nc"}]}]}]}]}"#;
20732 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20733 let md = adf_to_markdown(&doc).unwrap();
20734 let rt = markdown_to_adf(&md).unwrap();
20735
20736 let item_content = rt.content[0].content.as_ref().unwrap()[0]
20737 .content
20738 .as_ref()
20739 .unwrap();
20740 assert_eq!(item_content.len(), 1);
20741
20742 let inlines = item_content[0].content.as_ref().unwrap();
20743 assert_eq!(inlines.len(), 1);
20744 assert_eq!(inlines[0].text.as_deref(), Some("a\nb\nc"));
20745 }
20746
20747 #[test]
20748 fn newline_in_marked_text_node_roundtrips() {
20749 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold\ntext","marks":[{"type":"strong"}]}]}]}"#;
20752 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20753 let md = adf_to_markdown(&doc).unwrap();
20754 assert!(
20755 md.contains("**bold\\ntext**"),
20756 "bold text with embedded newline should stay in one marked run: {md:?}"
20757 );
20758
20759 let rt = markdown_to_adf(&md).unwrap();
20760 let inlines = rt.content[0].content.as_ref().unwrap();
20761 assert_eq!(inlines.len(), 1);
20762 assert_eq!(inlines[0].text.as_deref(), Some("bold\ntext"));
20763 assert!(inlines[0]
20764 .marks
20765 .as_ref()
20766 .unwrap()
20767 .iter()
20768 .any(|m| m.mark_type == "strong"));
20769 }
20770
20771 #[test]
20772 fn trailing_newline_in_text_node_roundtrips() {
20773 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"trailing\n"}]}]}"#;
20776 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20777 let md = adf_to_markdown(&doc).unwrap();
20778 assert!(
20779 md.contains("trailing\\n"),
20780 "trailing newline should be escaped: {md:?}"
20781 );
20782
20783 let rt = markdown_to_adf(&md).unwrap();
20784 let inlines = rt.content[0].content.as_ref().unwrap();
20785 assert_eq!(inlines.len(), 1);
20786 assert_eq!(inlines[0].text.as_deref(), Some("trailing\n"));
20787 }
20788
20789 #[test]
20790 fn hardbreak_and_embedded_newline_are_distinct() {
20791 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"}]}]}"#;
20794 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20795 let md = adf_to_markdown(&doc).unwrap();
20796 let rt = markdown_to_adf(&md).unwrap();
20797
20798 let inlines = rt.content[0].content.as_ref().unwrap();
20799 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20800 assert_eq!(
20801 types,
20802 vec!["text", "hardBreak", "text", "hardBreak", "text"]
20803 );
20804 assert_eq!(inlines[0].text.as_deref(), Some("before"));
20805 assert_eq!(inlines[2].text.as_deref(), Some("mid\ndle"));
20806 assert_eq!(inlines[4].text.as_deref(), Some("after"));
20807 }
20808
20809 #[test]
20812 fn issue_472_bullet_list_trailing_hardbreak_roundtrips() {
20813 let adf_json = r#"{"version":1,"type":"doc","content":[
20816 {"type":"bulletList","content":[
20817 {"type":"listItem","content":[
20818 {"type":"paragraph","content":[
20819 {"type":"text","text":"First item"},
20820 {"type":"hardBreak"}
20821 ]}
20822 ]},
20823 {"type":"listItem","content":[
20824 {"type":"paragraph","content":[
20825 {"type":"text","text":"Second item"}
20826 ]}
20827 ]}
20828 ]}
20829 ]}"#;
20830 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20831 let md = adf_to_markdown(&doc).unwrap();
20832 let rt = markdown_to_adf(&md).unwrap();
20833
20834 assert_eq!(
20836 rt.content.len(),
20837 1,
20838 "Should be 1 block (bulletList), got {}",
20839 rt.content.len()
20840 );
20841 assert_eq!(rt.content[0].node_type, "bulletList");
20842 let items = rt.content[0].content.as_ref().unwrap();
20843 assert_eq!(
20844 items.len(),
20845 2,
20846 "Should have 2 listItems, got {}",
20847 items.len()
20848 );
20849
20850 let p1 = items[0].content.as_ref().unwrap()[0]
20852 .content
20853 .as_ref()
20854 .unwrap();
20855 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20856 assert_eq!(types1, vec!["text", "hardBreak"]);
20857 assert_eq!(p1[0].text.as_deref(), Some("First item"));
20858
20859 let p2 = items[1].content.as_ref().unwrap()[0]
20861 .content
20862 .as_ref()
20863 .unwrap();
20864 assert_eq!(p2[0].text.as_deref(), Some("Second item"));
20865 }
20866
20867 #[test]
20868 fn issue_472_ordered_list_trailing_hardbreak_roundtrips() {
20869 let adf_json = r#"{"version":1,"type":"doc","content":[
20871 {"type":"orderedList","attrs":{"order":1},"content":[
20872 {"type":"listItem","content":[
20873 {"type":"paragraph","content":[
20874 {"type":"text","text":"Alpha"},
20875 {"type":"hardBreak"}
20876 ]}
20877 ]},
20878 {"type":"listItem","content":[
20879 {"type":"paragraph","content":[
20880 {"type":"text","text":"Beta"}
20881 ]}
20882 ]}
20883 ]}
20884 ]}"#;
20885 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20886 let md = adf_to_markdown(&doc).unwrap();
20887 let rt = markdown_to_adf(&md).unwrap();
20888
20889 assert_eq!(rt.content.len(), 1);
20890 assert_eq!(rt.content[0].node_type, "orderedList");
20891 let items = rt.content[0].content.as_ref().unwrap();
20892 assert_eq!(items.len(), 2);
20893
20894 let p1 = items[0].content.as_ref().unwrap()[0]
20895 .content
20896 .as_ref()
20897 .unwrap();
20898 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20899 assert_eq!(types1, vec!["text", "hardBreak"]);
20900 assert_eq!(p1[0].text.as_deref(), Some("Alpha"));
20901 }
20902
20903 #[test]
20904 fn issue_472_trailing_hardbreak_jfm_no_blank_line() {
20905 let adf_json = r#"{"version":1,"type":"doc","content":[
20908 {"type":"bulletList","content":[
20909 {"type":"listItem","content":[
20910 {"type":"paragraph","content":[
20911 {"type":"text","text":"Hello"},
20912 {"type":"hardBreak"}
20913 ]}
20914 ]},
20915 {"type":"listItem","content":[
20916 {"type":"paragraph","content":[
20917 {"type":"text","text":"World"}
20918 ]}
20919 ]}
20920 ]}
20921 ]}"#;
20922 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20923 let md = adf_to_markdown(&doc).unwrap();
20924
20925 assert_eq!(md, "- Hello\\\n- World\n");
20927 }
20928
20929 #[test]
20930 fn issue_472_multiple_trailing_hardbreaks_roundtrip() {
20931 let adf_json = r#"{"version":1,"type":"doc","content":[
20933 {"type":"bulletList","content":[
20934 {"type":"listItem","content":[
20935 {"type":"paragraph","content":[
20936 {"type":"text","text":"Item"},
20937 {"type":"hardBreak"},
20938 {"type":"hardBreak"}
20939 ]}
20940 ]},
20941 {"type":"listItem","content":[
20942 {"type":"paragraph","content":[
20943 {"type":"text","text":"Next"}
20944 ]}
20945 ]}
20946 ]}
20947 ]}"#;
20948 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20949 let md = adf_to_markdown(&doc).unwrap();
20950 let rt = markdown_to_adf(&md).unwrap();
20951
20952 assert_eq!(rt.content.len(), 1);
20954 assert_eq!(rt.content[0].node_type, "bulletList");
20955 let items = rt.content[0].content.as_ref().unwrap();
20956 assert_eq!(items.len(), 2);
20957
20958 let p1 = items[0].content.as_ref().unwrap()[0]
20960 .content
20961 .as_ref()
20962 .unwrap();
20963 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20964 assert_eq!(types1, vec!["text", "hardBreak", "hardBreak"]);
20965 }
20966
20967 #[test]
20968 fn issue_472_hardbreak_mid_and_trailing_roundtrip() {
20969 let adf_json = r#"{"version":1,"type":"doc","content":[
20971 {"type":"bulletList","content":[
20972 {"type":"listItem","content":[
20973 {"type":"paragraph","content":[
20974 {"type":"text","text":"Line one"},
20975 {"type":"hardBreak"},
20976 {"type":"text","text":"Line two"},
20977 {"type":"hardBreak"}
20978 ]}
20979 ]},
20980 {"type":"listItem","content":[
20981 {"type":"paragraph","content":[
20982 {"type":"text","text":"Other item"}
20983 ]}
20984 ]}
20985 ]}
20986 ]}"#;
20987 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20988 let md = adf_to_markdown(&doc).unwrap();
20989 let rt = markdown_to_adf(&md).unwrap();
20990
20991 assert_eq!(rt.content.len(), 1);
20992 assert_eq!(rt.content[0].node_type, "bulletList");
20993 let items = rt.content[0].content.as_ref().unwrap();
20994 assert_eq!(items.len(), 2);
20995
20996 let p1 = items[0].content.as_ref().unwrap()[0]
20997 .content
20998 .as_ref()
20999 .unwrap();
21000 let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21001 assert_eq!(types1, vec!["text", "hardBreak", "text", "hardBreak"]);
21002 assert_eq!(p1[0].text.as_deref(), Some("Line one"));
21003 assert_eq!(p1[2].text.as_deref(), Some("Line two"));
21004 }
21005
21006 #[test]
21007 fn issue_472_only_hardbreak_in_listitem_paragraph() {
21008 let adf_json = r#"{"version":1,"type":"doc","content":[
21010 {"type":"bulletList","content":[
21011 {"type":"listItem","content":[
21012 {"type":"paragraph","content":[
21013 {"type":"hardBreak"}
21014 ]}
21015 ]},
21016 {"type":"listItem","content":[
21017 {"type":"paragraph","content":[
21018 {"type":"text","text":"After"}
21019 ]}
21020 ]}
21021 ]}
21022 ]}"#;
21023 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21024 let md = adf_to_markdown(&doc).unwrap();
21025 let rt = markdown_to_adf(&md).unwrap();
21026
21027 assert_eq!(rt.content.len(), 1);
21029 assert_eq!(rt.content[0].node_type, "bulletList");
21030 let items = rt.content[0].content.as_ref().unwrap();
21031 assert_eq!(items.len(), 2);
21032 }
21033
21034 #[test]
21035 fn issue_472_three_items_middle_has_trailing_hardbreak() {
21036 let adf_json = r#"{"version":1,"type":"doc","content":[
21038 {"type":"bulletList","content":[
21039 {"type":"listItem","content":[
21040 {"type":"paragraph","content":[
21041 {"type":"text","text":"First"}
21042 ]}
21043 ]},
21044 {"type":"listItem","content":[
21045 {"type":"paragraph","content":[
21046 {"type":"text","text":"Second"},
21047 {"type":"hardBreak"}
21048 ]}
21049 ]},
21050 {"type":"listItem","content":[
21051 {"type":"paragraph","content":[
21052 {"type":"text","text":"Third"}
21053 ]}
21054 ]}
21055 ]}
21056 ]}"#;
21057 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21058 let md = adf_to_markdown(&doc).unwrap();
21059 let rt = markdown_to_adf(&md).unwrap();
21060
21061 assert_eq!(rt.content.len(), 1);
21062 assert_eq!(rt.content[0].node_type, "bulletList");
21063 let items = rt.content[0].content.as_ref().unwrap();
21064 assert_eq!(items.len(), 3);
21065 assert_eq!(
21066 items[0].content.as_ref().unwrap()[0]
21067 .content
21068 .as_ref()
21069 .unwrap()[0]
21070 .text
21071 .as_deref(),
21072 Some("First")
21073 );
21074 assert_eq!(
21075 items[2].content.as_ref().unwrap()[0]
21076 .content
21077 .as_ref()
21078 .unwrap()[0]
21079 .text
21080 .as_deref(),
21081 Some("Third")
21082 );
21083 }
21084
21085 #[test]
21088 fn issue_494_space_after_hardbreak_roundtrip() {
21089 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21092 {"type":"text","text":"Some text"},
21093 {"type":"hardBreak"},
21094 {"type":"text","text":" "}
21095 ]}]}"#;
21096 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21097 let md = adf_to_markdown(&doc).unwrap();
21098 let rt = markdown_to_adf(&md).unwrap();
21099 let inlines = rt.content[0].content.as_ref().unwrap();
21100 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21101 assert_eq!(
21102 types,
21103 vec!["text", "hardBreak", "text"],
21104 "space-only text node after hardBreak should survive round-trip"
21105 );
21106 assert_eq!(inlines[2].text.as_deref(), Some(" "));
21107 }
21108
21109 #[test]
21110 fn issue_494_multiple_spaces_after_hardbreak_roundtrip() {
21111 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21113 {"type":"text","text":"Hello"},
21114 {"type":"hardBreak"},
21115 {"type":"text","text":" "}
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 let inlines = rt.content[0].content.as_ref().unwrap();
21121 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21122 assert_eq!(
21123 types,
21124 vec!["text", "hardBreak", "text"],
21125 "multi-space text node after hardBreak should survive round-trip"
21126 );
21127 assert_eq!(inlines[2].text.as_deref(), Some(" "));
21128 }
21129
21130 #[test]
21131 fn issue_494_space_then_text_after_hardbreak_roundtrip() {
21132 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21135 {"type":"text","text":"Before"},
21136 {"type":"hardBreak"},
21137 {"type":"text","text":" After"}
21138 ]}]}"#;
21139 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21140 let md = adf_to_markdown(&doc).unwrap();
21141 let rt = markdown_to_adf(&md).unwrap();
21142 let inlines = rt.content[0].content.as_ref().unwrap();
21143 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21144 assert_eq!(types, vec!["text", "hardBreak", "text"]);
21145 assert_eq!(inlines[2].text.as_deref(), Some(" After"));
21146 }
21147
21148 #[test]
21149 fn issue_494_hardbreak_then_space_then_hardbreak_roundtrip() {
21150 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21152 {"type":"text","text":"A"},
21153 {"type":"hardBreak"},
21154 {"type":"text","text":" "},
21155 {"type":"hardBreak"},
21156 {"type":"text","text":"B"}
21157 ]}]}"#;
21158 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21159 let md = adf_to_markdown(&doc).unwrap();
21160 let rt = markdown_to_adf(&md).unwrap();
21161 let inlines = rt.content[0].content.as_ref().unwrap();
21162 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21163 assert_eq!(
21164 types,
21165 vec!["text", "hardBreak", "text", "hardBreak", "text"],
21166 "space between two hardBreaks should survive round-trip"
21167 );
21168 assert_eq!(inlines[2].text.as_deref(), Some(" "));
21169 assert_eq!(inlines[4].text.as_deref(), Some("B"));
21170 }
21171
21172 #[test]
21173 fn issue_494_trailing_space_hardbreak_style_not_confused() {
21174 let md = "first paragraph\n\nsecond paragraph\n";
21177 let doc = markdown_to_adf(md).unwrap();
21178 assert_eq!(
21179 doc.content.len(),
21180 2,
21181 "blank line should still separate paragraphs"
21182 );
21183 }
21184
21185 #[test]
21186 fn issue_494_space_after_trailing_space_hardbreak_roundtrip() {
21187 let md = "line one \n \n";
21190 let doc = markdown_to_adf(md).unwrap();
21194 let inlines = doc.content[0].content.as_ref().unwrap();
21195 let has_text_after_break = inlines.iter().any(|n| {
21196 n.node_type == "text"
21197 && n.text
21198 .as_deref()
21199 .is_some_and(|t| t.trim().is_empty() && !t.is_empty())
21200 });
21201 assert!(
21202 has_text_after_break || inlines.len() >= 2,
21203 "space-only line after trailing-space hardBreak should be preserved"
21204 );
21205 }
21206
21207 #[test]
21208 fn issue_494_space_after_hardbreak_in_list_item_roundtrip() {
21209 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21211 {"type":"listItem","content":[{"type":"paragraph","content":[
21212 {"type":"text","text":"item"},
21213 {"type":"hardBreak"},
21214 {"type":"text","text":" "}
21215 ]}]}
21216 ]}]}"#;
21217 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21218 let md = adf_to_markdown(&doc).unwrap();
21219 let rt = markdown_to_adf(&md).unwrap();
21220 let list = &rt.content[0];
21221 let item = &list.content.as_ref().unwrap()[0];
21222 let para = &item.content.as_ref().unwrap()[0];
21223 let inlines = para.content.as_ref().unwrap();
21224 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21225 assert_eq!(
21226 types,
21227 vec!["text", "hardBreak", "text"],
21228 "space after hardBreak in list item should survive round-trip"
21229 );
21230 assert_eq!(inlines[2].text.as_deref(), Some(" "));
21231 }
21232
21233 #[test]
21236 fn issue_510_trailing_double_space_paragraph_roundtrip() {
21237 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"}]}]}"#;
21240 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21241 let md = adf_to_markdown(&doc).unwrap();
21242 let rt = markdown_to_adf(&md).unwrap();
21243
21244 assert_eq!(
21246 rt.content.len(),
21247 2,
21248 "should produce two paragraphs, got: {}",
21249 rt.content.len()
21250 );
21251 assert_eq!(rt.content[0].node_type, "paragraph");
21252 assert_eq!(rt.content[1].node_type, "paragraph");
21253
21254 let p1 = rt.content[0].content.as_ref().unwrap();
21256 assert_eq!(
21257 p1[0].text.as_deref(),
21258 Some("first paragraph with trailing spaces "),
21259 "trailing spaces should be preserved in first paragraph"
21260 );
21261
21262 let p2 = rt.content[1].content.as_ref().unwrap();
21264 assert_eq!(p2[0].text.as_deref(), Some("second paragraph"));
21265
21266 let all_types: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21268 assert!(
21269 !all_types.contains(&"hardBreak"),
21270 "trailing spaces should not produce hardBreak, got: {all_types:?}"
21271 );
21272 }
21273
21274 #[test]
21275 fn issue_510_trailing_triple_space_roundtrip() {
21276 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"text "}]},{"type":"paragraph","content":[{"type":"text","text":"next"}]}]}"#;
21278 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21279 let md = adf_to_markdown(&doc).unwrap();
21280 let rt = markdown_to_adf(&md).unwrap();
21281
21282 assert_eq!(rt.content.len(), 2, "should still be two paragraphs");
21283 let p1 = rt.content[0].content.as_ref().unwrap();
21284 assert_eq!(
21285 p1[0].text.as_deref(),
21286 Some("text "),
21287 "three trailing spaces should be preserved"
21288 );
21289 }
21290
21291 #[test]
21292 fn issue_510_trailing_spaces_with_backslash_roundtrip() {
21293 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\ "}]}]}"#;
21295 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21296 let md = adf_to_markdown(&doc).unwrap();
21297 let rt = markdown_to_adf(&md).unwrap();
21298 let p = rt.content[0].content.as_ref().unwrap();
21299 assert_eq!(
21300 p[0].text.as_deref(),
21301 Some("end\\ "),
21302 "backslash + trailing spaces should both survive"
21303 );
21304 }
21305
21306 #[test]
21307 fn issue_510_jfm_contains_escaped_trailing_space() {
21308 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello "}]}]}"#;
21310 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21311 let md = adf_to_markdown(&doc).unwrap();
21312 assert!(
21313 md.contains(r"\ "),
21314 "JFM should contain backslash-space escape for trailing spaces, got: {md:?}"
21315 );
21316 for line in md.lines() {
21318 assert!(
21319 !line.ends_with(" "),
21320 "no JFM line should end with two plain spaces, got: {line:?}"
21321 );
21322 }
21323 }
21324
21325 #[test]
21326 fn issue_510_single_trailing_space_not_escaped() {
21327 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"word "}]}]}"#;
21329 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21330 let md = adf_to_markdown(&doc).unwrap();
21331 assert!(
21332 !md.contains('\\'),
21333 "single trailing space should not be escaped, got: {md:?}"
21334 );
21335 let rt = markdown_to_adf(&md).unwrap();
21336 let p = rt.content[0].content.as_ref().unwrap();
21337 assert_eq!(p[0].text.as_deref(), Some("word "));
21338 }
21339
21340 #[test]
21341 fn issue_510_trailing_spaces_in_heading_roundtrip() {
21342 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"heading "}]}]}"#;
21344 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21345 let md = adf_to_markdown(&doc).unwrap();
21346 let rt = markdown_to_adf(&md).unwrap();
21347 let h = rt.content[0].content.as_ref().unwrap();
21348 assert_eq!(
21349 h[0].text.as_deref(),
21350 Some("heading "),
21351 "trailing spaces in heading should be preserved"
21352 );
21353 }
21354
21355 #[test]
21356 fn issue_510_trailing_spaces_in_list_item_roundtrip() {
21357 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item "}]}]}]}]}"#;
21359 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21360 let md = adf_to_markdown(&doc).unwrap();
21361 let rt = markdown_to_adf(&md).unwrap();
21362 let list = &rt.content[0];
21363 let item = &list.content.as_ref().unwrap()[0];
21364 let para = &item.content.as_ref().unwrap()[0];
21365 let inlines = para.content.as_ref().unwrap();
21366 assert_eq!(
21367 inlines[0].text.as_deref(),
21368 Some("item "),
21369 "trailing spaces in list item should be preserved"
21370 );
21371 }
21372
21373 #[test]
21374 fn issue_510_trailing_spaces_with_bold_mark_roundtrip() {
21375 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold ","marks":[{"type":"strong"}]}]}]}"#;
21379 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21380 let md = adf_to_markdown(&doc).unwrap();
21381 let rt = markdown_to_adf(&md).unwrap();
21382 let p = rt.content[0].content.as_ref().unwrap();
21383 assert_eq!(
21384 p[0].text.as_deref(),
21385 Some("bold "),
21386 "trailing spaces in bold text should be preserved"
21387 );
21388 }
21389
21390 #[test]
21391 fn issue_510_hardbreak_between_paragraphs_still_works() {
21392 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"line one"},{"type":"hardBreak"},{"type":"text","text":"line two"}]}]}"#;
21394 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21395 let md = adf_to_markdown(&doc).unwrap();
21396 let rt = markdown_to_adf(&md).unwrap();
21397 let inlines = rt.content[0].content.as_ref().unwrap();
21398 let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21399 assert_eq!(
21400 types,
21401 vec!["text", "hardBreak", "text"],
21402 "explicit hardBreak should still round-trip"
21403 );
21404 }
21405
21406 #[test]
21407 fn issue_510_all_spaces_text_node_roundtrip() {
21408 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":" "}]}]}"#;
21410 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21411 let md = adf_to_markdown(&doc).unwrap();
21412 let rt = markdown_to_adf(&md).unwrap();
21413 let p = rt.content[0].content.as_ref().unwrap();
21414 assert_eq!(
21415 p[0].text.as_deref(),
21416 Some(" "),
21417 "space-only text node should survive round-trip"
21418 );
21419 }
21420
21421 #[test]
21424 fn issue_522_listitem_hardbreak_then_two_paragraphs_roundtrips() {
21425 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"}]}]}]}]}"#;
21428 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21429 let md = adf_to_markdown(&doc).unwrap();
21430 let rt = markdown_to_adf(&md).unwrap();
21431
21432 let items = rt.content[0].content.as_ref().unwrap();
21433 assert_eq!(items.len(), 1);
21434 let children = items[0].content.as_ref().unwrap();
21435 assert_eq!(
21436 children.len(),
21437 3,
21438 "Expected 3 paragraphs in listItem, got {}",
21439 children.len()
21440 );
21441 assert_eq!(children[0].node_type, "paragraph");
21442 assert_eq!(children[1].node_type, "paragraph");
21443 assert_eq!(children[2].node_type, "paragraph");
21444
21445 let text1 = children[1].content.as_ref().unwrap()[0]
21447 .text
21448 .as_deref()
21449 .unwrap();
21450 assert_eq!(text1, "second paragraph");
21451 let text2 = children[2].content.as_ref().unwrap()[0]
21452 .text
21453 .as_deref()
21454 .unwrap();
21455 assert_eq!(text2, "third paragraph");
21456 }
21457
21458 #[test]
21459 fn issue_522_ordered_list_hardbreak_then_paragraphs_roundtrips() {
21460 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"}]}]}]}]}"#;
21462 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21463 let md = adf_to_markdown(&doc).unwrap();
21464 let rt = markdown_to_adf(&md).unwrap();
21465
21466 let items = rt.content[0].content.as_ref().unwrap();
21467 let children = items[0].content.as_ref().unwrap();
21468 assert_eq!(
21469 children.len(),
21470 3,
21471 "Expected 3 paragraphs in ordered listItem, got {}",
21472 children.len()
21473 );
21474 assert_eq!(children[1].node_type, "paragraph");
21475 assert_eq!(children[2].node_type, "paragraph");
21476 assert_eq!(
21477 children[1].content.as_ref().unwrap()[0]
21478 .text
21479 .as_deref()
21480 .unwrap(),
21481 "second para"
21482 );
21483 assert_eq!(
21484 children[2].content.as_ref().unwrap()[0]
21485 .text
21486 .as_deref()
21487 .unwrap(),
21488 "third para"
21489 );
21490 }
21491
21492 #[test]
21493 fn issue_522_two_paragraphs_without_hardbreak_roundtrips() {
21494 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"}]}]}]}]}"#;
21496 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21497 let md = adf_to_markdown(&doc).unwrap();
21498 let rt = markdown_to_adf(&md).unwrap();
21499
21500 let items = rt.content[0].content.as_ref().unwrap();
21501 let children = items[0].content.as_ref().unwrap();
21502 assert_eq!(
21503 children.len(),
21504 2,
21505 "Expected 2 paragraphs in listItem, got {}",
21506 children.len()
21507 );
21508 assert_eq!(children[0].node_type, "paragraph");
21509 assert_eq!(children[1].node_type, "paragraph");
21510 }
21511
21512 #[test]
21513 fn issue_522_paragraph_then_nested_list_no_spurious_blank() {
21514 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"}]}]}]}]}]}]}"#;
21517 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21518 let md = adf_to_markdown(&doc).unwrap();
21519 assert!(
21521 !md.contains(" \n -"),
21522 "No blank separator between paragraph and nested list"
21523 );
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!(children.len(), 2);
21529 assert_eq!(children[0].node_type, "paragraph");
21530 assert_eq!(children[1].node_type, "bulletList");
21531 }
21532
21533 #[test]
21534 fn issue_522_three_paragraphs_no_hardbreak_roundtrips() {
21535 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"}]}]}]}]}"#;
21537 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21538 let md = adf_to_markdown(&doc).unwrap();
21539 let rt = markdown_to_adf(&md).unwrap();
21540
21541 let items = rt.content[0].content.as_ref().unwrap();
21542 let children = items[0].content.as_ref().unwrap();
21543 assert_eq!(
21544 children.len(),
21545 3,
21546 "Expected 3 paragraphs, got {}",
21547 children.len()
21548 );
21549 for (i, child) in children.iter().enumerate() {
21550 assert_eq!(
21551 child.node_type, "paragraph",
21552 "Child {i} should be a paragraph"
21553 );
21554 }
21555 }
21556
21557 #[test]
21558 fn issue_522_multiple_list_items_each_with_paragraphs() {
21559 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"}]}]}]}]}"#;
21561 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21562 let md = adf_to_markdown(&doc).unwrap();
21563 let rt = markdown_to_adf(&md).unwrap();
21564
21565 let items = rt.content[0].content.as_ref().unwrap();
21566 assert_eq!(items.len(), 2, "Expected 2 list items");
21567
21568 let item1 = items[0].content.as_ref().unwrap();
21569 assert_eq!(item1.len(), 2, "Item 1 should have 2 paragraphs");
21570
21571 let item2 = items[1].content.as_ref().unwrap();
21572 assert_eq!(item2.len(), 2, "Item 2 should have 2 paragraphs");
21573 let item2_p1_inlines = item2[0].content.as_ref().unwrap();
21575 let types: Vec<&str> = item2_p1_inlines
21576 .iter()
21577 .map(|n| n.node_type.as_str())
21578 .collect();
21579 assert_eq!(types, vec!["text", "hardBreak", "text"]);
21580 }
21581
21582 #[test]
21583 fn issue_531_blockquote_hardbreak_then_two_paragraphs_roundtrips() {
21584 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"}]}]}]}"#;
21588 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21589 let md = adf_to_markdown(&doc).unwrap();
21590 let rt = markdown_to_adf(&md).unwrap();
21591
21592 let children = rt.content[0].content.as_ref().unwrap();
21593 assert_eq!(
21594 children.len(),
21595 3,
21596 "Expected 3 paragraphs in blockquote, got {}",
21597 children.len()
21598 );
21599 assert_eq!(children[0].node_type, "paragraph");
21600 assert_eq!(children[1].node_type, "paragraph");
21601 assert_eq!(children[2].node_type, "paragraph");
21602
21603 let text1 = children[1].content.as_ref().unwrap()[0]
21604 .text
21605 .as_deref()
21606 .unwrap();
21607 assert_eq!(text1, "second paragraph");
21608 let text2 = children[2].content.as_ref().unwrap()[0]
21609 .text
21610 .as_deref()
21611 .unwrap();
21612 assert_eq!(text2, "third paragraph");
21613 }
21614
21615 #[test]
21616 fn issue_531_blockquote_two_paragraphs_without_hardbreak_roundtrips() {
21617 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"}]}]}]}"#;
21619 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21620 let md = adf_to_markdown(&doc).unwrap();
21621 let rt = markdown_to_adf(&md).unwrap();
21622
21623 let children = rt.content[0].content.as_ref().unwrap();
21624 assert_eq!(
21625 children.len(),
21626 2,
21627 "Expected 2 paragraphs in blockquote, got {}",
21628 children.len()
21629 );
21630 assert_eq!(children[0].node_type, "paragraph");
21631 assert_eq!(children[1].node_type, "paragraph");
21632 }
21633
21634 #[test]
21635 fn issue_531_blockquote_three_paragraphs_no_hardbreak_roundtrips() {
21636 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"}]}]}]}"#;
21638 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21639 let md = adf_to_markdown(&doc).unwrap();
21640 let rt = markdown_to_adf(&md).unwrap();
21641
21642 let children = rt.content[0].content.as_ref().unwrap();
21643 assert_eq!(
21644 children.len(),
21645 3,
21646 "Expected 3 paragraphs in blockquote, got {}",
21647 children.len()
21648 );
21649 for child in children {
21650 assert_eq!(child.node_type, "paragraph");
21651 }
21652 }
21653
21654 #[test]
21655 fn issue_531_blockquote_paragraph_then_list_no_spurious_blank() {
21656 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"}]}]}]}]}]}"#;
21659 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21660 let md = adf_to_markdown(&doc).unwrap();
21661 let rt = markdown_to_adf(&md).unwrap();
21662
21663 let children = rt.content[0].content.as_ref().unwrap();
21664 assert_eq!(children[0].node_type, "paragraph");
21665 assert_eq!(children[1].node_type, "bulletList");
21666 }
21667
21668 #[test]
21669 fn issue_531_blockquote_single_paragraph_unchanged() {
21670 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"solo"}]}]}]}"#;
21672 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21673 let md = adf_to_markdown(&doc).unwrap();
21674 let rt = markdown_to_adf(&md).unwrap();
21675
21676 let children = rt.content[0].content.as_ref().unwrap();
21677 assert_eq!(children.len(), 1);
21678 assert_eq!(children[0].node_type, "paragraph");
21679 let text = children[0].content.as_ref().unwrap()[0]
21680 .text
21681 .as_deref()
21682 .unwrap();
21683 assert_eq!(text, "solo");
21684 }
21685
21686 fn assert_roundtrip_marks(adf_json: &str, expected_marks: &[&str]) {
21691 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21692 let md = adf_to_markdown(&doc).unwrap();
21693 let rt = markdown_to_adf(&md).unwrap();
21694 let node = &rt.content[0].content.as_ref().unwrap()[0];
21695 let mark_types: Vec<&str> = node
21696 .marks
21697 .as_ref()
21698 .expect("should have marks")
21699 .iter()
21700 .map(|m| m.mark_type.as_str())
21701 .collect();
21702 assert_eq!(
21703 mark_types, expected_marks,
21704 "mark order mismatch for md={md}"
21705 );
21706 }
21707
21708 #[test]
21709 fn issue_554_code_and_text_color_preserved() {
21710 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21711 {"type":"text","text":"x","marks":[
21712 {"type":"textColor","attrs":{"color":"#008000"}},
21713 {"type":"code"}
21714 ]}
21715 ]}]}"##;
21716 assert_roundtrip_marks(adf_json, &["textColor", "code"]);
21717 }
21718
21719 #[test]
21720 fn issue_554_code_and_bg_color_preserved() {
21721 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21722 {"type":"text","text":"x","marks":[
21723 {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21724 {"type":"code"}
21725 ]}
21726 ]}]}"##;
21727 assert_roundtrip_marks(adf_json, &["backgroundColor", "code"]);
21728 }
21729
21730 #[test]
21731 fn issue_554_code_and_subsup_preserved() {
21732 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21733 {"type":"text","text":"x","marks":[
21734 {"type":"subsup","attrs":{"type":"sub"}},
21735 {"type":"code"}
21736 ]}
21737 ]}]}"#;
21738 assert_roundtrip_marks(adf_json, &["subsup", "code"]);
21739 }
21740
21741 #[test]
21742 fn issue_554_code_and_underline_preserved() {
21743 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21744 {"type":"text","text":"x","marks":[
21745 {"type":"underline"},
21746 {"type":"code"}
21747 ]}
21748 ]}]}"#;
21749 assert_roundtrip_marks(adf_json, &["underline", "code"]);
21750 }
21751
21752 #[test]
21753 fn issue_554_code_textcolor_and_underline_preserved() {
21754 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21755 {"type":"text","text":"x","marks":[
21756 {"type":"textColor","attrs":{"color":"#008000"}},
21757 {"type":"underline"},
21758 {"type":"code"}
21759 ]}
21760 ]}]}"##;
21761 assert_roundtrip_marks(adf_json, &["textColor", "underline", "code"]);
21762 }
21763
21764 #[test]
21765 fn issue_554_textcolor_and_underline_preserved() {
21766 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21767 {"type":"text","text":"x","marks":[
21768 {"type":"textColor","attrs":{"color":"#008000"}},
21769 {"type":"underline"}
21770 ]}
21771 ]}]}"##;
21772 assert_roundtrip_marks(adf_json, &["textColor", "underline"]);
21773 }
21774
21775 #[test]
21776 fn issue_554_underline_and_textcolor_preserved_order_swapped() {
21777 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21778 {"type":"text","text":"x","marks":[
21779 {"type":"underline"},
21780 {"type":"textColor","attrs":{"color":"#008000"}}
21781 ]}
21782 ]}]}"##;
21783 assert_roundtrip_marks(adf_json, &["underline", "textColor"]);
21785 }
21786
21787 #[test]
21788 fn issue_554_textcolor_and_annotation_preserved() {
21789 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21790 {"type":"text","text":"x","marks":[
21791 {"type":"textColor","attrs":{"color":"#008000"}},
21792 {"type":"annotation","attrs":{"id":"abc-123","annotationType":"inlineComment"}}
21793 ]}
21794 ]}]}"##;
21795 assert_roundtrip_marks(adf_json, &["textColor", "annotation"]);
21796 }
21797
21798 #[test]
21799 fn issue_554_bgcolor_and_underline_preserved() {
21800 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21801 {"type":"text","text":"x","marks":[
21802 {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21803 {"type":"underline"}
21804 ]}
21805 ]}]}"##;
21806 assert_roundtrip_marks(adf_json, &["backgroundColor", "underline"]);
21807 }
21808
21809 #[test]
21810 fn issue_554_subsup_and_underline_preserved() {
21811 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21812 {"type":"text","text":"x","marks":[
21813 {"type":"subsup","attrs":{"type":"sub"}},
21814 {"type":"underline"}
21815 ]}
21816 ]}]}"#;
21817 assert_roundtrip_marks(adf_json, &["subsup", "underline"]);
21818 }
21819
21820 #[test]
21821 fn issue_554_exact_reproducer_full_match() {
21822 let adf_json = r##"{
21825 "version": 1,
21826 "type": "doc",
21827 "content": [
21828 {
21829 "type": "paragraph",
21830 "content": [
21831 {"type":"text","text":"Status: ","marks":[{"type":"strong"}]},
21832 {"type":"text","text":"Approved","marks":[
21833 {"type":"textColor","attrs":{"color":"#008000"}}
21834 ]},
21835 {"type":"text","text":" — ready to proceed"}
21836 ]
21837 }
21838 ]
21839 }"##;
21840 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21841 let md = adf_to_markdown(&doc).unwrap();
21842 assert!(
21843 md.contains(":span[Approved]{color=#008000}"),
21844 "JFM should contain green span: {md}"
21845 );
21846 let rt = markdown_to_adf(&md).unwrap();
21847 let approved = rt.content[0]
21849 .content
21850 .as_ref()
21851 .unwrap()
21852 .iter()
21853 .find(|n| n.text.as_deref() == Some("Approved"))
21854 .expect("Approved text node");
21855 let marks = approved.marks.as_ref().expect("should have marks");
21856 let color_mark = marks
21857 .iter()
21858 .find(|m| m.mark_type == "textColor")
21859 .expect("textColor mark must be preserved");
21860 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
21861 }
21862
21863 #[test]
21864 fn issue_554_textcolor_with_code_renders_span_around_code() {
21865 let doc = AdfDocument {
21868 version: 1,
21869 doc_type: "doc".to_string(),
21870 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
21871 "fn main",
21872 vec![
21873 AdfMark::text_color("#008000"),
21874 AdfMark {
21875 mark_type: "code".to_string(),
21876 attrs: None,
21877 },
21878 ],
21879 )])],
21880 };
21881 let md = adf_to_markdown(&doc).unwrap();
21882 assert!(
21883 md.contains(":span[`fn main`]{color=#008000}"),
21884 "expected span-wrapped code, got: {md}"
21885 );
21886 }
21887
21888 #[test]
21889 fn issue_554_underline_with_code_renders_bracketed_around_code() {
21890 let doc = AdfDocument {
21891 version: 1,
21892 doc_type: "doc".to_string(),
21893 content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
21894 "fn main",
21895 vec![
21896 AdfMark::underline(),
21897 AdfMark {
21898 mark_type: "code".to_string(),
21899 attrs: None,
21900 },
21901 ],
21902 )])],
21903 };
21904 let md = adf_to_markdown(&doc).unwrap();
21905 assert!(
21906 md.contains("[`fn main`]{underline}"),
21907 "expected bracketed-span around code, got: {md}"
21908 );
21909 }
21910
21911 #[test]
21914 fn issue_554_underscore_adjacent_to_textcolor_span_roundtrip() {
21915 let adf_json = r##"{
21920 "version": 1,
21921 "type": "doc",
21922 "content": [
21923 {
21924 "type": "paragraph",
21925 "content": [
21926 {"type":"text","text":"_ "},
21927 {"type":"text","text":"_Action:*","marks":[
21928 {"type":"textColor","attrs":{"color":"#008000"}}
21929 ]},
21930 {"type":"text","text":" Complete the setup process."}
21931 ]
21932 }
21933 ]
21934 }"##;
21935 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21936 let md = adf_to_markdown(&doc).unwrap();
21937 assert!(
21940 md.contains(r"\_ ") && md.contains(r":span[\_Action"),
21941 "underscores at node boundaries should be escaped: {md}"
21942 );
21943 let rt = markdown_to_adf(&md).unwrap();
21944 let para_content = rt.content[0].content.as_ref().unwrap();
21945 let colored = para_content
21947 .iter()
21948 .find(|n| {
21949 n.marks
21950 .as_deref()
21951 .is_some_and(|ms| ms.iter().any(|m| m.mark_type == "textColor"))
21952 })
21953 .expect("textColor node must be preserved");
21954 assert_eq!(colored.text.as_deref(), Some("_Action:*"));
21955 let color_mark = colored
21956 .marks
21957 .as_ref()
21958 .unwrap()
21959 .iter()
21960 .find(|m| m.mark_type == "textColor")
21961 .unwrap();
21962 assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
21963 for n in para_content {
21965 if let Some(ms) = n.marks.as_deref() {
21966 assert!(
21967 !ms.iter().any(|m| m.mark_type == "em"),
21968 "no em mark should appear, got marks {:?}",
21969 ms.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
21970 );
21971 }
21972 }
21973 }
21974
21975 #[test]
21976 fn issue_554_underscore_intraword_left_unescaped() {
21977 let doc = AdfDocument {
21981 version: 1,
21982 doc_type: "doc".to_string(),
21983 content: vec![AdfNode::paragraph(vec![AdfNode::text(
21984 "call do_something_useful now",
21985 )])],
21986 };
21987 let md = adf_to_markdown(&doc).unwrap();
21988 assert!(
21989 md.contains("do_something_useful") && !md.contains(r"do\_something\_useful"),
21990 "intraword underscores should not be escaped: {md}"
21991 );
21992 }
21993
21994 #[test]
21995 fn issue_554_code_underline_then_textcolor_bracketed_outer() {
21996 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21999 {"type":"text","text":"x","marks":[
22000 {"type":"underline"},
22001 {"type":"textColor","attrs":{"color":"#008000"}},
22002 {"type":"code"}
22003 ]}
22004 ]}]}"##;
22005 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22006 let md = adf_to_markdown(&doc).unwrap();
22007 assert!(
22009 md.starts_with('[') && md.contains("underline}"),
22010 "bracketed-span should wrap the span, got: {md}"
22011 );
22012 let rt = markdown_to_adf(&md).unwrap();
22013 let node = &rt.content[0].content.as_ref().unwrap()[0];
22014 let mark_types: Vec<&str> = node
22015 .marks
22016 .as_ref()
22017 .unwrap()
22018 .iter()
22019 .map(|m| m.mark_type.as_str())
22020 .collect();
22021 assert_eq!(mark_types, vec!["underline", "textColor", "code"]);
22022 }
22023
22024 #[test]
22025 fn issue_554_textcolor_underline_link_all_preserved() {
22026 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22030 {"type":"text","text":"linked","marks":[
22031 {"type":"textColor","attrs":{"color":"#008000"}},
22032 {"type":"underline"},
22033 {"type":"link","attrs":{"href":"https://example.com"}}
22034 ]}
22035 ]}]}"##;
22036 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22037 let md = adf_to_markdown(&doc).unwrap();
22038 let rt = markdown_to_adf(&md).unwrap();
22039 let node = &rt.content[0].content.as_ref().unwrap()[0];
22040 let mark_types: Vec<&str> = node
22041 .marks
22042 .as_ref()
22043 .unwrap()
22044 .iter()
22045 .map(|m| m.mark_type.as_str())
22046 .collect();
22047 assert_eq!(mark_types, vec!["textColor", "underline", "link"]);
22048 }
22049
22050 #[test]
22051 fn issue_554_underline_textcolor_link_bracketed_outer_link_last() {
22052 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22055 {"type":"text","text":"linked","marks":[
22056 {"type":"underline"},
22057 {"type":"textColor","attrs":{"color":"#008000"}},
22058 {"type":"link","attrs":{"href":"https://example.com"}}
22059 ]}
22060 ]}]}"##;
22061 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22062 let md = adf_to_markdown(&doc).unwrap();
22063 let rt = markdown_to_adf(&md).unwrap();
22064 let node = &rt.content[0].content.as_ref().unwrap()[0];
22065 let mark_types: Vec<&str> = node
22066 .marks
22067 .as_ref()
22068 .unwrap()
22069 .iter()
22070 .map(|m| m.mark_type.as_str())
22071 .collect();
22072 assert_eq!(mark_types, vec!["underline", "textColor", "link"]);
22073 }
22074
22075 #[test]
22076 fn issue_554_link_underline_textcolor_link_outer() {
22077 let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22081 {"type":"text","text":"linked","marks":[
22082 {"type":"link","attrs":{"href":"https://example.com"}},
22083 {"type":"underline"},
22084 {"type":"textColor","attrs":{"color":"#008000"}}
22085 ]}
22086 ]}]}"##;
22087 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22088 let md = adf_to_markdown(&doc).unwrap();
22089 assert!(
22090 md.starts_with('[') && md.contains("](https://example.com)"),
22091 "link should be outermost, got: {md}"
22092 );
22093 let rt = markdown_to_adf(&md).unwrap();
22094 let node = &rt.content[0].content.as_ref().unwrap()[0];
22095 let mark_types: Vec<&str> = node
22096 .marks
22097 .as_ref()
22098 .unwrap()
22099 .iter()
22100 .map(|m| m.mark_type.as_str())
22101 .collect();
22102 assert_eq!(mark_types, vec!["link", "underline", "textColor"]);
22103 }
22104
22105 #[test]
22106 fn issue_554_trailing_underscore_then_leading_underscore_round_trip() {
22107 let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22111 {"type":"text","text":"end_"},
22112 {"type":"text","text":"_start"}
22113 ]}]}"#;
22114 let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22115 let md = adf_to_markdown(&doc).unwrap();
22116 let rt = markdown_to_adf(&md).unwrap();
22117 let combined: String = rt.content[0]
22119 .content
22120 .as_ref()
22121 .unwrap()
22122 .iter()
22123 .filter_map(|n| n.text.as_deref())
22124 .collect();
22125 assert_eq!(combined, "end__start");
22126 for n in rt.content[0].content.as_ref().unwrap() {
22128 if let Some(ms) = n.marks.as_deref() {
22129 assert!(!ms.iter().any(|m| m.mark_type == "em"));
22130 }
22131 }
22132 }
22133
22134 #[test]
22142 fn blockquote_nesting_beyond_cap_errors() {
22143 let md = format!("{}x", "> ".repeat(200));
22144 let err = markdown_to_adf(&md).unwrap_err();
22145 assert!(err.to_string().contains("maximum depth"));
22146 }
22147
22148 #[test]
22149 fn list_nesting_beyond_cap_errors() {
22150 let mut md = String::new();
22151 for i in 0..200 {
22152 md.push_str(&" ".repeat(i));
22153 md.push_str("- x\n");
22154 }
22155 let err = markdown_to_adf(&md).unwrap_err();
22156 assert!(err.to_string().contains("maximum depth"));
22157 }
22158
22159 #[test]
22160 fn container_directive_nesting_beyond_cap_errors() {
22161 let mut md = String::new();
22162 for _ in 0..200 {
22163 md.push_str(":::panel{type=info}\n");
22164 }
22165 md.push_str("body\n");
22166 for _ in 0..200 {
22167 md.push_str(":::\n");
22168 }
22169 let err = markdown_to_adf(&md).unwrap_err();
22170 assert!(err.to_string().contains("maximum depth"));
22171 }
22172
22173 #[test]
22174 fn directive_table_cell_nesting_beyond_cap_errors() {
22175 let mut md = String::new();
22176 for _ in 0..200 {
22177 md.push_str("::::table\n:::tr\n:::td\n");
22178 }
22179 md.push_str("cell\n");
22180 for _ in 0..200 {
22181 md.push_str(":::\n:::\n::::\n");
22182 }
22183 let err = markdown_to_adf(&md).unwrap_err();
22184 assert!(err.to_string().contains("maximum depth"));
22185 }
22186
22187 #[test]
22188 fn layout_column_nesting_beyond_cap_errors() {
22189 let mut md = String::new();
22190 for _ in 0..200 {
22191 md.push_str("::::layout\n:::column{width=100}\n");
22192 }
22193 md.push_str("x\n");
22194 for _ in 0..200 {
22195 md.push_str(":::\n::::\n");
22196 }
22197 let err = markdown_to_adf(&md).unwrap_err();
22198 assert!(err.to_string().contains("maximum depth"));
22199 }
22200
22201 #[test]
22202 fn blockquote_nesting_at_cap_boundary() {
22203 let at_cap = format!("{}x", "> ".repeat(MAX_NESTING_DEPTH));
22206 assert!(markdown_to_adf(&at_cap).is_ok());
22207 let past_cap = format!("{}x", "> ".repeat(MAX_NESTING_DEPTH + 1));
22208 let err = markdown_to_adf(&past_cap).unwrap_err();
22209 assert!(err.to_string().contains("maximum depth"));
22210 }
22211
22212 #[test]
22213 fn list_nesting_at_cap_succeeds() {
22214 let mut md = String::new();
22218 for i in 0..MAX_NESTING_DEPTH {
22219 md.push_str(&" ".repeat(i));
22220 md.push_str("- x\n");
22221 }
22222 assert!(markdown_to_adf(&md).is_ok());
22223 }
22224
22225 #[test]
22226 fn deep_but_reasonable_nesting_succeeds() {
22227 let md = format!("{}deep", "> ".repeat(20));
22228 let doc = markdown_to_adf(&md).unwrap();
22229 let mut node = &doc.content[0];
22231 for _ in 0..20 {
22232 assert_eq!(node.node_type, "blockquote");
22233 node = &node.content.as_ref().unwrap()[0];
22234 }
22235 assert_eq!(node.node_type, "paragraph");
22236 }
22237
22238 #[test]
22239 fn deeply_nested_emphasis_degrades_without_overflow() {
22240 let md = format!("{}x{}", "*_".repeat(400), "_*".repeat(400));
22241 let doc = markdown_to_adf(&md).unwrap();
22242 assert_eq!(doc.content[0].node_type, "paragraph");
22243 }
22244
22245 #[test]
22246 fn deeply_nested_links_degrade_without_overflow() {
22247 let mut md = String::from("x");
22248 for _ in 0..400 {
22249 md = format!("[{md}](https://example.com)");
22250 }
22251 let doc = markdown_to_adf(&md).unwrap();
22252 assert_eq!(doc.content[0].node_type, "paragraph");
22253 }
22254
22255 #[test]
22256 fn deeply_nested_bracketed_spans_degrade_without_overflow() {
22257 let mut md = String::from("x");
22258 for _ in 0..400 {
22259 md = format!("[{md}]{{underline}}");
22260 }
22261 let doc = markdown_to_adf(&md).unwrap();
22262 assert_eq!(doc.content[0].node_type, "paragraph");
22263 }
22264}