1use super::document::Document;
16use super::node::{Block, Inline};
17use super::shortcode::{Shortcode, ShortcodeKind};
18use super::url::Url;
19
20pub fn visit_urls_mut<F>(doc: &mut Document, mut callback: F)
28where
29 F: FnMut(&mut Url),
30{
31 for block in &mut doc.blocks {
32 visit_urls_in_block(block, &mut callback);
33 }
34}
35
36fn visit_urls_in_block<F>(block: &mut Block, callback: &mut F)
37where
38 F: FnMut(&mut Url),
39{
40 match block {
41 Block::Heading { children, .. } => {
42 for inline in children {
43 visit_urls_in_inline(inline, callback);
44 }
45 }
46 Block::Paragraph(children) => {
47 for inline in children {
48 visit_urls_in_inline(inline, callback);
49 }
50 }
51 Block::Callout { children, .. } | Block::FootnoteDefinition { children, .. } => {
52 for nested in children {
53 visit_urls_in_block(nested, callback);
54 }
55 }
56 Block::List { items, .. } => {
57 for item_blocks in items {
58 for nested in item_blocks {
59 visit_urls_in_block(nested, callback);
60 }
61 }
62 }
63 Block::Table { header, rows, .. } => {
64 for cell in header {
65 for inline in cell {
66 visit_urls_in_inline(inline, callback);
67 }
68 }
69 for row in rows {
70 for cell in row {
71 for inline in cell {
72 visit_urls_in_inline(inline, callback);
73 }
74 }
75 }
76 }
77 Block::BlockQuote(children) => {
78 for nested in children {
79 visit_urls_in_block(nested, callback);
80 }
81 }
82 Block::Shortcode(sc) => {
83 visit_urls_in_shortcode(sc, callback);
84 }
85 Block::Figure { image, caption, .. } => {
86 visit_urls_in_inline(image, callback);
91 if let Some(cap_inlines) = caption {
92 for inline in cap_inlines {
93 visit_urls_in_inline(inline, callback);
94 }
95 }
96 }
97 Block::LinkCard { url, children } => {
98 callback(url);
101 for nested in children {
102 visit_urls_in_block(nested, callback);
103 }
104 }
105 Block::CodeBlock { .. } | Block::ThematicBreak | Block::Other(_) => {
106 }
108 }
109}
110
111fn visit_urls_in_shortcode<F>(sc: &mut super::shortcode::Shortcode, callback: &mut F)
112where
113 F: FnMut(&mut Url),
114{
115 use super::shortcode::Shortcode;
116 match sc {
117 Shortcode::Subscribe(_) => {} Shortcode::Buttons(args) => {
119 for item in &mut args.items {
120 callback(&mut item.url);
121 }
122 }
123 Shortcode::Gallery(args) => {
124 for item in &mut args.items {
125 callback(&mut item.src);
126 }
127 }
128 Shortcode::Hero(args) => {
129 if let Some(image) = args.image.as_mut() {
130 callback(image);
131 }
132 for image in &mut args.extra_images {
133 callback(image);
134 }
135 for block in &mut args.overlay {
140 visit_urls_in_block(block, callback);
141 }
142 }
143 Shortcode::Grid(args) => {
144 for cell_blocks in &mut args.cells {
149 for block in cell_blocks {
150 visit_urls_in_block(block, callback);
151 }
152 }
153 }
154 Shortcode::Recent(_) => {} Shortcode::Apply(_) => {} }
157}
158
159fn visit_urls_in_inline<F>(inline: &mut Inline, callback: &mut F)
160where
161 F: FnMut(&mut Url),
162{
163 match inline {
164 Inline::Link { url, children, .. } => {
165 callback(url);
166 for nested in children {
167 visit_urls_in_inline(nested, callback);
168 }
169 }
170 Inline::Image { src, .. } => {
171 callback(src);
172 }
173 Inline::Emphasis(children) | Inline::Strong(children) | Inline::Strikethrough(children) => {
174 for nested in children {
175 visit_urls_in_inline(nested, callback);
176 }
177 }
178 Inline::Text(_)
179 | Inline::Code(_)
180 | Inline::LineBreak
181 | Inline::FootnoteRef(_)
182 | Inline::TaskMarker(_)
183 | Inline::Other(_) => {}
184 }
185}
186
187pub fn visit_blocks<F>(doc: &Document, mut callback: F) -> bool
194where
195 F: FnMut(&Block) -> bool,
196{
197 for block in &doc.blocks {
198 if !visit_block(block, &mut callback) {
199 return false;
200 }
201 }
202 true
203}
204
205fn visit_block<F>(block: &Block, callback: &mut F) -> bool
206where
207 F: FnMut(&Block) -> bool,
208{
209 if !callback(block) {
210 return false;
211 }
212 match block {
213 Block::Callout { children, .. }
214 | Block::BlockQuote(children)
215 | Block::FootnoteDefinition { children, .. } => {
221 for nested in children {
222 if !visit_block(nested, callback) {
223 return false;
224 }
225 }
226 }
227 Block::List { items, .. } => {
228 for item_blocks in items {
229 for nested in item_blocks {
230 if !visit_block(nested, callback) {
231 return false;
232 }
233 }
234 }
235 }
236 Block::LinkCard { children, .. } => {
237 for nested in children {
240 if !visit_block(nested, callback) {
241 return false;
242 }
243 }
244 }
245 Block::Shortcode(super::shortcode::Shortcode::Grid(args)) => {
246 for cell_blocks in &args.cells {
250 for nested in cell_blocks {
251 if !visit_block(nested, callback) {
252 return false;
253 }
254 }
255 }
256 }
257 Block::Shortcode(super::shortcode::Shortcode::Hero(args)) => {
258 for nested in &args.overlay {
262 if !visit_block(nested, callback) {
263 return false;
264 }
265 }
266 }
267 _ => {}
276 }
277 true
278}
279
280pub fn has_shortcode_recursive(doc: &Document, kind: ShortcodeKind) -> bool {
286 let mut found = false;
287 visit_blocks(doc, |block| {
288 if let Block::Shortcode(sc) = block {
289 if sc.kind() == kind {
290 found = true;
291 return false; }
293 }
294 true
295 });
296 found
297}
298
299pub fn has_callout_recursive(doc: &Document) -> bool {
334 let mut found = false;
335 visit_blocks(doc, |block| {
336 match block {
337 Block::Callout { .. } => {
338 found = true;
339 return false; }
341 Block::Shortcode(Shortcode::Recent(r)) if markdown_has_callout(&r.fallback_markdown) => {
342 found = true;
343 return false;
344 }
345 Block::Other(html) if html_opens_a_callout(html) => {
346 found = true;
347 return false;
348 }
349 Block::Shortcode(sc) if shortcode_classes(sc).is_some_and(has_callout_class) => {
350 found = true;
351 return false;
352 }
353 _ => {}
354 }
355 true
356 });
357 found
358}
359
360fn has_callout_class(class_list: &str) -> bool {
366 class_list.split_whitespace().any(|c| c == "callout")
367}
368
369fn shortcode_classes(sc: &Shortcode) -> Option<&str> {
372 match sc {
373 Shortcode::Buttons(b) => Some(b.classes.as_str()),
374 Shortcode::Gallery(g) => Some(g.classes.as_str()),
375 Shortcode::Grid(g) => Some(g.classes.as_str()),
376 Shortcode::Hero(h) => Some(h.classes.as_str()),
377 _ => None,
378 }
379}
380
381fn html_opens_a_callout(html: &str) -> bool {
399 let lowered = html.to_ascii_lowercase();
400 lowered.split("class").skip(1).any(|after| {
403 let Some(value) = after.trim_start().strip_prefix('=') else {
405 return false; };
407 let value = value.trim_start();
408 let mut chars = value.chars();
409 match chars.next() {
410 Some(q @ ('"' | '\'')) => chars
411 .as_str()
412 .split_once(q)
413 .is_some_and(|(list, _)| has_callout_class(list)),
414 _ => value.split([' ', '\t', '\n', '\r', '>', '/']).next() == Some("callout"),
417 }
418 })
419}
420
421fn markdown_has_callout(markdown: &str) -> bool {
426 markdown.lines().any(|line| {
427 let line = line.trim_start();
428 line.starts_with('>') && line.trim_start_matches(['>', ' ']).starts_with("[!")
429 })
430}
431
432#[cfg(test)]
433mod tests {
434 use super::super::node::Inline;
435 use super::super::url::{Url, UrlKind};
436 use super::*;
437
438 fn paragraph_with_link(url: &str) -> Block {
439 Block::Paragraph(vec![Inline::Link {
440 url: Url::unresolved(url),
441 title: None,
442 children: vec![Inline::Text("t".into())],
443 is_wikilink: false,
444 }])
445 }
446
447 #[test]
454 fn visit_blocks_descends_into_a_footnote_definition_body() {
455 let inner = Block::Paragraph(vec![Inline::Text("inside the note".into())]);
456 let doc = Document::from_blocks(vec![Block::FootnoteDefinition {
457 label: "a".into(),
458 children: vec![inner.clone()],
459 }]);
460
461 let mut seen = 0usize;
462 visit_blocks(&doc, |b| {
463 if matches!(b, Block::Paragraph(_)) {
464 seen += 1;
465 }
466 true
467 });
468 assert_eq!(
469 seen, 1,
470 "the note's body block was never visited — the catch-all swallowed it"
471 );
472
473 let control = Document::from_blocks(vec![Block::BlockQuote(vec![inner])]);
474 let mut seen_control = 0usize;
475 visit_blocks(&control, |b| {
476 if matches!(b, Block::Paragraph(_)) {
477 seen_control += 1;
478 }
479 true
480 });
481 assert_eq!(seen, seen_control, "identical content, different container");
482 }
483
484 #[test]
485 fn visits_url_in_paragraph_link() {
486 let mut doc = Document::from_blocks(vec![paragraph_with_link("docs/")]);
487 let mut seen: Vec<String> = Vec::new();
488 visit_urls_mut(&mut doc, |u| match u {
489 Url::Unresolved(s) => seen.push(s.clone()),
490 _ => {}
491 });
492 assert_eq!(seen, vec!["docs/".to_string()]);
493 }
494
495 #[test]
496 fn visits_url_in_image_src() {
497 let mut doc = Document::from_blocks(vec![Block::Paragraph(vec![Inline::Image {
498 src: Url::unresolved("img.png"),
499 alt: "x".into(),
500 title: None,
501 is_wikilink: false,
502 wikilink_pothole: None,
503 }])]);
504 let mut seen: Vec<String> = Vec::new();
505 visit_urls_mut(&mut doc, |u| match u {
506 Url::Unresolved(s) => seen.push(s.clone()),
507 _ => {}
508 });
509 assert_eq!(seen, vec!["img.png".to_string()]);
510 }
511
512 #[test]
513 fn callback_can_mutate_url_to_resolved() {
514 let mut doc = Document::from_blocks(vec![paragraph_with_link("docs/")]);
516 visit_urls_mut(&mut doc, |u| {
517 *u = Url::resolved("../docs/", UrlKind::Wikilink);
518 });
519 match &doc.blocks[0] {
520 Block::Paragraph(children) => match &children[0] {
521 Inline::Link { url, .. } => {
522 assert!(url.is_resolved());
523 let Url::Resolved(r) = url else {
524 panic!("expected Resolved, got {url:?}")
525 };
526 assert_eq!(r.href, "../docs/");
527 }
528 _ => panic!("expected Link"),
529 },
530 _ => panic!("expected Paragraph"),
531 }
532 }
533
534 #[test]
535 fn visits_url_inside_heading() {
536 let mut doc = Document::from_blocks(vec![Block::Heading {
537 level: 2,
538 children: vec![Inline::Link {
539 url: Url::unresolved("x"),
540 title: None,
541 children: vec![Inline::Text("t".into())],
542 is_wikilink: false,
543 }],
544 id: None,
545 }]);
546 let mut count = 0;
547 visit_urls_mut(&mut doc, |_| count += 1);
548 assert_eq!(count, 1);
549 }
550
551 #[test]
552 fn visits_url_inside_emphasis_and_strong() {
553 let mut doc = Document::from_blocks(vec![Block::Paragraph(vec![Inline::Strong(vec![
554 Inline::Emphasis(vec![Inline::Link {
555 url: Url::unresolved("nested"),
556 title: None,
557 children: vec![],
558 is_wikilink: false,
559 }]),
560 ])])]);
561 let mut count = 0;
562 visit_urls_mut(&mut doc, |_| count += 1);
563 assert_eq!(count, 1);
564 }
565
566 #[test]
567 fn visits_url_inside_link_children() {
568 let mut doc = Document::from_blocks(vec![Block::Paragraph(vec![Inline::Link {
571 url: Url::unresolved("outer"),
572 title: None,
573 children: vec![Inline::Image {
574 src: Url::unresolved("inner.png"),
575 alt: "".into(),
576 title: None,
577 is_wikilink: false,
578 wikilink_pothole: None,
579 }],
580 is_wikilink: false,
581 }])]);
582 let mut seen: Vec<String> = Vec::new();
583 visit_urls_mut(&mut doc, |u| match u {
584 Url::Unresolved(s) => seen.push(s.clone()),
585 _ => {}
586 });
587 assert_eq!(seen, vec!["outer".to_string(), "inner.png".to_string()]);
588 }
589
590 #[test]
591 fn visits_urls_inside_list_items() {
592 let mut doc = Document::from_blocks(vec![Block::List {
593 ordered: false,
594 start: None,
595 items: vec![
596 vec![paragraph_with_link("a")],
597 vec![paragraph_with_link("b")],
598 ],
599 item_source_lines: vec![],
600 }]);
601 let mut seen: Vec<String> = Vec::new();
602 visit_urls_mut(&mut doc, |u| match u {
603 Url::Unresolved(s) => seen.push(s.clone()),
604 _ => {}
605 });
606 assert_eq!(seen, vec!["a".to_string(), "b".to_string()]);
607 }
608
609 #[test]
610 fn visits_urls_inside_blockquote() {
611 let mut doc =
612 Document::from_blocks(vec![Block::BlockQuote(vec![paragraph_with_link("q")])]);
613 let mut count = 0;
614 visit_urls_mut(&mut doc, |_| count += 1);
615 assert_eq!(count, 1);
616 }
617
618 #[test]
619 fn visits_urls_inside_table_header_and_rows() {
620 let mut doc = Document::from_blocks(vec![Block::Table {
621 header: vec![vec![Inline::Link {
622 url: Url::unresolved("h"),
623 title: None,
624 children: vec![],
625 is_wikilink: false,
626 }]],
627 rows: vec![vec![vec![Inline::Link {
628 url: Url::unresolved("r"),
629 title: None,
630 children: vec![],
631 is_wikilink: false,
632 }]]],
633 alignments: Vec::new(),
634 header_source_line: None,
635 row_source_lines: vec![],
636 }]);
637 let mut seen: Vec<String> = Vec::new();
638 visit_urls_mut(&mut doc, |u| match u {
639 Url::Unresolved(s) => seen.push(s.clone()),
640 _ => {}
641 });
642 assert_eq!(seen, vec!["h".to_string(), "r".to_string()]);
643 }
644
645 #[test]
648 fn detects_a_callout_anywhere_it_can_appear() {
649 let callout = || Block::Callout {
650 kind: super::super::node::CalloutKind::Note,
651 fold: None,
652 title: None,
653 children: vec![Block::Paragraph(vec![Inline::Text("x".into())])],
654 };
655 assert!(has_callout_recursive(&Document::from_blocks(vec![callout()])));
657 assert!(has_callout_recursive(&Document::from_blocks(vec![Block::List {
659 ordered: false,
660 start: None,
661 items: vec![vec![callout()]],
662 item_source_lines: Vec::new(),
663 }])));
664 assert!(!has_callout_recursive(&Document::from_blocks(vec![Block::Paragraph(vec![
667 Inline::Text("no callout here".into())
668 ])])));
669 }
670
671 #[test]
676 fn detects_a_callout_in_a_recent_shortcode_fallback() {
677 use super::super::shortcode::RecentShortcode;
678 let with = Document::from_blocks(vec![Block::Shortcode(Shortcode::Recent(
679 RecentShortcode {
680 fallback_markdown: "> [!warning] Heads up\n> Nothing published yet.".into(),
681 ..Default::default()
682 },
683 ))]);
684 assert!(has_callout_recursive(&with), "a fallback callout must gate the partial on");
685
686 let without = Document::from_blocks(vec![Block::Shortcode(Shortcode::Recent(
687 RecentShortcode {
688 fallback_markdown: "> Just a quote, no callout.".into(),
689 ..Default::default()
690 },
691 ))]);
692 assert!(!has_callout_recursive(&without), "a plain blockquote is not a callout");
693 }
694
695 #[test]
703 fn detects_a_callout_written_as_a_css_region() {
704 let other = |html: &str| Document::from_blocks(vec![Block::Other(html.into())]);
705 assert!(has_callout_recursive(&other("<div class=\"callout\">\n")));
706 assert!(has_callout_recursive(&other("<div class=\"lead callout wide\">\n")));
707 assert!(has_callout_recursive(&other("<div id=\"x\" class='callout'>\n")));
708 assert!(has_callout_recursive(&other("<div class=callout>\n")));
709
710 assert!(has_callout_recursive(&other("<div CLASS=\"callout\">\n")));
713 assert!(has_callout_recursive(&other("<div class = \"callout\">\n")));
714 assert!(has_callout_recursive(&other("<span class=callout/>")));
715 assert!(has_callout_recursive(&other("<p class=\"lead\">hi</p><div class=\"callout\">")));
717
718 assert!(!has_callout_recursive(&other("<div class=\"callout-ish\">\n")));
721 assert!(!has_callout_recursive(&other("<p>I love a good callout.</p>")));
722 assert!(!has_callout_recursive(&other("<div class=\"grid\">\n")));
723 assert!(!has_callout_recursive(&other("<div classname=\"callout\">\n")));
724 }
725
726 #[test]
729 fn detects_a_callout_class_on_a_typed_shortcode() {
730 use super::super::shortcode::GridShortcode;
731 let grid = |classes: &str| {
732 Document::from_blocks(vec![Block::Shortcode(Shortcode::Grid(GridShortcode {
733 classes: classes.into(),
734 ..Default::default()
735 }))])
736 };
737 assert!(has_callout_recursive(&grid("callout")));
738 assert!(has_callout_recursive(&grid("wide callout")));
739 assert!(!has_callout_recursive(&grid("wide")));
740 assert!(!has_callout_recursive(&grid("")));
741 }
742
743 #[test]
744 fn visits_urls_inside_callout() {
745 let mut doc = Document::from_blocks(vec![Block::Callout {
746 kind: super::super::node::CalloutKind::Note,
747 fold: None,
748 title: None,
749 children: vec![paragraph_with_link("inside")],
750 }]);
751 let mut count = 0;
752 visit_urls_mut(&mut doc, |_| count += 1);
753 assert_eq!(count, 1);
754 }
755
756 #[test]
757 fn does_not_visit_text_or_code() {
758 let mut doc = Document::from_blocks(vec![
761 Block::Paragraph(vec![Inline::Text("plain".into()), Inline::Code("c".into())]),
762 Block::CodeBlock {
763 lang: None,
764 value: "x".into(),
765 },
766 Block::ThematicBreak,
767 Block::Other("<raw>".into()),
768 ]);
769 let mut count = 0;
770 visit_urls_mut(&mut doc, |_| count += 1);
771 assert_eq!(count, 0);
772 }
773
774 #[test]
775 fn empty_document_visits_nothing() {
776 let mut doc = Document::new();
777 let mut count = 0;
778 visit_urls_mut(&mut doc, |_| count += 1);
779 assert_eq!(count, 0);
780 }
781
782 #[test]
783 fn visit_blocks_walks_top_level() {
784 let doc = Document::from_blocks(vec![Block::ThematicBreak, Block::Paragraph(vec![])]);
785 let mut count = 0;
786 visit_blocks(&doc, |_| {
787 count += 1;
788 true
789 });
790 assert_eq!(count, 2);
791 }
792
793 #[test]
794 fn visit_blocks_descends_into_blockquote() {
795 let doc = Document::from_blocks(vec![Block::BlockQuote(vec![Block::ThematicBreak])]);
796 let mut count = 0;
797 visit_blocks(&doc, |_| {
798 count += 1;
799 true
800 });
801 assert_eq!(count, 2); }
803
804 #[test]
805 fn visit_blocks_descends_into_list_items() {
806 let doc = Document::from_blocks(vec![Block::List {
807 ordered: false,
808 start: None,
809 items: vec![vec![Block::ThematicBreak], vec![Block::ThematicBreak]],
810 item_source_lines: vec![],
811 }]);
812 let mut count = 0;
813 visit_blocks(&doc, |_| {
814 count += 1;
815 true
816 });
817 assert_eq!(count, 3); }
819
820 #[test]
821 fn visit_blocks_short_circuits_when_callback_returns_false() {
822 let doc = Document::from_blocks(vec![
823 Block::ThematicBreak,
824 Block::ThematicBreak,
825 Block::ThematicBreak,
826 ]);
827 let mut count = 0;
828 let result = visit_blocks(&doc, |_| {
829 count += 1;
830 count < 2 });
832 assert!(!result);
833 assert_eq!(count, 2);
834 }
835
836 #[test]
841 fn visits_url_inside_figure_image() {
842 let mut doc = Document::from_blocks(vec![Block::Figure {
843 image: Inline::Image {
844 src: Url::unresolved("fig.png"),
845 alt: "f".into(),
846 title: None,
847 is_wikilink: false,
848 wikilink_pothole: None,
849 },
850 caption: Some(vec![Inline::Text("f".into())]),
851 width: None,
852 align: None,
853 class_names: Vec::new(),
854 img_style: None,
855 }]);
856 let mut seen: Vec<String> = Vec::new();
857 visit_urls_mut(&mut doc, |u| match u {
858 Url::Unresolved(s) => seen.push(s.clone()),
859 _ => {}
860 });
861 assert_eq!(seen, vec!["fig.png".to_string()]);
862 }
863
864 #[test]
865 fn figure_url_becomes_resolved_after_visit() {
866 let mut doc = Document::from_blocks(vec![Block::Figure {
870 image: Inline::Image {
871 src: Url::unresolved("p.jpg"),
872 alt: "".into(),
873 title: None,
874 is_wikilink: false,
875 wikilink_pothole: None,
876 },
877 caption: None,
878 width: None,
879 align: None,
880 class_names: Vec::new(),
881 img_style: None,
882 }]);
883 visit_urls_mut(&mut doc, |u| {
884 *u = Url::resolved("p.jpg", UrlKind::Asset);
885 });
886 match &doc.blocks[0] {
887 Block::Figure { image, .. } => match image {
888 Inline::Image { src, .. } => assert!(src.is_resolved()),
889 _ => panic!("expected Image inside Figure"),
890 },
891 _ => panic!("expected Figure"),
892 }
893 }
894
895 #[test]
896 fn visits_url_inside_figure_caption_inlines() {
897 let mut doc = Document::from_blocks(vec![Block::Figure {
901 image: Inline::Image {
902 src: Url::unresolved("fig.png"),
903 alt: "".into(),
904 title: None,
905 is_wikilink: false,
906 wikilink_pothole: None,
907 },
908 caption: Some(vec![Inline::Link {
909 url: Url::unresolved("credit"),
910 title: None,
911 children: vec![Inline::Text("credit".into())],
912 is_wikilink: false,
913 }]),
914 width: None,
915 align: None,
916 class_names: Vec::new(),
917 img_style: None,
918 }]);
919 let mut seen: Vec<String> = Vec::new();
920 visit_urls_mut(&mut doc, |u| match u {
921 Url::Unresolved(s) => seen.push(s.clone()),
922 _ => {}
923 });
924 assert_eq!(seen, vec!["fig.png".to_string(), "credit".to_string()]);
925 }
926
927 #[test]
928 fn has_shortcode_recursive_returns_false_on_empty_doc() {
929 let doc = Document::new();
933 assert!(!has_shortcode_recursive(&doc, ShortcodeKind::Subscribe));
934 assert!(!has_shortcode_recursive(&doc, ShortcodeKind::Buttons));
935 }
936}