Skip to main content

moss_core/ast/
dispatch_wikilink_embeds.rs

1//! Wikilink embed dispatch visitor.
2//!
3//! Walks a [`Document`] and routes every wikilink embed image
4//! (`Inline::Image { is_wikilink: true, .. }`) through the
5//! [`crate::resolve::wikilink_dispatch::dispatch_wikilink_embed_with_registry`]
6//! dispatcher, replacing the block-level paragraph with the renderer's
7//! output (HTML, inline markdown re-parse, deferred plugin marker, or
8//! standard link).
9//!
10//! # Why a separate visitor
11//!
12//! Pre-Phase-4, `transform_events` ran this dispatch INLINE on each
13//! `Event::Start(Tag::Image { link_type: LinkType::WikiLink, .. })`
14//! event from pulldown-cmark, swallowing the event range. With the flip to
15//! `parse → render_document`, pulldown-cmark only runs once (during
16//! `parse`), so the dispatcher must operate on the typed AST instead. This
17//! visitor IS the AST equivalent.
18//!
19//! # Why ![`![[...]]`] needs pothole preservation
20//!
21//! PR3.5 (2026-05-28) added wikilink-alt classification to the parser, so
22//! `![[v.mp4|width=400]]` arrives at the AST with `alt: ""` (params
23//! consumed) and the original `width=400` token is gone. The dispatcher
24//! needs the original pothole to compose typed params for the video synth.
25//! PR7a-flip-core-B added the `wikilink_pothole` field on `Inline::Image`
26//! that the parser populates from the raw alt text BEFORE classification
27//! runs — this visitor reads it for embed dispatch.
28//!
29//! # Inline vs block-level
30//!
31//! Per the SoCiviC + chps fixtures (the 4 client sites at Phase 4 cutover),
32//! every wikilink embed in production is a "lone embed paragraph": a
33//! paragraph whose only `Inline::Image { is_wikilink: true, .. }` plus
34//! whitespace/linebreaks. The visitor detects this shape and replaces the
35//! whole paragraph with the dispatch output (so block-level HTML doesn't
36//! get `<p>`-wrapped).
37//!
38//! Inline wikilink images (e.g. `Some text ![[icon.png]] more text` in the
39//! same paragraph) stay as `Inline::Image` and route through the normal
40//! `render_inline` → `hooks.render_image` synth path. The dispatcher does
41//! NOT walk them — the `<picture>` shape produced by `synthesize_image_html`
42//! is the right output for inline embeds.
43
44use crate::asset_snapshot::AssetSnapshot;
45use crate::content_graph::ContentGraph;
46use crate::resolve::registry::RendererRegistry;
47use crate::resolve::wikilink_dispatch::{
48    dispatch_wikilink_embed_with_registry, EmitKind, WikilinkEmit,
49};
50use crate::resolve::{Diagnostic, OutgoingLink};
51
52use super::document::Document;
53use super::node::{Block, Inline};
54use super::parser::parse;
55use super::shortcode::Shortcode;
56use super::url::Url;
57
58/// Aggregated output of [`dispatch_wikilink_embeds`].
59///
60/// Returned alongside the in-place document mutation so callers
61/// (currently `process_markdown_file`) can fold the discovered outgoing
62/// links into the page's `ContentGraph` updates and surface diagnostics.
63#[derive(Debug, Default, Clone)]
64pub struct WikilinkDispatchResult {
65    /// Outgoing links discovered by the dispatcher (target paths +
66    /// display text). Caller may extend its own outgoing-link list.
67    pub outgoing_links: Vec<OutgoingLink>,
68    /// Diagnostics (e.g. unresolved reference). Caller logs via
69    /// `log::warn!` per entry.
70    pub diagnostics: Vec<Diagnostic>,
71}
72
73/// Walk the document's top-level blocks and dispatch every wikilink
74/// embed image (`![[…]]` paragraph) through the embed-renderer registry.
75///
76/// Returns the aggregated outgoing-link + diagnostic data; mutates
77/// `doc.blocks` in place to substitute embed paragraphs with their
78/// dispatched output.
79///
80/// # When to call
81///
82/// Run BEFORE [`crate::ast::resolve_urls::resolve_urls`]. The dispatcher
83/// reads `Inline::Image.src` as `Url::Unresolved(raw)` — the parser's
84/// pre-resolve form — because `dispatch_wikilink_embed_with_registry` does
85/// its own [`crate::resolve::fuzzy_path::resolve_reference`] internally.
86/// If `resolve_urls` runs first, the wikilink images' src is already
87/// `Url::Resolved(href)` and the dispatcher's internal resolver would
88/// double-resolve.
89pub fn dispatch_wikilink_embeds(
90    doc: &mut Document,
91    snapshot: &AssetSnapshot,
92    graph: &ContentGraph,
93    registry: &RendererRegistry,
94    source_path: &str,
95) -> WikilinkDispatchResult {
96    let mut result = WikilinkDispatchResult::default();
97    dispatch_in_block_children(
98        &mut doc.blocks,
99        snapshot,
100        graph,
101        registry,
102        source_path,
103        &mut result,
104    );
105    result
106}
107
108/// Walk a `Vec<Block>` (top-level OR a BlockQuote / Callout / List item)
109/// and dispatch any lone-embed paragraphs. Recurses into nested
110/// containers (BlockQuote / Callout / List items).
111fn dispatch_in_block_children(
112    blocks: &mut Vec<Block>,
113    snapshot: &AssetSnapshot,
114    graph: &ContentGraph,
115    registry: &RendererRegistry,
116    source_path: &str,
117    result: &mut WikilinkDispatchResult,
118) {
119    let mut i = 0;
120    while i < blocks.len() {
121        // First, check if this block is a lone wikilink embed paragraph.
122        let dispatch_info = match &blocks[i] {
123            Block::Paragraph(inlines) => find_lone_wikilink_image(inlines),
124            _ => None,
125        };
126
127        if let Some((dest_url, pothole)) = dispatch_info {
128            let emit = dispatch_wikilink_embed_with_registry(
129                &dest_url,
130                pothole.as_deref(),
131                true, // is_embed: lone-paragraph wikilink image is an embed
132                graph,
133                source_path,
134                snapshot,
135                registry,
136            );
137            apply_emit(blocks, i, emit, result);
138            i += 1;
139            continue;
140        }
141
142        // Not a lone embed — descend into nested containers if any.
143        //
144        // PR7a-flip-core-C (2026-05-28): the recursion now matches the
145        // visitor pattern in `visit.rs` for `Grid.cells` and `Hero.overlay`
146        // (visit.rs:140, 145). Pre-flip, this visitor missed shortcode
147        // bodies — a wikilink embed inside a `:::grid` cell or `:::hero`
148        // overlay would not be dispatched.
149        match &mut blocks[i] {
150            Block::BlockQuote(children)
151            | Block::Callout { children, .. }
152            | Block::FootnoteDefinition { children, .. } => {
153                dispatch_in_block_children(
154                    children,
155                    snapshot,
156                    graph,
157                    registry,
158                    source_path,
159                    result,
160                );
161            }
162            Block::List { items, .. } => {
163                for item in items.iter_mut() {
164                    dispatch_in_block_children(
165                        item,
166                        snapshot,
167                        graph,
168                        registry,
169                        source_path,
170                        result,
171                    );
172                }
173            }
174            Block::LinkCard { children, .. } => {
175                // PR4.5 compound-link cell — descend into its block body so
176                // wikilinks inside a grid LinkCard render correctly.
177                dispatch_in_block_children(
178                    children,
179                    snapshot,
180                    graph,
181                    registry,
182                    source_path,
183                    result,
184                );
185            }
186            Block::Shortcode(sc) => {
187                dispatch_in_shortcode(sc, snapshot, graph, registry, source_path, result);
188            }
189            _ => {}
190        }
191        i += 1;
192    }
193}
194
195/// Recurse into a shortcode's typed block bodies (Grid cells, Hero overlay).
196/// Matches `visit.rs::visit_urls_in_shortcode` so wikilink embeds inside
197/// shortcode bodies are dispatched alongside top-level ones.
198fn dispatch_in_shortcode(
199    sc: &mut Shortcode,
200    snapshot: &AssetSnapshot,
201    graph: &ContentGraph,
202    registry: &RendererRegistry,
203    source_path: &str,
204    result: &mut WikilinkDispatchResult,
205) {
206    match sc {
207        // Variants with no typed block body — nothing to descend into.
208        Shortcode::Subscribe(_)
209        | Shortcode::Buttons(_)
210        | Shortcode::Gallery(_)
211        | Shortcode::Recent(_)
212        | Shortcode::Apply(_) => {}
213        Shortcode::Hero(args) => {
214            dispatch_in_block_children(
215                &mut args.overlay,
216                snapshot,
217                graph,
218                registry,
219                source_path,
220                result,
221            );
222        }
223        Shortcode::Grid(args) => {
224            for cell in args.cells.iter_mut() {
225                dispatch_in_block_children(cell, snapshot, graph, registry, source_path, result);
226            }
227        }
228    }
229}
230
231/// Detect a "lone wikilink image" paragraph: exactly one
232/// `Inline::Image { is_wikilink: true, .. }` modulo whitespace text and
233/// line breaks.
234///
235/// Returns `Some((dest_url, pothole))` where `dest_url` is the unresolved
236/// wikilink target (e.g. `"v.mp4"`) and `pothole` is the original pothole
237/// text (e.g. `Some("width=400")`).
238fn find_lone_wikilink_image(inlines: &[Inline]) -> Option<(String, Option<String>)> {
239    let mut found: Option<(String, Option<String>)> = None;
240    for inline in inlines {
241        match inline {
242            Inline::Image {
243                src,
244                is_wikilink: true,
245                wikilink_pothole,
246                ..
247            } => {
248                if found.is_some() {
249                    return None; // Multiple images — not a lone embed.
250                }
251                let dest = match src {
252                    Url::Unresolved(s) => s.clone(),
253                    Url::Resolved(r) => r.href.clone(),
254                };
255                found = Some((dest, wikilink_pothole.clone()));
256            }
257            // Whitespace / linebreak siblings are tolerated.
258            Inline::Text(t) if t.trim().is_empty() => {}
259            Inline::LineBreak => {}
260            _ => return None, // Any non-whitespace sibling disqualifies.
261        }
262    }
263    found
264}
265
266/// Apply the dispatcher's `EmitKind` to `blocks[i]`.
267///
268/// - `Html` / `Deferred` → replace with `Block::Other(html_or_marker)`
269///   (block-level raw HTML, bypassing `<p>` wrap).
270/// - `Inline` / `Link` → re-parse via [`parse`]; splice the resulting
271///   blocks in at position `i` (so e.g. an image embed that re-parses
272///   into a `Block::Paragraph(vec![Inline::Image { … }])` becomes the
273///   exact same shape the inline-image renderer expects).
274fn apply_emit(
275    blocks: &mut Vec<Block>,
276    i: usize,
277    emit: WikilinkEmit,
278    result: &mut WikilinkDispatchResult,
279) {
280    if let Some(link) = emit.outgoing_link {
281        result.outgoing_links.push(link);
282    }
283    result.diagnostics.extend(emit.diagnostics);
284
285    match emit.output {
286        EmitKind::Html(html) | EmitKind::Deferred(html) => {
287            blocks[i] = Block::Other(html);
288        }
289        EmitKind::Block(block) => {
290            // Image-embed synth-collapse: a typed `Block::Figure` (or other
291            // typed block) substituted 1:1 at `blocks[i]`. This is the only
292            // emit shape that preserves the source paragraph's `block_meta`
293            // (a `Block::Other` HTML string carries none): replacing in place
294            // leaves the parallel `block_meta` vec untouched, so the figure
295            // inherits the original paragraph's `data-source-line`. No
296            // re-parse, no splice — exactly one block in, one block out.
297            blocks[i] = *block;
298        }
299        EmitKind::Inline(markdown) | EmitKind::Link(markdown) => {
300            // Re-parse the emitted markdown and splice in the resulting
301            // blocks at position `i`. Typical shape: a single
302            // `Block::Paragraph(vec![Inline::Image { … }])` or
303            // `Block::Figure { image: Inline::Image { … }, … }`, which
304            // routes through the standard image-render path (synth
305            // `<picture>` for raster, etc.).
306            //
307            // The caller's loop advances by 1, so we leave it to step
308            // through any inserted blocks. The inserted blocks shouldn't
309            // themselves contain wikilink embeds (re-parse of a
310            // `![alt](url)` produces a plain markdown image), so a single
311            // advance is safe.
312            let parsed = parse(&markdown);
313            // Latent invariant: caller (apply_emit) holds `&mut Document` and
314            // splices block_meta in lockstep if/when parsed.blocks.len() != 1.
315            // Today re-parse of an emit-rendered `![alt](url)` or `[text](url)`
316            // always yields exactly one block, so the parallel-vec invariant
317            // (blocks.len() == block_meta.len()) accidentally holds via this
318            // path. If a future EmitKind expansion emits a multi-block
319            // markdown fragment, this assert fails fast in debug builds
320            // before render_document's own debug_assert panics with a less
321            // helpful message.
322            debug_assert_eq!(
323                parsed.blocks.len(),
324                1,
325                "wikilink-emit re-parse must yield exactly one block; \
326                 block_meta lockstep update needed here if this changes"
327            );
328            blocks.splice(i..=i, parsed.blocks);
329        }
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::asset_snapshot::AssetSnapshot;
337    use crate::content_graph::ContentGraph;
338    use crate::resolve::registry::RendererRegistry;
339
340    fn empty_graph() -> ContentGraph {
341        crate::content_graph::ContentGraphBuilder::new().build()
342    }
343
344    fn empty_snapshot() -> AssetSnapshot {
345        AssetSnapshot::default()
346    }
347
348    fn empty_registry() -> RendererRegistry {
349        RendererRegistry::builtin().build()
350    }
351
352    #[test]
353    fn lone_wikilink_embed_image_replaces_paragraph_with_inline_form() {
354        // `![[photo.png]]` is a lone wikilink-embed paragraph. The image
355        // renderer emits inline-markdown (`![alt](url)`), which re-parses
356        // to `Block::Figure { image: Inline::Image { … }, … }` (image-only
357        // paragraph promotion). The dispatcher splices in the re-parsed
358        // result.
359        let mut doc = Document::from_blocks(vec![Block::Paragraph(vec![Inline::Image {
360            src: Url::unresolved("photo.png"),
361            alt: String::new(),
362            title: None,
363            is_wikilink: true,
364            wikilink_pothole: None,
365        }])]);
366        let snap = empty_snapshot();
367        let graph = empty_graph();
368        let reg = empty_registry();
369        let result = dispatch_wikilink_embeds(&mut doc, &snap, &graph, &reg, "post.md");
370        // The original paragraph is replaced. Either with Block::Figure
371        // (after re-parse) or Block::Paragraph (when re-parse doesn't
372        // promote). Either way, the resulting blocks should NOT contain
373        // a wikilink `Inline::Image`.
374        let has_wikilink_image = find_any_wikilink_image(&doc.blocks);
375        assert!(
376            !has_wikilink_image,
377            "dispatch should have removed the wikilink image"
378        );
379        // photo.png is unresolved in an empty graph; the result records
380        // the missing-reference outgoing link (target_path == "photo.png").
381        // We don't assert the exact form because the renderer's behavior
382        // for unresolved paths is contract-tested in wikilink_dispatch.rs.
383        let _ = result;
384    }
385
386    #[test]
387    fn non_wikilink_image_is_left_alone() {
388        // Standard markdown image (not a wikilink). Dispatch should
389        // skip it.
390        let mut doc = Document::from_blocks(vec![Block::Paragraph(vec![Inline::Image {
391            src: Url::unresolved("photo.png"),
392            alt: "a".into(),
393            title: None,
394            is_wikilink: false,
395            wikilink_pothole: None,
396        }])]);
397        let snap = empty_snapshot();
398        let graph = empty_graph();
399        let reg = empty_registry();
400        let _ = dispatch_wikilink_embeds(&mut doc, &snap, &graph, &reg, "post.md");
401        match &doc.blocks[0] {
402            Block::Paragraph(inlines) => match &inlines[0] {
403                Inline::Image { is_wikilink, .. } => assert!(!is_wikilink),
404                _ => panic!("expected Image"),
405            },
406            _ => panic!("expected Paragraph"),
407        }
408    }
409
410    #[test]
411    fn inline_wikilink_image_with_surrounding_text_is_not_dispatched() {
412        // `Some text ![[icon.png]] more text` — the wikilink image is
413        // inline. Dispatch should NOT touch it (the inline path
414        // renders through `render_image`).
415        let mut doc = Document::from_blocks(vec![Block::Paragraph(vec![
416            Inline::Text("hello ".into()),
417            Inline::Image {
418                src: Url::unresolved("icon.png"),
419                alt: String::new(),
420                title: None,
421                is_wikilink: true,
422                wikilink_pothole: None,
423            },
424            Inline::Text(" world".into()),
425        ])]);
426        let snap = empty_snapshot();
427        let graph = empty_graph();
428        let reg = empty_registry();
429        let _ = dispatch_wikilink_embeds(&mut doc, &snap, &graph, &reg, "post.md");
430        // The paragraph should still carry the inline wikilink image
431        // (text + image + text shape preserved).
432        match &doc.blocks[0] {
433            Block::Paragraph(inlines) => {
434                assert_eq!(inlines.len(), 3);
435                assert!(matches!(
436                    &inlines[1],
437                    Inline::Image {
438                        is_wikilink: true,
439                        ..
440                    }
441                ));
442            }
443            other => panic!("expected Paragraph, got {other:?}"),
444        }
445    }
446
447    #[test]
448    fn empty_document_is_a_no_op() {
449        let mut doc = Document::from_blocks(vec![]);
450        let snap = empty_snapshot();
451        let graph = empty_graph();
452        let reg = empty_registry();
453        let result = dispatch_wikilink_embeds(&mut doc, &snap, &graph, &reg, "post.md");
454        assert!(doc.blocks.is_empty());
455        assert!(result.outgoing_links.is_empty());
456        assert!(result.diagnostics.is_empty());
457    }
458
459    /// Helper: scan blocks for any wikilink-embed Inline::Image. Used by
460    /// the lone-embed test to verify dispatch removed the wikilink.
461    fn find_any_wikilink_image(blocks: &[Block]) -> bool {
462        for block in blocks {
463            if block_has_wikilink_image(block) {
464                return true;
465            }
466        }
467        false
468    }
469
470    fn block_has_wikilink_image(block: &Block) -> bool {
471        match block {
472            Block::Paragraph(inlines) => inlines.iter().any(|i| {
473                matches!(
474                    i,
475                    Inline::Image {
476                        is_wikilink: true,
477                        ..
478                    }
479                )
480            }),
481            Block::Figure { image, .. } => matches!(
482                image,
483                Inline::Image {
484                    is_wikilink: true,
485                    ..
486                }
487            ),
488            Block::BlockQuote(children) | Block::Callout { children, .. } => {
489                children.iter().any(block_has_wikilink_image)
490            }
491            Block::List { items, .. } => items
492                .iter()
493                .any(|item| item.iter().any(block_has_wikilink_image)),
494            Block::LinkCard { children, .. } => children.iter().any(block_has_wikilink_image),
495            Block::Shortcode(sc) => shortcode_has_wikilink_image(sc),
496            _ => false,
497        }
498    }
499
500    fn shortcode_has_wikilink_image(sc: &super::super::shortcode::Shortcode) -> bool {
501        use super::super::shortcode::Shortcode;
502        match sc {
503            // Variants with no typed block body — no wikilink images possible.
504            // (`Recent` carries `fallback_markdown: String`, not typed blocks;
505            // see `fix(ast): cover Shortcode::Recent in dispatch_wikilink_embeds`
506            // commit 747b8f2b0 for the production-side rationale.)
507            Shortcode::Subscribe(_)
508            | Shortcode::Buttons(_)
509            | Shortcode::Gallery(_)
510            | Shortcode::Recent(_)
511            | Shortcode::Apply(_) => false,
512            Shortcode::Hero(args) => args.overlay.iter().any(block_has_wikilink_image),
513            Shortcode::Grid(args) => args
514                .cells
515                .iter()
516                .any(|cell| cell.iter().any(block_has_wikilink_image)),
517        }
518    }
519
520    // -----------------------------------------------------------------
521    // PR7a-flip-core-C (2026-05-28): shortcode-body recursion
522    // -----------------------------------------------------------------
523
524    #[test]
525    fn grid_cell_wikilink_embed_is_dispatched() {
526        // A `:::grid` whose cell contains a lone wikilink embed paragraph.
527        // The visitor must descend into Grid.cells and dispatch the embed,
528        // replacing the paragraph in place. Before flip-core-C, the
529        // wikilink Inline::Image would survive in the cell.
530        use super::super::shortcode::{GridShortcode, Shortcode};
531
532        let cell = vec![Block::Paragraph(vec![Inline::Image {
533            src: Url::unresolved("photo.png"),
534            alt: String::new(),
535            title: None,
536            is_wikilink: true,
537            wikilink_pothole: None,
538        }])];
539        let mut doc =
540            Document::from_blocks(vec![Block::Shortcode(Shortcode::Grid(GridShortcode {
541                columns: 1,
542                ratio: None,
543                classes: String::new(),
544                cells: vec![cell],
545                width: None,
546            }))]);
547        let snap = empty_snapshot();
548        let graph = empty_graph();
549        let reg = empty_registry();
550        let _ = dispatch_wikilink_embeds(&mut doc, &snap, &graph, &reg, "post.md");
551        let has_wikilink_image = find_any_wikilink_image(&doc.blocks);
552        assert!(
553            !has_wikilink_image,
554            "dispatch should descend into Grid cells and remove the wikilink image"
555        );
556    }
557
558    #[test]
559    fn hero_overlay_wikilink_embed_is_dispatched() {
560        // A `:::hero` whose overlay contains a lone wikilink embed paragraph.
561        // The visitor must descend into Hero.overlay and dispatch the embed.
562        // SoCiviC's fixtures rely on this — hero overlays carry markdown
563        // that may include `![[...]]` references.
564        use super::super::shortcode::{HeroShortcode, Shortcode};
565
566        let overlay = vec![Block::Paragraph(vec![Inline::Image {
567            src: Url::unresolved("overlay.png"),
568            alt: String::new(),
569            title: None,
570            is_wikilink: true,
571            wikilink_pothole: None,
572        }])];
573        let mut doc =
574            Document::from_blocks(vec![Block::Shortcode(Shortcode::Hero(HeroShortcode {
575                image: None,
576                extra_images: Vec::new(),
577                attrs: String::new(),
578                classes: String::new(),
579                overlay,
580                overlay_text: String::new(),
581                width: None,
582                mobile: None,
583                caption: String::new(),
584            }))]);
585        let snap = empty_snapshot();
586        let graph = empty_graph();
587        let reg = empty_registry();
588        let _ = dispatch_wikilink_embeds(&mut doc, &snap, &graph, &reg, "post.md");
589        let has_wikilink_image = find_any_wikilink_image(&doc.blocks);
590        assert!(
591            !has_wikilink_image,
592            "dispatch should descend into Hero overlay and remove the wikilink image"
593        );
594    }
595
596    // --- parse → dispatch composition (video sizing truth table) ---------
597    //
598    // Each stage was unit-tested in isolation while the COMPOSITION broke:
599    // the parser promoted `![[clip.mov|77%]]` to Block::Figure (width
600    // bypasses the empty-alt guard) and this visitor only dispatches
601    // Paragraph-shaped embeds, so the video synthesizer never ran. These
602    // tests pin the full parse→dispatch pipe for every video sizing shape.
603
604    fn parse_and_dispatch(md: &str, files: &[&str]) -> Vec<Block> {
605        let mut doc = crate::ast::parse(md);
606        let mut b = crate::content_graph::ContentGraphBuilder::new();
607        for p in files {
608            let slug = std::path::Path::new(p)
609                .file_stem()
610                .and_then(|s| s.to_str())
611                .unwrap_or(p);
612            b.add_file(p, slug);
613        }
614        let graph = b.build();
615        let snap = empty_snapshot();
616        let reg = empty_registry();
617        let _ = dispatch_wikilink_embeds(&mut doc, &snap, &graph, &reg, "post.md");
618        doc.blocks
619    }
620
621    /// Extract the raw HTML of the single Block::Other the dispatcher
622    /// emitted, panicking with the actual shape otherwise.
623    fn dispatched_html(blocks: &[Block]) -> &str {
624        match blocks {
625            [Block::Other(html)] => html,
626            other => panic!("expected one dispatched Block::Other, got {other:?}"),
627        }
628    }
629
630    #[test]
631    fn video_plain_dispatches_to_video_synth() {
632        let blocks = parse_and_dispatch("![[clip.mov]]\n", &["clip.mov"]);
633        let html = dispatched_html(&blocks);
634        assert!(html.contains("<video"), "got: {html}");
635        assert!(html.contains("clip.mp4"), "mov→mp4 swap missing: {html}");
636    }
637
638    #[test]
639    fn video_percent_keeps_video_and_width() {
640        let blocks = parse_and_dispatch("![[clip.mov|77%]]\n", &["clip.mov"]);
641        let html = dispatched_html(&blocks);
642        assert!(html.contains("<video"), "got: {html}");
643        assert!(
644            html.contains(r#"width="77%""#),
645            "percent width dropped: {html}"
646        );
647        assert!(
648            !html.contains("<img"),
649            "video must not render as <img>: {html}"
650        );
651    }
652
653    #[test]
654    fn video_box_sizing_keeps_video_and_dims() {
655        let blocks = parse_and_dispatch("![[clip.mov|640x360]]\n", &["clip.mov"]);
656        let html = dispatched_html(&blocks);
657        assert!(html.contains("<video"), "got: {html}");
658        assert!(html.contains(r#"width="640px""#), "got: {html}");
659        assert!(html.contains(r#"height="360px""#), "got: {html}");
660        assert!(
661            !html.contains("figcaption"),
662            "sizing alias must not become a caption: {html}"
663        );
664    }
665
666    #[test]
667    fn image_percent_still_promotes_to_figure() {
668        // Images keep the parse-time Figure promotion (dispatch skips the
669        // already-promoted block; resolve_urls owns its src downstream).
670        let blocks = parse_and_dispatch("![[pic.jpg|55%]]\n", &["pic.jpg"]);
671        match &blocks[..] {
672            [Block::Figure { width, .. }] => {
673                assert_eq!(width.as_deref(), Some("55%"));
674            }
675            other => panic!("expected Figure for image percent, got {other:?}"),
676        }
677    }
678}