use super::document::Document;
use super::node::{Block, Inline};
use super::shortcode::{Shortcode, ShortcodeKind};
use super::url::Url;
pub fn visit_urls_mut<F>(doc: &mut Document, mut callback: F)
where
F: FnMut(&mut Url),
{
for block in &mut doc.blocks {
visit_urls_in_block(block, &mut callback);
}
}
fn visit_urls_in_block<F>(block: &mut Block, callback: &mut F)
where
F: FnMut(&mut Url),
{
match block {
Block::Heading { children, .. } => {
for inline in children {
visit_urls_in_inline(inline, callback);
}
}
Block::Paragraph(children) => {
for inline in children {
visit_urls_in_inline(inline, callback);
}
}
Block::Callout { children, .. } | Block::FootnoteDefinition { children, .. } => {
for nested in children {
visit_urls_in_block(nested, callback);
}
}
Block::List { items, .. } => {
for item_blocks in items {
for nested in item_blocks {
visit_urls_in_block(nested, callback);
}
}
}
Block::Table { header, rows, .. } => {
for cell in header {
for inline in cell {
visit_urls_in_inline(inline, callback);
}
}
for row in rows {
for cell in row {
for inline in cell {
visit_urls_in_inline(inline, callback);
}
}
}
}
Block::BlockQuote(children) => {
for nested in children {
visit_urls_in_block(nested, callback);
}
}
Block::Shortcode(sc) => {
visit_urls_in_shortcode(sc, callback);
}
Block::Figure { image, caption, .. } => {
visit_urls_in_inline(image, callback);
if let Some(cap_inlines) = caption {
for inline in cap_inlines {
visit_urls_in_inline(inline, callback);
}
}
}
Block::LinkCard { url, children } => {
callback(url);
for nested in children {
visit_urls_in_block(nested, callback);
}
}
Block::CodeBlock { .. } | Block::ThematicBreak | Block::Other(_) => {
}
}
}
fn visit_urls_in_shortcode<F>(sc: &mut super::shortcode::Shortcode, callback: &mut F)
where
F: FnMut(&mut Url),
{
use super::shortcode::Shortcode;
match sc {
Shortcode::Subscribe(_) => {} Shortcode::Buttons(args) => {
for item in &mut args.items {
callback(&mut item.url);
}
}
Shortcode::Gallery(args) => {
for item in &mut args.items {
callback(&mut item.src);
}
}
Shortcode::Hero(args) => {
if let Some(image) = args.image.as_mut() {
callback(image);
}
for image in &mut args.extra_images {
callback(image);
}
for block in &mut args.overlay {
visit_urls_in_block(block, callback);
}
}
Shortcode::Grid(args) => {
for cell_blocks in &mut args.cells {
for block in cell_blocks {
visit_urls_in_block(block, callback);
}
}
}
Shortcode::Recent(_) => {} Shortcode::Apply(_) => {} }
}
fn visit_urls_in_inline<F>(inline: &mut Inline, callback: &mut F)
where
F: FnMut(&mut Url),
{
match inline {
Inline::Link { url, children, .. } => {
callback(url);
for nested in children {
visit_urls_in_inline(nested, callback);
}
}
Inline::Image { src, .. } => {
callback(src);
}
Inline::Emphasis(children) | Inline::Strong(children) | Inline::Strikethrough(children) => {
for nested in children {
visit_urls_in_inline(nested, callback);
}
}
Inline::Text(_)
| Inline::Code(_)
| Inline::LineBreak
| Inline::FootnoteRef(_)
| Inline::TaskMarker(_)
| Inline::Other(_) => {}
}
}
pub fn visit_blocks<F>(doc: &Document, mut callback: F) -> bool
where
F: FnMut(&Block) -> bool,
{
for block in &doc.blocks {
if !visit_block(block, &mut callback) {
return false;
}
}
true
}
fn visit_block<F>(block: &Block, callback: &mut F) -> bool
where
F: FnMut(&Block) -> bool,
{
if !callback(block) {
return false;
}
match block {
Block::Callout { children, .. }
| Block::BlockQuote(children)
| Block::FootnoteDefinition { children, .. } => {
for nested in children {
if !visit_block(nested, callback) {
return false;
}
}
}
Block::List { items, .. } => {
for item_blocks in items {
for nested in item_blocks {
if !visit_block(nested, callback) {
return false;
}
}
}
}
Block::LinkCard { children, .. } => {
for nested in children {
if !visit_block(nested, callback) {
return false;
}
}
}
Block::Shortcode(super::shortcode::Shortcode::Grid(args)) => {
for cell_blocks in &args.cells {
for nested in cell_blocks {
if !visit_block(nested, callback) {
return false;
}
}
}
}
Block::Shortcode(super::shortcode::Shortcode::Hero(args)) => {
for nested in &args.overlay {
if !visit_block(nested, callback) {
return false;
}
}
}
_ => {}
}
true
}
pub fn has_shortcode_recursive(doc: &Document, kind: ShortcodeKind) -> bool {
let mut found = false;
visit_blocks(doc, |block| {
if let Block::Shortcode(sc) = block {
if sc.kind() == kind {
found = true;
return false; }
}
true
});
found
}
pub fn has_callout_recursive(doc: &Document) -> bool {
let mut found = false;
visit_blocks(doc, |block| {
match block {
Block::Callout { .. } => {
found = true;
return false; }
Block::Shortcode(Shortcode::Recent(r)) if markdown_has_callout(&r.fallback_markdown) => {
found = true;
return false;
}
Block::Other(html) if html_opens_a_callout(html) => {
found = true;
return false;
}
Block::Shortcode(sc) if shortcode_classes(sc).is_some_and(has_callout_class) => {
found = true;
return false;
}
_ => {}
}
true
});
found
}
fn has_callout_class(class_list: &str) -> bool {
class_list.split_whitespace().any(|c| c == "callout")
}
fn shortcode_classes(sc: &Shortcode) -> Option<&str> {
match sc {
Shortcode::Buttons(b) => Some(b.classes.as_str()),
Shortcode::Gallery(g) => Some(g.classes.as_str()),
Shortcode::Grid(g) => Some(g.classes.as_str()),
Shortcode::Hero(h) => Some(h.classes.as_str()),
_ => None,
}
}
fn html_opens_a_callout(html: &str) -> bool {
let lowered = html.to_ascii_lowercase();
lowered.split("class").skip(1).any(|after| {
let Some(value) = after.trim_start().strip_prefix('=') else {
return false; };
let value = value.trim_start();
let mut chars = value.chars();
match chars.next() {
Some(q @ ('"' | '\'')) => chars
.as_str()
.split_once(q)
.is_some_and(|(list, _)| has_callout_class(list)),
_ => value.split([' ', '\t', '\n', '\r', '>', '/']).next() == Some("callout"),
}
})
}
fn markdown_has_callout(markdown: &str) -> bool {
markdown.lines().any(|line| {
let line = line.trim_start();
line.starts_with('>') && line.trim_start_matches(['>', ' ']).starts_with("[!")
})
}
#[cfg(test)]
mod tests {
use super::super::node::Inline;
use super::super::url::{Url, UrlKind};
use super::*;
fn paragraph_with_link(url: &str) -> Block {
Block::Paragraph(vec![Inline::Link {
url: Url::unresolved(url),
title: None,
children: vec![Inline::Text("t".into())],
is_wikilink: false,
}])
}
#[test]
fn visit_blocks_descends_into_a_footnote_definition_body() {
let inner = Block::Paragraph(vec![Inline::Text("inside the note".into())]);
let doc = Document::from_blocks(vec![Block::FootnoteDefinition {
label: "a".into(),
children: vec![inner.clone()],
}]);
let mut seen = 0usize;
visit_blocks(&doc, |b| {
if matches!(b, Block::Paragraph(_)) {
seen += 1;
}
true
});
assert_eq!(
seen, 1,
"the note's body block was never visited — the catch-all swallowed it"
);
let control = Document::from_blocks(vec![Block::BlockQuote(vec![inner])]);
let mut seen_control = 0usize;
visit_blocks(&control, |b| {
if matches!(b, Block::Paragraph(_)) {
seen_control += 1;
}
true
});
assert_eq!(seen, seen_control, "identical content, different container");
}
#[test]
fn visits_url_in_paragraph_link() {
let mut doc = Document::from_blocks(vec![paragraph_with_link("docs/")]);
let mut seen: Vec<String> = Vec::new();
visit_urls_mut(&mut doc, |u| match u {
Url::Unresolved(s) => seen.push(s.clone()),
_ => {}
});
assert_eq!(seen, vec!["docs/".to_string()]);
}
#[test]
fn visits_url_in_image_src() {
let mut doc = Document::from_blocks(vec![Block::Paragraph(vec![Inline::Image {
src: Url::unresolved("img.png"),
alt: "x".into(),
title: None,
is_wikilink: false,
wikilink_pothole: None,
}])]);
let mut seen: Vec<String> = Vec::new();
visit_urls_mut(&mut doc, |u| match u {
Url::Unresolved(s) => seen.push(s.clone()),
_ => {}
});
assert_eq!(seen, vec!["img.png".to_string()]);
}
#[test]
fn callback_can_mutate_url_to_resolved() {
let mut doc = Document::from_blocks(vec![paragraph_with_link("docs/")]);
visit_urls_mut(&mut doc, |u| {
*u = Url::resolved("../docs/", UrlKind::Wikilink);
});
match &doc.blocks[0] {
Block::Paragraph(children) => match &children[0] {
Inline::Link { url, .. } => {
assert!(url.is_resolved());
let Url::Resolved(r) = url else {
panic!("expected Resolved, got {url:?}")
};
assert_eq!(r.href, "../docs/");
}
_ => panic!("expected Link"),
},
_ => panic!("expected Paragraph"),
}
}
#[test]
fn visits_url_inside_heading() {
let mut doc = Document::from_blocks(vec![Block::Heading {
level: 2,
children: vec![Inline::Link {
url: Url::unresolved("x"),
title: None,
children: vec![Inline::Text("t".into())],
is_wikilink: false,
}],
id: None,
}]);
let mut count = 0;
visit_urls_mut(&mut doc, |_| count += 1);
assert_eq!(count, 1);
}
#[test]
fn visits_url_inside_emphasis_and_strong() {
let mut doc = Document::from_blocks(vec![Block::Paragraph(vec![Inline::Strong(vec![
Inline::Emphasis(vec![Inline::Link {
url: Url::unresolved("nested"),
title: None,
children: vec![],
is_wikilink: false,
}]),
])])]);
let mut count = 0;
visit_urls_mut(&mut doc, |_| count += 1);
assert_eq!(count, 1);
}
#[test]
fn visits_url_inside_link_children() {
let mut doc = Document::from_blocks(vec![Block::Paragraph(vec![Inline::Link {
url: Url::unresolved("outer"),
title: None,
children: vec![Inline::Image {
src: Url::unresolved("inner.png"),
alt: "".into(),
title: None,
is_wikilink: false,
wikilink_pothole: None,
}],
is_wikilink: false,
}])]);
let mut seen: Vec<String> = Vec::new();
visit_urls_mut(&mut doc, |u| match u {
Url::Unresolved(s) => seen.push(s.clone()),
_ => {}
});
assert_eq!(seen, vec!["outer".to_string(), "inner.png".to_string()]);
}
#[test]
fn visits_urls_inside_list_items() {
let mut doc = Document::from_blocks(vec![Block::List {
ordered: false,
start: None,
items: vec![
vec![paragraph_with_link("a")],
vec![paragraph_with_link("b")],
],
item_source_lines: vec![],
}]);
let mut seen: Vec<String> = Vec::new();
visit_urls_mut(&mut doc, |u| match u {
Url::Unresolved(s) => seen.push(s.clone()),
_ => {}
});
assert_eq!(seen, vec!["a".to_string(), "b".to_string()]);
}
#[test]
fn visits_urls_inside_blockquote() {
let mut doc =
Document::from_blocks(vec![Block::BlockQuote(vec![paragraph_with_link("q")])]);
let mut count = 0;
visit_urls_mut(&mut doc, |_| count += 1);
assert_eq!(count, 1);
}
#[test]
fn visits_urls_inside_table_header_and_rows() {
let mut doc = Document::from_blocks(vec![Block::Table {
header: vec![vec![Inline::Link {
url: Url::unresolved("h"),
title: None,
children: vec![],
is_wikilink: false,
}]],
rows: vec![vec![vec![Inline::Link {
url: Url::unresolved("r"),
title: None,
children: vec![],
is_wikilink: false,
}]]],
alignments: Vec::new(),
header_source_line: None,
row_source_lines: vec![],
}]);
let mut seen: Vec<String> = Vec::new();
visit_urls_mut(&mut doc, |u| match u {
Url::Unresolved(s) => seen.push(s.clone()),
_ => {}
});
assert_eq!(seen, vec!["h".to_string(), "r".to_string()]);
}
#[test]
fn detects_a_callout_anywhere_it_can_appear() {
let callout = || Block::Callout {
kind: super::super::node::CalloutKind::Note,
fold: None,
title: None,
children: vec![Block::Paragraph(vec![Inline::Text("x".into())])],
};
assert!(has_callout_recursive(&Document::from_blocks(vec![callout()])));
assert!(has_callout_recursive(&Document::from_blocks(vec![Block::List {
ordered: false,
start: None,
items: vec![vec![callout()]],
item_source_lines: Vec::new(),
}])));
assert!(!has_callout_recursive(&Document::from_blocks(vec![Block::Paragraph(vec![
Inline::Text("no callout here".into())
])])));
}
#[test]
fn detects_a_callout_in_a_recent_shortcode_fallback() {
use super::super::shortcode::RecentShortcode;
let with = Document::from_blocks(vec![Block::Shortcode(Shortcode::Recent(
RecentShortcode {
fallback_markdown: "> [!warning] Heads up\n> Nothing published yet.".into(),
..Default::default()
},
))]);
assert!(has_callout_recursive(&with), "a fallback callout must gate the partial on");
let without = Document::from_blocks(vec![Block::Shortcode(Shortcode::Recent(
RecentShortcode {
fallback_markdown: "> Just a quote, no callout.".into(),
..Default::default()
},
))]);
assert!(!has_callout_recursive(&without), "a plain blockquote is not a callout");
}
#[test]
fn detects_a_callout_written_as_a_css_region() {
let other = |html: &str| Document::from_blocks(vec![Block::Other(html.into())]);
assert!(has_callout_recursive(&other("<div class=\"callout\">\n")));
assert!(has_callout_recursive(&other("<div class=\"lead callout wide\">\n")));
assert!(has_callout_recursive(&other("<div id=\"x\" class='callout'>\n")));
assert!(has_callout_recursive(&other("<div class=callout>\n")));
assert!(has_callout_recursive(&other("<div CLASS=\"callout\">\n")));
assert!(has_callout_recursive(&other("<div class = \"callout\">\n")));
assert!(has_callout_recursive(&other("<span class=callout/>")));
assert!(has_callout_recursive(&other("<p class=\"lead\">hi</p><div class=\"callout\">")));
assert!(!has_callout_recursive(&other("<div class=\"callout-ish\">\n")));
assert!(!has_callout_recursive(&other("<p>I love a good callout.</p>")));
assert!(!has_callout_recursive(&other("<div class=\"grid\">\n")));
assert!(!has_callout_recursive(&other("<div classname=\"callout\">\n")));
}
#[test]
fn detects_a_callout_class_on_a_typed_shortcode() {
use super::super::shortcode::GridShortcode;
let grid = |classes: &str| {
Document::from_blocks(vec![Block::Shortcode(Shortcode::Grid(GridShortcode {
classes: classes.into(),
..Default::default()
}))])
};
assert!(has_callout_recursive(&grid("callout")));
assert!(has_callout_recursive(&grid("wide callout")));
assert!(!has_callout_recursive(&grid("wide")));
assert!(!has_callout_recursive(&grid("")));
}
#[test]
fn visits_urls_inside_callout() {
let mut doc = Document::from_blocks(vec![Block::Callout {
kind: super::super::node::CalloutKind::Note,
fold: None,
title: None,
children: vec![paragraph_with_link("inside")],
}]);
let mut count = 0;
visit_urls_mut(&mut doc, |_| count += 1);
assert_eq!(count, 1);
}
#[test]
fn does_not_visit_text_or_code() {
let mut doc = Document::from_blocks(vec![
Block::Paragraph(vec![Inline::Text("plain".into()), Inline::Code("c".into())]),
Block::CodeBlock {
lang: None,
value: "x".into(),
},
Block::ThematicBreak,
Block::Other("<raw>".into()),
]);
let mut count = 0;
visit_urls_mut(&mut doc, |_| count += 1);
assert_eq!(count, 0);
}
#[test]
fn empty_document_visits_nothing() {
let mut doc = Document::new();
let mut count = 0;
visit_urls_mut(&mut doc, |_| count += 1);
assert_eq!(count, 0);
}
#[test]
fn visit_blocks_walks_top_level() {
let doc = Document::from_blocks(vec![Block::ThematicBreak, Block::Paragraph(vec![])]);
let mut count = 0;
visit_blocks(&doc, |_| {
count += 1;
true
});
assert_eq!(count, 2);
}
#[test]
fn visit_blocks_descends_into_blockquote() {
let doc = Document::from_blocks(vec![Block::BlockQuote(vec![Block::ThematicBreak])]);
let mut count = 0;
visit_blocks(&doc, |_| {
count += 1;
true
});
assert_eq!(count, 2); }
#[test]
fn visit_blocks_descends_into_list_items() {
let doc = Document::from_blocks(vec![Block::List {
ordered: false,
start: None,
items: vec![vec![Block::ThematicBreak], vec![Block::ThematicBreak]],
item_source_lines: vec![],
}]);
let mut count = 0;
visit_blocks(&doc, |_| {
count += 1;
true
});
assert_eq!(count, 3); }
#[test]
fn visit_blocks_short_circuits_when_callback_returns_false() {
let doc = Document::from_blocks(vec![
Block::ThematicBreak,
Block::ThematicBreak,
Block::ThematicBreak,
]);
let mut count = 0;
let result = visit_blocks(&doc, |_| {
count += 1;
count < 2 });
assert!(!result);
assert_eq!(count, 2);
}
#[test]
fn visits_url_inside_figure_image() {
let mut doc = Document::from_blocks(vec![Block::Figure {
image: Inline::Image {
src: Url::unresolved("fig.png"),
alt: "f".into(),
title: None,
is_wikilink: false,
wikilink_pothole: None,
},
caption: Some(vec![Inline::Text("f".into())]),
width: None,
align: None,
class_names: Vec::new(),
img_style: None,
}]);
let mut seen: Vec<String> = Vec::new();
visit_urls_mut(&mut doc, |u| match u {
Url::Unresolved(s) => seen.push(s.clone()),
_ => {}
});
assert_eq!(seen, vec!["fig.png".to_string()]);
}
#[test]
fn figure_url_becomes_resolved_after_visit() {
let mut doc = Document::from_blocks(vec![Block::Figure {
image: Inline::Image {
src: Url::unresolved("p.jpg"),
alt: "".into(),
title: None,
is_wikilink: false,
wikilink_pothole: None,
},
caption: None,
width: None,
align: None,
class_names: Vec::new(),
img_style: None,
}]);
visit_urls_mut(&mut doc, |u| {
*u = Url::resolved("p.jpg", UrlKind::Asset);
});
match &doc.blocks[0] {
Block::Figure { image, .. } => match image {
Inline::Image { src, .. } => assert!(src.is_resolved()),
_ => panic!("expected Image inside Figure"),
},
_ => panic!("expected Figure"),
}
}
#[test]
fn visits_url_inside_figure_caption_inlines() {
let mut doc = Document::from_blocks(vec![Block::Figure {
image: Inline::Image {
src: Url::unresolved("fig.png"),
alt: "".into(),
title: None,
is_wikilink: false,
wikilink_pothole: None,
},
caption: Some(vec![Inline::Link {
url: Url::unresolved("credit"),
title: None,
children: vec![Inline::Text("credit".into())],
is_wikilink: false,
}]),
width: None,
align: None,
class_names: Vec::new(),
img_style: None,
}]);
let mut seen: Vec<String> = Vec::new();
visit_urls_mut(&mut doc, |u| match u {
Url::Unresolved(s) => seen.push(s.clone()),
_ => {}
});
assert_eq!(seen, vec!["fig.png".to_string(), "credit".to_string()]);
}
#[test]
fn has_shortcode_recursive_returns_false_on_empty_doc() {
let doc = Document::new();
assert!(!has_shortcode_recursive(&doc, ShortcodeKind::Subscribe));
assert!(!has_shortcode_recursive(&doc, ShortcodeKind::Buttons));
}
}