1use crate::config::MarkdownFlavor;
17use crate::lint_context::LineInfo;
18use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
19use crate::utils::range_utils::calculate_line_range;
20
21const GFM_ALERT_TYPES: &[&str] = &["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"];
24
25#[derive(Clone)]
26pub struct MD028NoBlanksBlockquote;
27
28impl MD028NoBlanksBlockquote {
29 #[inline]
31 fn is_blockquote_line(line: &str) -> bool {
32 if !line.as_bytes().contains(&b'>') {
34 return false;
35 }
36 line.trim_start().starts_with('>')
37 }
38
39 fn get_blockquote_info(line: &str) -> (usize, usize) {
42 let bytes = line.as_bytes();
43 let mut i = 0;
44
45 while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
47 i += 1;
48 }
49
50 let whitespace_end = i;
51 let mut level = 0;
52
53 while i < bytes.len() {
55 if bytes[i] == b'>' {
56 level += 1;
57 i += 1;
58 } else if bytes[i] == b' ' || bytes[i] == b'\t' {
59 i += 1;
60 } else {
61 break;
62 }
63 }
64
65 (level, whitespace_end)
66 }
67
68 #[inline]
70 fn is_in_skip_context(line_infos: &[LineInfo], idx: usize) -> bool {
71 if let Some(li) = line_infos.get(idx) {
72 li.in_html_comment || li.in_code_block || li.in_html_block || li.in_front_matter
73 } else {
74 false
75 }
76 }
77
78 fn has_content_between(lines: &[&str], line_infos: &[LineInfo], start: usize, end: usize) -> bool {
83 for (offset, line) in lines[start..end].iter().enumerate() {
84 let idx = start + offset;
85 if Self::is_in_skip_context(line_infos, idx) {
88 if !line.trim().is_empty() {
89 return true;
90 }
91 continue;
92 }
93 let trimmed = line.trim();
94 if !trimmed.is_empty() && !trimmed.starts_with('>') {
96 return true;
97 }
98 }
99 false
100 }
101
102 #[inline]
106 fn is_gfm_alert_line(line: &str) -> bool {
107 if !line.contains("[!") {
109 return false;
110 }
111
112 let trimmed = line.trim_start();
114 if !trimmed.starts_with('>') {
115 return false;
116 }
117
118 let content = trimmed
120 .trim_start_matches('>')
121 .trim_start_matches([' ', '\t'])
122 .trim_start_matches('>')
123 .trim_start();
124
125 if !content.starts_with("[!") {
127 return false;
128 }
129
130 if let Some(end_bracket) = content.find(']') {
132 let alert_type = &content[2..end_bracket];
133 return GFM_ALERT_TYPES.iter().any(|&t| t.eq_ignore_ascii_case(alert_type));
134 }
135
136 false
137 }
138
139 #[inline]
144 fn is_obsidian_callout_line(line: &str) -> bool {
145 if !line.contains("[!") {
147 return false;
148 }
149
150 let trimmed = line.trim_start();
152 if !trimmed.starts_with('>') {
153 return false;
154 }
155
156 let content = trimmed
158 .trim_start_matches('>')
159 .trim_start_matches([' ', '\t'])
160 .trim_start_matches('>')
161 .trim_start();
162
163 if !content.starts_with("[!") {
165 return false;
166 }
167
168 if let Some(end_bracket) = content.find(']') {
170 if end_bracket > 2 {
172 let alert_type = &content[2..end_bracket];
174 return !alert_type.is_empty()
175 && alert_type.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_');
176 }
177 }
178
179 false
180 }
181
182 #[inline]
186 fn is_callout_line(line: &str, flavor: MarkdownFlavor) -> bool {
187 match flavor {
188 MarkdownFlavor::Obsidian => Self::is_obsidian_callout_line(line),
189 _ => Self::is_gfm_alert_line(line),
190 }
191 }
192
193 fn find_blockquote_start(lines: &[&str], line_infos: &[LineInfo], from_idx: usize) -> Option<usize> {
196 if from_idx >= lines.len() {
197 return None;
198 }
199
200 let mut start_idx = from_idx;
202
203 for i in (0..=from_idx).rev() {
204 if Self::is_in_skip_context(line_infos, i) {
206 continue;
207 }
208
209 let line = lines[i];
210
211 if Self::is_blockquote_line(line) {
213 start_idx = i;
214 } else if line.trim().is_empty() {
215 if start_idx == from_idx && !Self::is_blockquote_line(lines[from_idx]) {
218 continue;
219 }
220 break;
222 } else {
223 break;
225 }
226 }
227
228 if Self::is_blockquote_line(lines[start_idx]) && !Self::is_in_skip_context(line_infos, start_idx) {
230 Some(start_idx)
231 } else {
232 None
233 }
234 }
235
236 fn is_callout_block(
240 lines: &[&str],
241 line_infos: &[LineInfo],
242 blockquote_line_idx: usize,
243 flavor: MarkdownFlavor,
244 ) -> bool {
245 if let Some(start_idx) = Self::find_blockquote_start(lines, line_infos, blockquote_line_idx) {
247 return Self::is_callout_line(lines[start_idx], flavor);
249 }
250 false
251 }
252
253 fn are_likely_same_blockquote(
255 lines: &[&str],
256 line_infos: &[LineInfo],
257 blank_idx: usize,
258 flavor: MarkdownFlavor,
259 ) -> bool {
260 let mut prev_quote_idx = None;
272 let mut next_quote_idx = None;
273
274 for i in (0..blank_idx).rev() {
276 if Self::is_in_skip_context(line_infos, i) {
277 continue;
278 }
279 let line = lines[i];
280 if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
282 prev_quote_idx = Some(i);
283 break;
284 }
285 }
286
287 for (i, line) in lines.iter().enumerate().skip(blank_idx + 1) {
289 if Self::is_in_skip_context(line_infos, i) {
290 continue;
291 }
292 if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
294 next_quote_idx = Some(i);
295 break;
296 }
297 }
298
299 let (prev_idx, next_idx) = match (prev_quote_idx, next_quote_idx) {
300 (Some(p), Some(n)) => (p, n),
301 _ => return false,
302 };
303
304 let prev_is_callout = Self::is_callout_block(lines, line_infos, prev_idx, flavor);
310 let next_is_callout = Self::is_callout_block(lines, line_infos, next_idx, flavor);
311 if prev_is_callout || next_is_callout {
312 return false;
313 }
314
315 if Self::has_content_between(lines, line_infos, prev_idx + 1, next_idx) {
317 return false;
318 }
319
320 let (prev_level, prev_whitespace_end) = Self::get_blockquote_info(lines[prev_idx]);
322 let (next_level, next_whitespace_end) = Self::get_blockquote_info(lines[next_idx]);
323
324 if next_level < prev_level {
327 return false;
328 }
329
330 let prev_line = lines[prev_idx];
332 let next_line = lines[next_idx];
333 let prev_indent = &prev_line[..prev_whitespace_end];
334 let next_indent = &next_line[..next_whitespace_end];
335
336 prev_indent == next_indent
339 }
340
341 fn is_problematic_blank_line(
343 lines: &[&str],
344 line_infos: &[LineInfo],
345 index: usize,
346 flavor: MarkdownFlavor,
347 ) -> Option<(usize, String)> {
348 let current_line = lines[index];
349
350 if !current_line.trim().is_empty() || Self::is_blockquote_line(current_line) {
352 return None;
353 }
354
355 if !Self::are_likely_same_blockquote(lines, line_infos, index, flavor) {
358 return None;
359 }
360
361 for i in (0..index).rev() {
364 if Self::is_in_skip_context(line_infos, i) {
365 continue;
366 }
367 let line = lines[i];
368 if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
370 let (level, whitespace_end) = Self::get_blockquote_info(line);
371 let indent = &line[..whitespace_end];
372 let mut fix = String::with_capacity(indent.len() + level);
373 fix.push_str(indent);
374 for _ in 0..level {
375 fix.push('>');
376 }
377 return Some((level, fix));
378 }
379 }
380
381 None
382 }
383}
384
385impl Default for MD028NoBlanksBlockquote {
386 fn default() -> Self {
387 Self
388 }
389}
390
391impl Rule for MD028NoBlanksBlockquote {
392 fn name(&self) -> &'static str {
393 "MD028"
394 }
395
396 fn description(&self) -> &'static str {
397 "Blank line inside blockquote"
398 }
399
400 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
401 if !ctx.content.contains('>') {
403 return Ok(Vec::new());
404 }
405
406 let mut warnings = Vec::new();
407
408 let lines = ctx.raw_lines();
410
411 let mut blank_line_indices = Vec::new();
413 let mut has_blockquotes = false;
414
415 for (line_idx, line) in lines.iter().enumerate() {
416 if line_idx < ctx.lines.len() {
418 let li = &ctx.lines[line_idx];
419 if li.in_code_block || li.in_html_comment || li.in_html_block || li.in_front_matter {
420 continue;
421 }
422 }
423
424 if line.trim().is_empty() {
425 blank_line_indices.push(line_idx);
426 } else if Self::is_blockquote_line(line) {
427 has_blockquotes = true;
428 }
429 }
430
431 if !has_blockquotes {
433 return Ok(Vec::new());
434 }
435
436 for &line_idx in &blank_line_indices {
438 let line_num = line_idx + 1;
439
440 if let Some((level, fix_content)) = Self::is_problematic_blank_line(lines, &ctx.lines, line_idx, ctx.flavor)
442 {
443 let line = lines[line_idx];
444 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
445
446 warnings.push(LintWarning {
447 rule_name: Some(self.name().to_string()),
448 message: format!("Blank line inside blockquote (level {level})"),
449 line: start_line,
450 column: start_col,
451 end_line,
452 end_column: end_col,
453 severity: Severity::Warning,
454 fix: Some(Fix {
455 range: ctx
456 .line_index
457 .line_col_to_byte_range_with_length(line_num, 1, line.len()),
458 replacement: fix_content,
459 }),
460 });
461 }
462 }
463
464 Ok(warnings)
465 }
466
467 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
468 let mut result = Vec::with_capacity(ctx.lines.len());
469 let lines = ctx.raw_lines();
470
471 for (line_idx, line) in lines.iter().enumerate() {
472 if line_idx < ctx.lines.len() {
474 let li = &ctx.lines[line_idx];
475 if li.in_code_block || li.in_html_comment || li.in_html_block || li.in_front_matter {
476 result.push(line.to_string());
477 continue;
478 }
479 }
480 if let Some((_, fix_content)) = Self::is_problematic_blank_line(lines, &ctx.lines, line_idx, ctx.flavor) {
482 result.push(fix_content);
483 } else {
484 result.push(line.to_string());
485 }
486 }
487
488 Ok(result.join("\n") + if ctx.content.ends_with('\n') { "\n" } else { "" })
489 }
490
491 fn category(&self) -> RuleCategory {
493 RuleCategory::Blockquote
494 }
495
496 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
498 !ctx.likely_has_blockquotes()
499 }
500
501 fn as_any(&self) -> &dyn std::any::Any {
502 self
503 }
504
505 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
506 where
507 Self: Sized,
508 {
509 Box::new(MD028NoBlanksBlockquote)
510 }
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use crate::lint_context::LintContext;
517
518 #[test]
519 fn test_no_blockquotes() {
520 let rule = MD028NoBlanksBlockquote;
521 let content = "This is regular text\n\nWith blank lines\n\nBut no blockquotes";
522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
523 let result = rule.check(&ctx).unwrap();
524 assert!(result.is_empty(), "Should not flag content without blockquotes");
525 }
526
527 #[test]
528 fn test_valid_blockquote_no_blanks() {
529 let rule = MD028NoBlanksBlockquote;
530 let content = "> This is a blockquote\n> With multiple lines\n> But no blank lines";
531 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532 let result = rule.check(&ctx).unwrap();
533 assert!(result.is_empty(), "Should not flag blockquotes without blank lines");
534 }
535
536 #[test]
537 fn test_blockquote_with_empty_line_marker() {
538 let rule = MD028NoBlanksBlockquote;
539 let content = "> First line\n>\n> Third line";
541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
542 let result = rule.check(&ctx).unwrap();
543 assert!(result.is_empty(), "Should not flag lines with just > marker");
544 }
545
546 #[test]
547 fn test_blockquote_with_empty_line_marker_and_space() {
548 let rule = MD028NoBlanksBlockquote;
549 let content = "> First line\n> \n> Third line";
551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
552 let result = rule.check(&ctx).unwrap();
553 assert!(result.is_empty(), "Should not flag lines with > and space");
554 }
555
556 #[test]
557 fn test_blank_line_in_blockquote() {
558 let rule = MD028NoBlanksBlockquote;
559 let content = "> First line\n\n> Third line";
561 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
562 let result = rule.check(&ctx).unwrap();
563 assert_eq!(result.len(), 1, "Should flag truly blank line inside blockquote");
564 assert_eq!(result[0].line, 2);
565 assert!(result[0].message.contains("Blank line inside blockquote"));
566 }
567
568 #[test]
569 fn test_multiple_blank_lines() {
570 let rule = MD028NoBlanksBlockquote;
571 let content = "> First\n\n\n> Fourth";
572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
573 let result = rule.check(&ctx).unwrap();
574 assert_eq!(result.len(), 2, "Should flag each blank line within the blockquote");
576 assert_eq!(result[0].line, 2);
577 assert_eq!(result[1].line, 3);
578 }
579
580 #[test]
581 fn test_nested_blockquote_blank() {
582 let rule = MD028NoBlanksBlockquote;
583 let content = ">> Nested quote\n\n>> More nested";
584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
585 let result = rule.check(&ctx).unwrap();
586 assert_eq!(result.len(), 1);
587 assert_eq!(result[0].line, 2);
588 }
589
590 #[test]
591 fn test_nested_blockquote_with_marker() {
592 let rule = MD028NoBlanksBlockquote;
593 let content = ">> Nested quote\n>>\n>> More nested";
595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
596 let result = rule.check(&ctx).unwrap();
597 assert!(result.is_empty(), "Should not flag lines with >> marker");
598 }
599
600 #[test]
601 fn test_fix_single_blank() {
602 let rule = MD028NoBlanksBlockquote;
603 let content = "> First\n\n> Third";
604 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
605 let fixed = rule.fix(&ctx).unwrap();
606 assert_eq!(fixed, "> First\n>\n> Third");
607 }
608
609 #[test]
610 fn test_fix_nested_blank() {
611 let rule = MD028NoBlanksBlockquote;
612 let content = ">> Nested\n\n>> More";
613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
614 let fixed = rule.fix(&ctx).unwrap();
615 assert_eq!(fixed, ">> Nested\n>>\n>> More");
616 }
617
618 #[test]
619 fn test_fix_with_indentation() {
620 let rule = MD028NoBlanksBlockquote;
621 let content = " > Indented quote\n\n > More";
622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
623 let fixed = rule.fix(&ctx).unwrap();
624 assert_eq!(fixed, " > Indented quote\n >\n > More");
625 }
626
627 #[test]
628 fn test_mixed_levels() {
629 let rule = MD028NoBlanksBlockquote;
630 let content = "> Level 1\n\n>> Level 2\n\n> Level 1 again";
632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
633 let result = rule.check(&ctx).unwrap();
634 assert_eq!(result.len(), 1);
637 assert_eq!(result[0].line, 2);
638 }
639
640 #[test]
641 fn test_blockquote_with_code_block() {
642 let rule = MD028NoBlanksBlockquote;
643 let content = "> Quote with code:\n> ```\n> code\n> ```\n>\n> More quote";
644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
645 let result = rule.check(&ctx).unwrap();
646 assert!(result.is_empty(), "Should not flag line with > marker");
648 }
649
650 #[test]
651 fn test_category() {
652 let rule = MD028NoBlanksBlockquote;
653 assert_eq!(rule.category(), RuleCategory::Blockquote);
654 }
655
656 #[test]
657 fn test_should_skip() {
658 let rule = MD028NoBlanksBlockquote;
659 let ctx1 = LintContext::new("No blockquotes here", crate::config::MarkdownFlavor::Standard, None);
660 assert!(rule.should_skip(&ctx1));
661
662 let ctx2 = LintContext::new("> Has blockquote", crate::config::MarkdownFlavor::Standard, None);
663 assert!(!rule.should_skip(&ctx2));
664 }
665
666 #[test]
667 fn test_empty_content() {
668 let rule = MD028NoBlanksBlockquote;
669 let content = "";
670 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
671 let result = rule.check(&ctx).unwrap();
672 assert!(result.is_empty());
673 }
674
675 #[test]
676 fn test_blank_after_blockquote() {
677 let rule = MD028NoBlanksBlockquote;
678 let content = "> Quote\n\nNot a quote";
679 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
680 let result = rule.check(&ctx).unwrap();
681 assert!(result.is_empty(), "Blank line after blockquote ends is valid");
682 }
683
684 #[test]
685 fn test_blank_before_blockquote() {
686 let rule = MD028NoBlanksBlockquote;
687 let content = "Not a quote\n\n> Quote";
688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
689 let result = rule.check(&ctx).unwrap();
690 assert!(result.is_empty(), "Blank line before blockquote starts is valid");
691 }
692
693 #[test]
694 fn test_preserve_trailing_newline() {
695 let rule = MD028NoBlanksBlockquote;
696 let content = "> Quote\n\n> More\n";
697 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
698 let fixed = rule.fix(&ctx).unwrap();
699 assert!(fixed.ends_with('\n'));
700
701 let content_no_newline = "> Quote\n\n> More";
702 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
703 let fixed2 = rule.fix(&ctx2).unwrap();
704 assert!(!fixed2.ends_with('\n'));
705 }
706
707 #[test]
708 fn test_document_structure_extension() {
709 let rule = MD028NoBlanksBlockquote;
710 let ctx = LintContext::new("> test", crate::config::MarkdownFlavor::Standard, None);
711 let result = rule.check(&ctx).unwrap();
713 assert!(result.is_empty(), "Should not flag valid blockquote");
714
715 let ctx2 = LintContext::new("no blockquote", crate::config::MarkdownFlavor::Standard, None);
717 assert!(rule.should_skip(&ctx2), "Should skip content without blockquotes");
718 }
719
720 #[test]
721 fn test_deeply_nested_blank() {
722 let rule = MD028NoBlanksBlockquote;
723 let content = ">>> Deep nest\n\n>>> More deep";
724 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
725 let result = rule.check(&ctx).unwrap();
726 assert_eq!(result.len(), 1);
727
728 let fixed = rule.fix(&ctx).unwrap();
729 assert_eq!(fixed, ">>> Deep nest\n>>>\n>>> More deep");
730 }
731
732 #[test]
733 fn test_deeply_nested_with_marker() {
734 let rule = MD028NoBlanksBlockquote;
735 let content = ">>> Deep nest\n>>>\n>>> More deep";
737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
738 let result = rule.check(&ctx).unwrap();
739 assert!(result.is_empty(), "Should not flag lines with >>> marker");
740 }
741
742 #[test]
743 fn test_complex_blockquote_structure() {
744 let rule = MD028NoBlanksBlockquote;
745 let content = "> Level 1\n> > Nested properly\n>\n> Back to level 1";
747 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
748 let result = rule.check(&ctx).unwrap();
749 assert!(result.is_empty(), "Should not flag line with > marker");
750 }
751
752 #[test]
753 fn test_complex_with_blank() {
754 let rule = MD028NoBlanksBlockquote;
755 let content = "> Level 1\n> > Nested\n\n> Back to level 1";
758 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
759 let result = rule.check(&ctx).unwrap();
760 assert_eq!(
761 result.len(),
762 0,
763 "Blank between different nesting levels is not inside blockquote"
764 );
765 }
766
767 #[test]
774 fn test_gfm_alert_detection_note() {
775 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
776 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE] Additional text"));
777 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
778 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!note]")); assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!Note]")); }
781
782 #[test]
783 fn test_gfm_alert_detection_all_types() {
784 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
786 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!TIP]"));
787 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!IMPORTANT]"));
788 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!WARNING]"));
789 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!CAUTION]"));
790 }
791
792 #[test]
793 fn test_gfm_alert_detection_not_alert() {
794 assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> Regular blockquote"));
796 assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [!INVALID]"));
797 assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [NOTE]")); assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [!]")); assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("Regular text [!NOTE]")); assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("")); assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> ")); }
803
804 #[test]
805 fn test_gfm_alerts_separated_by_blank_line() {
806 let rule = MD028NoBlanksBlockquote;
808 let content = "> [!TIP]\n> Here's a github tip\n\n> [!NOTE]\n> Here's a github note";
809 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
810 let result = rule.check(&ctx).unwrap();
811 assert!(result.is_empty(), "Should not flag blank line between GFM alerts");
812 }
813
814 #[test]
815 fn test_gfm_alerts_all_five_types_separated() {
816 let rule = MD028NoBlanksBlockquote;
818 let content = r#"> [!NOTE]
819> Note content
820
821> [!TIP]
822> Tip content
823
824> [!IMPORTANT]
825> Important content
826
827> [!WARNING]
828> Warning content
829
830> [!CAUTION]
831> Caution content"#;
832 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
833 let result = rule.check(&ctx).unwrap();
834 assert!(
835 result.is_empty(),
836 "Should not flag blank lines between any GFM alert types"
837 );
838 }
839
840 #[test]
841 fn test_gfm_alert_with_multiple_lines() {
842 let rule = MD028NoBlanksBlockquote;
844 let content = r#"> [!WARNING]
845> This is a warning
846> with multiple lines
847> of content
848
849> [!NOTE]
850> This is a note"#;
851 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
852 let result = rule.check(&ctx).unwrap();
853 assert!(
854 result.is_empty(),
855 "Should not flag blank line between multi-line GFM alerts"
856 );
857 }
858
859 #[test]
860 fn test_gfm_alert_followed_by_regular_blockquote() {
861 let rule = MD028NoBlanksBlockquote;
863 let content = "> [!TIP]\n> A helpful tip\n\n> Regular blockquote";
864 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
865 let result = rule.check(&ctx).unwrap();
866 assert!(result.is_empty(), "Should not flag blank line after GFM alert");
867 }
868
869 #[test]
870 fn test_regular_blockquote_followed_by_gfm_alert() {
871 let rule = MD028NoBlanksBlockquote;
873 let content = "> Regular blockquote\n\n> [!NOTE]\n> Important note";
874 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
875 let result = rule.check(&ctx).unwrap();
876 assert!(result.is_empty(), "Should not flag blank line before GFM alert");
877 }
878
879 #[test]
880 fn test_regular_blockquotes_still_flagged() {
881 let rule = MD028NoBlanksBlockquote;
883 let content = "> First blockquote\n\n> Second blockquote";
884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
885 let result = rule.check(&ctx).unwrap();
886 assert_eq!(
887 result.len(),
888 1,
889 "Should still flag blank line between regular blockquotes"
890 );
891 }
892
893 #[test]
894 fn test_gfm_alert_blank_line_within_same_alert() {
895 let rule = MD028NoBlanksBlockquote;
898 let content = "> [!NOTE]\n> First paragraph\n\n> Second paragraph of same note";
899 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
900 let result = rule.check(&ctx).unwrap();
901 assert!(
906 result.is_empty(),
907 "GFM alert status propagates to subsequent blockquote lines"
908 );
909 }
910
911 #[test]
912 fn test_gfm_alert_case_insensitive() {
913 let rule = MD028NoBlanksBlockquote;
914 let content = "> [!note]\n> lowercase\n\n> [!TIP]\n> uppercase\n\n> [!Warning]\n> mixed";
915 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
916 let result = rule.check(&ctx).unwrap();
917 assert!(result.is_empty(), "GFM alert detection should be case insensitive");
918 }
919
920 #[test]
921 fn test_gfm_alert_with_nested_blockquote() {
922 let rule = MD028NoBlanksBlockquote;
924 let content = "> [!NOTE]\n> > Nested quote inside alert\n\n> [!TIP]\n> Tip";
925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
926 let result = rule.check(&ctx).unwrap();
927 assert!(
928 result.is_empty(),
929 "Should not flag blank between alerts even with nested content"
930 );
931 }
932
933 #[test]
934 fn test_gfm_alert_indented() {
935 let rule = MD028NoBlanksBlockquote;
936 let content = " > [!NOTE]\n > Indented note\n\n > [!TIP]\n > Indented tip";
938 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
939 let result = rule.check(&ctx).unwrap();
940 assert!(result.is_empty(), "Should not flag blank between indented GFM alerts");
941 }
942
943 #[test]
944 fn test_gfm_alert_mixed_with_regular_content() {
945 let rule = MD028NoBlanksBlockquote;
947 let content = r#"# Heading
948
949Some paragraph.
950
951> [!NOTE]
952> Important note
953
954More paragraph text.
955
956> [!WARNING]
957> Be careful!
958
959Final text."#;
960 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
961 let result = rule.check(&ctx).unwrap();
962 assert!(
963 result.is_empty(),
964 "GFM alerts in mixed document should not trigger warnings"
965 );
966 }
967
968 #[test]
969 fn test_gfm_alert_fix_not_applied() {
970 let rule = MD028NoBlanksBlockquote;
972 let content = "> [!TIP]\n> Tip\n\n> [!NOTE]\n> Note";
973 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
974 let fixed = rule.fix(&ctx).unwrap();
975 assert_eq!(fixed, content, "Fix should not modify blank lines between GFM alerts");
976 }
977
978 #[test]
979 fn test_gfm_alert_multiple_blank_lines_between() {
980 let rule = MD028NoBlanksBlockquote;
982 let content = "> [!NOTE]\n> Note\n\n\n> [!TIP]\n> Tip";
983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
984 let result = rule.check(&ctx).unwrap();
985 assert!(
986 result.is_empty(),
987 "Should not flag multiple blank lines between GFM alerts"
988 );
989 }
990
991 #[test]
998 fn test_obsidian_callout_detection() {
999 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]"));
1001 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!info]"));
1002 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!todo]"));
1003 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!success]"));
1004 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!question]"));
1005 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!failure]"));
1006 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!danger]"));
1007 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!bug]"));
1008 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!example]"));
1009 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!quote]"));
1010 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!cite]"));
1011 }
1012
1013 #[test]
1014 fn test_obsidian_callout_custom_types() {
1015 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!custom]"));
1017 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my-callout]"));
1018 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my_callout]"));
1019 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!MyCallout]"));
1020 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!callout123]"));
1021 }
1022
1023 #[test]
1024 fn test_obsidian_callout_foldable() {
1025 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]+ Expanded"));
1027 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1028 "> [!NOTE]- Collapsed"
1029 ));
1030 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!WARNING]+"));
1031 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!TIP]-"));
1032 }
1033
1034 #[test]
1035 fn test_obsidian_callout_with_title() {
1036 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1038 "> [!NOTE] Custom Title"
1039 ));
1040 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1041 "> [!WARNING]+ Be Careful!"
1042 ));
1043 }
1044
1045 #[test]
1046 fn test_obsidian_callout_invalid() {
1047 assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
1049 "> Regular blockquote"
1050 ));
1051 assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [NOTE]")); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!]")); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
1054 "Regular text [!NOTE]"
1055 )); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("")); }
1058
1059 #[test]
1060 fn test_obsidian_callouts_separated_by_blank_line() {
1061 let rule = MD028NoBlanksBlockquote;
1063 let content = "> [!info]\n> Some info\n\n> [!todo]\n> A todo item";
1064 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1065 let result = rule.check(&ctx).unwrap();
1066 assert!(
1067 result.is_empty(),
1068 "Should not flag blank line between Obsidian callouts"
1069 );
1070 }
1071
1072 #[test]
1073 fn test_obsidian_custom_callouts_separated() {
1074 let rule = MD028NoBlanksBlockquote;
1076 let content = "> [!my-custom]\n> Custom content\n\n> [!another_custom]\n> More content";
1077 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1078 let result = rule.check(&ctx).unwrap();
1079 assert!(
1080 result.is_empty(),
1081 "Should not flag blank line between custom Obsidian callouts"
1082 );
1083 }
1084
1085 #[test]
1086 fn test_obsidian_foldable_callouts_separated() {
1087 let rule = MD028NoBlanksBlockquote;
1089 let content = "> [!NOTE]+ Expanded\n> Content\n\n> [!WARNING]- Collapsed\n> Warning content";
1090 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1091 let result = rule.check(&ctx).unwrap();
1092 assert!(
1093 result.is_empty(),
1094 "Should not flag blank line between foldable Obsidian callouts"
1095 );
1096 }
1097
1098 #[test]
1099 fn test_obsidian_custom_not_recognized_in_standard_flavor() {
1100 let rule = MD028NoBlanksBlockquote;
1103 let content = "> [!info]\n> Info content\n\n> [!todo]\n> Todo content";
1104 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1105 let result = rule.check(&ctx).unwrap();
1106 assert_eq!(
1108 result.len(),
1109 1,
1110 "Custom callout types should be flagged in Standard flavor"
1111 );
1112 }
1113
1114 #[test]
1115 fn test_obsidian_gfm_alerts_work_in_both_flavors() {
1116 let rule = MD028NoBlanksBlockquote;
1118 let content = "> [!NOTE]\n> Note\n\n> [!WARNING]\n> Warning";
1119
1120 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1122 let result_standard = rule.check(&ctx_standard).unwrap();
1123 assert!(result_standard.is_empty(), "GFM alerts should work in Standard flavor");
1124
1125 let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1127 let result_obsidian = rule.check(&ctx_obsidian).unwrap();
1128 assert!(
1129 result_obsidian.is_empty(),
1130 "GFM alerts should also work in Obsidian flavor"
1131 );
1132 }
1133
1134 #[test]
1135 fn test_obsidian_callout_all_builtin_types() {
1136 let rule = MD028NoBlanksBlockquote;
1138 let content = r#"> [!note]
1139> Note
1140
1141> [!abstract]
1142> Abstract
1143
1144> [!summary]
1145> Summary
1146
1147> [!info]
1148> Info
1149
1150> [!todo]
1151> Todo
1152
1153> [!tip]
1154> Tip
1155
1156> [!success]
1157> Success
1158
1159> [!question]
1160> Question
1161
1162> [!warning]
1163> Warning
1164
1165> [!failure]
1166> Failure
1167
1168> [!danger]
1169> Danger
1170
1171> [!bug]
1172> Bug
1173
1174> [!example]
1175> Example
1176
1177> [!quote]
1178> Quote"#;
1179 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1180 let result = rule.check(&ctx).unwrap();
1181 assert!(result.is_empty(), "All Obsidian callout types should be recognized");
1182 }
1183
1184 #[test]
1185 fn test_obsidian_fix_not_applied_to_callouts() {
1186 let rule = MD028NoBlanksBlockquote;
1188 let content = "> [!info]\n> Info\n\n> [!todo]\n> Todo";
1189 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1190 let fixed = rule.fix(&ctx).unwrap();
1191 assert_eq!(
1192 fixed, content,
1193 "Fix should not modify blank lines between Obsidian callouts"
1194 );
1195 }
1196
1197 #[test]
1198 fn test_obsidian_regular_blockquotes_still_flagged() {
1199 let rule = MD028NoBlanksBlockquote;
1201 let content = "> First blockquote\n\n> Second blockquote";
1202 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1203 let result = rule.check(&ctx).unwrap();
1204 assert_eq!(
1205 result.len(),
1206 1,
1207 "Regular blockquotes should still be flagged in Obsidian flavor"
1208 );
1209 }
1210
1211 #[test]
1212 fn test_obsidian_callout_mixed_with_regular_blockquote() {
1213 let rule = MD028NoBlanksBlockquote;
1215 let content = "> [!note]\n> Note content\n\n> Regular blockquote";
1216 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1217 let result = rule.check(&ctx).unwrap();
1218 assert!(
1219 result.is_empty(),
1220 "Should not flag blank after callout even if followed by regular blockquote"
1221 );
1222 }
1223
1224 #[test]
1228 fn test_html_comment_blockquotes_not_flagged() {
1229 let rule = MD028NoBlanksBlockquote;
1230 let content = "## Responses\n\n<!--\n> First response text here.\n> <br>— Person One\n\n> Second response text here.\n> <br>— Person Two\n-->\n\nThe above responses are currently disabled.\n";
1231 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1232 let result = rule.check(&ctx).unwrap();
1233 assert!(
1234 result.is_empty(),
1235 "Should not flag blank lines inside HTML comments, got: {result:?}"
1236 );
1237 }
1238
1239 #[test]
1240 fn test_fix_preserves_html_comment_content() {
1241 let rule = MD028NoBlanksBlockquote;
1242 let content = "<!--\n> First quote\n\n> Second quote\n-->\n";
1243 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1244 let fixed = rule.fix(&ctx).unwrap();
1245 assert_eq!(fixed, content, "Fix should not modify content inside HTML comments");
1246 }
1247
1248 #[test]
1249 fn test_multiline_html_comment_with_blockquotes() {
1250 let rule = MD028NoBlanksBlockquote;
1251 let content = "# Title\n\n<!--\n> Quote A\n> Line 2\n\n> Quote B\n> Line 2\n\n> Quote C\n-->\n\nSome text\n";
1252 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1253 let result = rule.check(&ctx).unwrap();
1254 assert!(
1255 result.is_empty(),
1256 "Should not flag any blank lines inside HTML comments, got: {result:?}"
1257 );
1258 }
1259
1260 #[test]
1261 fn test_blockquotes_outside_html_comment_still_flagged() {
1262 let rule = MD028NoBlanksBlockquote;
1263 let content = "> First quote\n\n> Second quote\n\n<!--\n> Commented quote A\n\n> Commented quote B\n-->\n";
1264 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1265 let result = rule.check(&ctx).unwrap();
1266 for w in &result {
1269 assert!(
1270 w.line < 5,
1271 "Warning at line {} should not be inside HTML comment",
1272 w.line
1273 );
1274 }
1275 assert!(
1276 !result.is_empty(),
1277 "Should still flag blank line between blockquotes outside HTML comment"
1278 );
1279 }
1280
1281 #[test]
1282 fn test_frontmatter_blockquote_like_content_not_flagged() {
1283 let rule = MD028NoBlanksBlockquote;
1284 let content = "---\n> not a real blockquote\n\n> also not real\n---\n\n# Title\n";
1285 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1286 let result = rule.check(&ctx).unwrap();
1287 assert!(
1288 result.is_empty(),
1289 "Should not flag content inside frontmatter, got: {result:?}"
1290 );
1291 }
1292
1293 #[test]
1294 fn test_comment_boundary_does_not_leak_into_adjacent_blockquotes() {
1295 let rule = MD028NoBlanksBlockquote;
1298 let content = "> real quote\n\n<!--\n> commented quote\n-->\n";
1299 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1300 let result = rule.check(&ctx).unwrap();
1301 assert!(
1302 result.is_empty(),
1303 "Should not match blockquotes across HTML comment boundaries, got: {result:?}"
1304 );
1305 }
1306
1307 #[test]
1308 fn test_blockquote_after_comment_boundary_not_matched() {
1309 let rule = MD028NoBlanksBlockquote;
1312 let content = "<!--\n> commented quote\n-->\n\n> real quote\n";
1313 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1314 let result = rule.check(&ctx).unwrap();
1315 assert!(
1316 result.is_empty(),
1317 "Should not match blockquotes across HTML comment boundaries, got: {result:?}"
1318 );
1319 }
1320
1321 #[test]
1322 fn test_fix_preserves_comment_boundary_content() {
1323 let rule = MD028NoBlanksBlockquote;
1325 let content = "> real quote\n\n<!--\n> commented quote A\n\n> commented quote B\n-->\n\n> another real quote\n";
1326 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1327 let fixed = rule.fix(&ctx).unwrap();
1328 assert_eq!(
1329 fixed, content,
1330 "Fix should not modify content when blockquotes are separated by comment boundaries"
1331 );
1332 }
1333
1334 #[test]
1335 fn test_inline_html_comment_does_not_suppress_warning() {
1336 let rule = MD028NoBlanksBlockquote;
1339 let content = "> quote with <!-- inline comment -->\n\n> continuation\n";
1340 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1341 let result = rule.check(&ctx).unwrap();
1342 assert!(
1344 !result.is_empty(),
1345 "Should still flag blank lines between blockquotes with inline HTML comments"
1346 );
1347 }
1348
1349 #[test]
1355 fn test_comment_with_blockquote_markers_on_delimiters() {
1356 let rule = MD028NoBlanksBlockquote;
1359 let content = "<!-- > not a real blockquote\n\n> also not real -->\n\n> real quote A\n\n> real quote B";
1360 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1361 let result = rule.check(&ctx).unwrap();
1362 assert_eq!(
1364 result.len(),
1365 1,
1366 "Should only warn about blank between real quotes, got: {result:?}"
1367 );
1368 assert_eq!(result[0].line, 6, "Warning should be on line 6 (between real quotes)");
1369 }
1370
1371 #[test]
1372 fn test_commented_blockquote_between_real_blockquotes() {
1373 let rule = MD028NoBlanksBlockquote;
1377 let content = "> real A\n\n<!-- > commented -->\n\n> real B";
1378 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1379 let result = rule.check(&ctx).unwrap();
1380 assert!(
1381 result.is_empty(),
1382 "Should NOT warn when non-blockquote content (HTML comment) separates blockquotes, got: {result:?}"
1383 );
1384 }
1385
1386 #[test]
1387 fn test_code_block_with_blockquote_markers_between_real_blockquotes() {
1388 let rule = MD028NoBlanksBlockquote;
1390 let content = "> real A\n\n```\n> not a blockquote\n```\n\n> real B";
1391 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1392 let result = rule.check(&ctx).unwrap();
1393 assert!(
1394 result.is_empty(),
1395 "Should NOT warn when code block with > markers separates blockquotes, got: {result:?}"
1396 );
1397 }
1398
1399 #[test]
1400 fn test_frontmatter_with_blockquote_markers_does_not_cause_false_positive() {
1401 let rule = MD028NoBlanksBlockquote;
1403 let content = "---\n> frontmatter value\n---\n\n> real quote A\n\n> real quote B";
1404 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1405 let result = rule.check(&ctx).unwrap();
1406 assert_eq!(
1408 result.len(),
1409 1,
1410 "Should only flag the blank between real quotes, got: {result:?}"
1411 );
1412 assert_eq!(result[0].line, 6, "Warning should be on line 6 (between real quotes)");
1413 }
1414
1415 #[test]
1416 fn test_fix_does_not_modify_comment_separated_blockquotes() {
1417 let rule = MD028NoBlanksBlockquote;
1419 let content = "> real A\n\n<!-- > commented -->\n\n> real B";
1420 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1421 let fixed = rule.fix(&ctx).unwrap();
1422 assert_eq!(
1423 fixed, content,
1424 "Fix should not modify content when blockquotes are separated by HTML comment"
1425 );
1426 }
1427
1428 #[test]
1429 fn test_fix_works_correctly_with_comment_before_real_blockquotes() {
1430 let rule = MD028NoBlanksBlockquote;
1433 let content = "<!-- > not a real blockquote\n\n> also not real -->\n\n> real quote A\n\n> real quote B";
1434 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1435 let fixed = rule.fix(&ctx).unwrap();
1436 assert!(
1438 fixed.contains("> real quote A\n>\n> real quote B"),
1439 "Fix should add > marker between real quotes, got: {fixed}"
1440 );
1441 assert!(
1443 fixed.contains("<!-- > not a real blockquote"),
1444 "Fix should not modify comment content"
1445 );
1446 }
1447
1448 #[test]
1449 fn test_html_block_with_angle_brackets_not_flagged() {
1450 let rule = MD028NoBlanksBlockquote;
1453 let content = "<div>\n> not a real blockquote\n\n> also not real\n</div>";
1454 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1455 let result = rule.check(&ctx).unwrap();
1456
1457 assert!(
1458 result.is_empty(),
1459 "Lines inside HTML blocks should not trigger MD028. Got: {result:?}"
1460 );
1461 }
1462
1463 #[test]
1464 fn test_html_block_does_not_leak_into_adjacent_blockquotes() {
1465 let rule = MD028NoBlanksBlockquote;
1467 let content =
1468 "<details>\n<summary>Click</summary>\n> inside html block\n</details>\n\n> real quote A\n\n> real quote B";
1469 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1470 let result = rule.check(&ctx).unwrap();
1471
1472 assert_eq!(
1474 result.len(),
1475 1,
1476 "Expected 1 warning for blank between real blockquotes after HTML block. Got: {result:?}"
1477 );
1478 }
1479}