Skip to main content

moss_core/ast/
visit.rs

1//! Visitor helpers over the typed AST.
2//!
3//! Pattern matching is the visitor framework (no `Visit` trait, no
4//! `Box<dyn Node>`). These free functions exist for the cases that
5//! genuinely need recursive descent across every variant — URL resolution,
6//! shortcode-presence queries — and would otherwise be repeated in every
7//! consumer.
8//!
9//! ## When to add a visitor here
10//!
11//! Add a free function only when the alternative is repeated recursive
12//! traversal across multiple call sites. Per the design doc principle P4:
13//! one-off transformations belong inline as `match block { ... }`.
14
15use super::document::Document;
16use super::node::{Block, Inline};
17use super::shortcode::{Shortcode, ShortcodeKind};
18use super::url::Url;
19
20/// Visit every URL in the document with a callback that may mutate it
21/// in place. Walks links, image srcs, and all nested block/inline content.
22///
23/// Used by the resolve-classification pass: the upstream resolve pipeline
24/// has already rewritten markdown sources so URLs come out as one of the
25/// shapes documented in [`crate::ast::url::Url`]. The callback inspects the
26/// raw string and replaces it with a [`crate::ast::url::Url::Resolved`].
27pub 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            // Descend into the image's src (the load-bearing URL); the
87            // caption is a Vec<Inline> that may itself carry links —
88            // unlikely in practice (captions default to alt text) but the
89            // visitor must not silently skip them.
90            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            // Phase 4 PR4.5: the wrapping URL (compound-link href) +
99            // every URL inside the inner block content.
100            callback(url);
101            for nested in children {
102                visit_urls_in_block(nested, callback);
103            }
104        }
105        Block::CodeBlock { .. } | Block::ThematicBreak | Block::Other(_) => {
106            // No URLs in these.
107        }
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(_) => {} // No URLs.
118        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            // Phase 4 PR4.5 (2026-05-28): descend into the typed overlay
136            // blocks so URLs inside `:::hero` overlay markdown (e.g. a
137            // `[Read more](/x)` link in the overlay copy) get classified
138            // by the same visitor pass.
139            for block in &mut args.overlay {
140                visit_urls_in_block(block, callback);
141            }
142        }
143        Shortcode::Grid(args) => {
144            // Phase 4 PR4.5 (2026-05-28): cells are now typed Vec<Block>;
145            // descend into each cell. Compound-link cells render through
146            // `Block::LinkCard { url, children }`, whose own visit arm
147            // walks both the wrapping href and the inner children.
148            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(_) => {} // No URLs.
155        Shortcode::Apply(_) => {}  // No URLs.
156    }
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
187/// Visit every block (top-level + nested) with a read-only callback. The
188/// callback returns `false` to short-circuit the traversal (any returned
189/// `false` makes the whole walk return `false`).
190///
191/// Used for queries like "does any block contain a `:::subscribe`
192/// shortcode?" — the body of `has_shortcode_recursive` below.
193pub 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        // A footnote definition is a block container like any other. It is
216        // listed here rather than left to the catch-all because the catch-all
217        // is for blocks with no block children at all, and a walk that skipped
218        // a note's body would answer "does any block …?" with a No that only
219        // means "not anywhere I looked".
220        | 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            // Phase 4 PR4.5: descend into the compound-link cell's inner
238            // block content (image + heading + paragraphs).
239            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            // Phase 4 PR4.5: cells are typed Vec<Block>; descend so
247            // `has_shortcode_recursive(_, Subscribe)` etc. find shortcodes
248            // nested inside grid cells.
249            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            // Phase 4 PR4.5: overlay is typed Vec<Block>; descend so
259            // `has_shortcode_recursive(_, Subscribe)` etc. find shortcodes
260            // nested inside `:::hero` overlays.
261            for nested in &args.overlay {
262                if !visit_block(nested, callback) {
263                    return false;
264                }
265            }
266        }
267        // Headings, paragraphs, code blocks, tables, other shortcode
268        // variants, thematic breaks, figures, raw HTML — terminal at the
269        // block level. Inline children of headings/paragraphs are visited
270        // by inline visitors, not block visitors.
271        //
272        // Every variant that OWNS a `Vec<Block>` must be named above, not
273        // left to fall through here: this arm reads as "no block children",
274        // and a container that lands in it is skipped in silence.
275        _ => {}
276    }
277    true
278}
279
280/// True if any block in the document is a shortcode of the given kind
281/// (recursive — descends into callouts, blockquotes, list items).
282///
283/// Replaces the `project_has_inline_subscribe` filesystem scan once
284/// shortcodes migrate to typed AST in Phase B.
285pub 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; // short-circuit
292            }
293        }
294        true
295    });
296    found
297}
298
299/// True if any block in the document is a callout (recursive — a callout
300/// nested inside a list item or another callout counts).
301///
302/// Gates the `callouts` site stylesheet partial: a build whose every page
303/// answers `false` here never ships `assets/css/site/callouts.css`. The
304/// query is a lowering of the typed tree, never a scan of emitted HTML —
305/// see NORTH-STAR "parse once, lower to many".
306///
307/// # The four shapes it matches
308///
309/// The gate is only as complete as the typed tree, and three documented paths
310/// reach a `class="callout"` element without a `Block::Callout`:
311///
312/// 1. **`:::recent` fallback text.** `Recent.fallback_markdown` is a raw
313///    `String`, not `Vec<Block>` like `Grid.cells` and `Hero.overlay` — it is
314///    parsed at HTML-emit time. `visit_blocks` cannot descend into it, so this
315///    function re-parses it for callout syntax below. Promoting the field to
316///    `Vec<Block>` would delete that special case; until then it is the one
317///    place this query looks at text rather than structure.
318/// 2. **The pure-CSS region `:::{.callout}`.** `shortcode_extract` lowers an
319///    empty-name fenced div to a literal `<div class="callout">` in a
320///    `Block::Other` — see [`html_opens_a_callout`]. This is documented
321///    authoring syntax, not hand-rolled markup, so it must gate the partial.
322/// 3. **`{.callout}` on a typed shortcode** (`:::grid 2 {.callout}`), whose
323///    classes live in the typed struct — see [`shortcode_classes`].
324///
325/// # What it still cannot see
326///
327/// A `.moss/theme/` script that injects a callout at runtime is outside the
328/// AST entirely, and so is HTML a **plugin** adds in the `enhance` hook —
329/// which runs after `SiteAssets` is folded, so even an AST query could not
330/// help. Both are outside the "declared, never inferred" contract the
331/// stylesheet module states: a site that hand-rolls moss's internal markup at
332/// runtime is asking for the class without asking for the feature.
333pub 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; // short-circuit
340            }
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
360/// True if `class_list` (a space-separated `{.a .b}` or `class="…"` value)
361/// contains `callout` as a whole token.
362///
363/// Token-wise, not substring: `callout-note` is a different class and a site
364/// using only it has not asked for the callout partial.
365fn has_callout_class(class_list: &str) -> bool {
366    class_list.split_whitespace().any(|c| c == "callout")
367}
368
369/// The classes a typed shortcode puts on its wrapper, for the variants that
370/// accept `{.foo}` class args.
371fn 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
381/// True if a raw-HTML block opens an element carrying the `callout` class.
382///
383/// This is the pure-CSS region form — `:::{.callout}` — which
384/// `shortcode_extract` lowers to a literal `<div class="callout">` in a
385/// `Block::Other`, never to `Block::Callout`. It is documented authoring
386/// syntax (`docs/authoring/customization.md`, styling rung 3), so a site whose
387/// only callouts are written that way must still get the partial.
388///
389/// A crude `contains` on the whole block would fire on prose that merely
390/// mentions the word; matching inside a `class="…"` attribute value keeps the
391/// over-approximation to markup the author actually wrote.
392///
393/// moss's own lowering always produces lowercase, double-quoted `class="…"`,
394/// but a `Block::Other` can also be HTML the author typed into the markdown by
395/// hand. So this tolerates `CLASS`, single quotes, unquoted values, and spaces
396/// around the `=`. Erring toward matching is the cheap direction: a false
397/// positive costs 4 kB of CSS, a false negative renders an unstyled grey box.
398fn html_opens_a_callout(html: &str) -> bool {
399    let lowered = html.to_ascii_lowercase();
400    // `split` rather than `match_indices` + slice: `clippy::string_slice` is
401    // denied in this crate, and the iterator hands back the tail directly.
402    lowered.split("class").skip(1).any(|after| {
403        // Tolerate a space before the `=`.
404        let Some(value) = after.trim_start().strip_prefix('=') else {
405            return false; // `classname="…"`, or the bare word in prose.
406        };
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            // Unquoted value: one token, ending at whitespace or the tag's
415            // close — including the `/` of a self-closing tag.
416            _ => value.split([' ', '\t', '\n', '\r', '>', '/']).next() == Some("callout"),
417        }
418    })
419}
420
421/// True if raw markdown contains Obsidian callout syntax (`> [!type]`).
422///
423/// Deliberately loose in the over-shipping direction: a false positive costs
424/// 4 KB of CSS, a false negative renders an unstyled grey box.
425fn 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    /// `visit_blocks` promises "every block (top-level + nested)". A footnote
448    /// definition owns a `Vec<Block>`, so a walk that stops at the definition
449    /// node answers a "does any block …?" query with a No that only means "not
450    /// anywhere I looked" — the failure mode is a silent wrong answer, not an
451    /// error. The blockquote control is what proves the miss is specific to
452    /// this container rather than to nesting in general.
453    #[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        // Critical contract: a single visit transitions Unresolved → Resolved.
515        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        // Nested links can't appear in CommonMark, but link children can
569        // contain images (e.g. `[![alt](img)](href)`). Both URLs visited.
570        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    /// The gate for the `callouts` stylesheet partial. A false negative here
646    /// ships a `class="callout"` element with no rules — an unstyled grey box.
647    #[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        // Top level.
656        assert!(has_callout_recursive(&Document::from_blocks(vec![callout()])));
657        // Nested inside a list item — `visit_blocks` has to descend.
658        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        // A document with no callout must answer false, or the gate is a
665        // constant and the partial always ships.
666        assert!(!has_callout_recursive(&Document::from_blocks(vec![Block::Paragraph(vec![
667            Inline::Text("no callout here".into())
668        ])])));
669    }
670
671    /// `Recent.fallback_markdown` is a raw `String`, parsed at HTML-emit time,
672    /// so `visit_blocks` cannot descend into it. A site whose ONLY callout is
673    /// in a `:::recent` fallback still emits `class="callout"`, so the gate
674    /// has to re-parse the text.
675    #[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    /// `:::{.callout}` — the pure-CSS region — never becomes `Block::Callout`.
696    /// `shortcode_extract` lowers it to a literal `<div class="callout">` in a
697    /// `Block::Other`, so a gate that only matched the typed variant shipped
698    /// no rules for a site that styles exclusively this way. It is documented
699    /// authoring syntax (customization.md, styling rung 3), not hand-rolled
700    /// markup, which is what separates it from the runtime-injection cases the
701    /// gate deliberately ignores.
702    #[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        // A `Block::Other` can also be HTML the author typed by hand, which is
711        // not held to moss's lowering conventions.
712        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        // Second element in the same block still counts.
716        assert!(has_callout_recursive(&other("<p class=\"lead\">hi</p><div class=\"callout\">")));
717
718        // Token-wise, not substring: a different class, and prose that merely
719        // says the word, must both leave the partial off.
720        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    /// A typed shortcode can carry `{.callout}` too (`:::grid 2 {.callout}`);
727    /// those classes live in the struct, not in any `Block::Other`.
728    #[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        // Text/Code/LineBreak are leaves with no URL field; the visitor
759        // must not synthesize visits.
760        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); // BlockQuote + nested ThematicBreak
802    }
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); // List + 2 ThematicBreaks
818    }
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 // stop after 2 visits
831        });
832        assert!(!result);
833        assert_eq!(count, 2);
834    }
835
836    // -----------------------------------------------------------------
837    // Phase 4 PR3 (2026-05-27): Block::Figure URL descent
838    // -----------------------------------------------------------------
839
840    #[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        // Critical contract: a single visit transitions the figure's
867        // image URL from Unresolved to Resolved (matching the
868        // visit_urls_mut bypass-prevention invariant).
869        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        // Defensive: caption is Vec<Inline>; if it carries a Link (rare —
898        // captions default to alt-text Inline::Text), the URL must still
899        // be visited.
900        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        // Phase A: Shortcode enum is empty. Recursive query returns false
930        // for any kind. Per-kind positive-case tests land alongside
931        // each Phase B migration (when a Shortcode variant exists).
932        let doc = Document::new();
933        assert!(!has_shortcode_recursive(&doc, ShortcodeKind::Subscribe));
934        assert!(!has_shortcode_recursive(&doc, ShortcodeKind::Buttons));
935    }
936}