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 // `` 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 `` 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 (``), 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, ®, "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, ®, "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, ®, "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, ®, "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, ®, "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, ®, "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, ®, "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}