use crate::asset_snapshot::AssetSnapshot;
use crate::content_graph::ContentGraph;
use crate::resolve::registry::RendererRegistry;
use crate::resolve::wikilink_dispatch::{
dispatch_wikilink_embed_with_registry, EmitKind, WikilinkEmit,
};
use crate::resolve::{Diagnostic, OutgoingLink};
use super::document::Document;
use super::node::{Block, Inline};
use super::parser::parse;
use super::shortcode::Shortcode;
use super::url::Url;
#[derive(Debug, Default, Clone)]
pub struct WikilinkDispatchResult {
pub outgoing_links: Vec<OutgoingLink>,
pub diagnostics: Vec<Diagnostic>,
}
pub fn dispatch_wikilink_embeds(
doc: &mut Document,
snapshot: &AssetSnapshot,
graph: &ContentGraph,
registry: &RendererRegistry,
source_path: &str,
) -> WikilinkDispatchResult {
let mut result = WikilinkDispatchResult::default();
dispatch_in_block_children(
&mut doc.blocks,
snapshot,
graph,
registry,
source_path,
&mut result,
);
result
}
fn dispatch_in_block_children(
blocks: &mut Vec<Block>,
snapshot: &AssetSnapshot,
graph: &ContentGraph,
registry: &RendererRegistry,
source_path: &str,
result: &mut WikilinkDispatchResult,
) {
let mut i = 0;
while i < blocks.len() {
let dispatch_info = match &blocks[i] {
Block::Paragraph(inlines) => find_lone_wikilink_image(inlines),
_ => None,
};
if let Some((dest_url, pothole)) = dispatch_info {
let emit = dispatch_wikilink_embed_with_registry(
&dest_url,
pothole.as_deref(),
true, graph,
source_path,
snapshot,
registry,
);
apply_emit(blocks, i, emit, result);
i += 1;
continue;
}
match &mut blocks[i] {
Block::BlockQuote(children)
| Block::Callout { children, .. }
| Block::FootnoteDefinition { children, .. } => {
dispatch_in_block_children(
children,
snapshot,
graph,
registry,
source_path,
result,
);
}
Block::List { items, .. } => {
for item in items.iter_mut() {
dispatch_in_block_children(
item,
snapshot,
graph,
registry,
source_path,
result,
);
}
}
Block::LinkCard { children, .. } => {
dispatch_in_block_children(
children,
snapshot,
graph,
registry,
source_path,
result,
);
}
Block::Shortcode(sc) => {
dispatch_in_shortcode(sc, snapshot, graph, registry, source_path, result);
}
_ => {}
}
i += 1;
}
}
fn dispatch_in_shortcode(
sc: &mut Shortcode,
snapshot: &AssetSnapshot,
graph: &ContentGraph,
registry: &RendererRegistry,
source_path: &str,
result: &mut WikilinkDispatchResult,
) {
match sc {
Shortcode::Subscribe(_)
| Shortcode::Buttons(_)
| Shortcode::Gallery(_)
| Shortcode::Recent(_)
| Shortcode::Apply(_) => {}
Shortcode::Hero(args) => {
dispatch_in_block_children(
&mut args.overlay,
snapshot,
graph,
registry,
source_path,
result,
);
}
Shortcode::Grid(args) => {
for cell in args.cells.iter_mut() {
dispatch_in_block_children(cell, snapshot, graph, registry, source_path, result);
}
}
}
}
fn find_lone_wikilink_image(inlines: &[Inline]) -> Option<(String, Option<String>)> {
let mut found: Option<(String, Option<String>)> = None;
for inline in inlines {
match inline {
Inline::Image {
src,
is_wikilink: true,
wikilink_pothole,
..
} => {
if found.is_some() {
return None; }
let dest = match src {
Url::Unresolved(s) => s.clone(),
Url::Resolved(r) => r.href.clone(),
};
found = Some((dest, wikilink_pothole.clone()));
}
Inline::Text(t) if t.trim().is_empty() => {}
Inline::LineBreak => {}
_ => return None, }
}
found
}
fn apply_emit(
blocks: &mut Vec<Block>,
i: usize,
emit: WikilinkEmit,
result: &mut WikilinkDispatchResult,
) {
if let Some(link) = emit.outgoing_link {
result.outgoing_links.push(link);
}
result.diagnostics.extend(emit.diagnostics);
match emit.output {
EmitKind::Html(html) | EmitKind::Deferred(html) => {
blocks[i] = Block::Other(html);
}
EmitKind::Block(block) => {
blocks[i] = *block;
}
EmitKind::Inline(markdown) | EmitKind::Link(markdown) => {
let parsed = parse(&markdown);
debug_assert_eq!(
parsed.blocks.len(),
1,
"wikilink-emit re-parse must yield exactly one block; \
block_meta lockstep update needed here if this changes"
);
blocks.splice(i..=i, parsed.blocks);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::asset_snapshot::AssetSnapshot;
use crate::content_graph::ContentGraph;
use crate::resolve::registry::RendererRegistry;
fn empty_graph() -> ContentGraph {
crate::content_graph::ContentGraphBuilder::new().build()
}
fn empty_snapshot() -> AssetSnapshot {
AssetSnapshot::default()
}
fn empty_registry() -> RendererRegistry {
RendererRegistry::builtin().build()
}
#[test]
fn lone_wikilink_embed_image_replaces_paragraph_with_inline_form() {
let mut doc = Document::from_blocks(vec![Block::Paragraph(vec![Inline::Image {
src: Url::unresolved("photo.png"),
alt: String::new(),
title: None,
is_wikilink: true,
wikilink_pothole: None,
}])]);
let snap = empty_snapshot();
let graph = empty_graph();
let reg = empty_registry();
let result = dispatch_wikilink_embeds(&mut doc, &snap, &graph, ®, "post.md");
let has_wikilink_image = find_any_wikilink_image(&doc.blocks);
assert!(
!has_wikilink_image,
"dispatch should have removed the wikilink image"
);
let _ = result;
}
#[test]
fn non_wikilink_image_is_left_alone() {
let mut doc = Document::from_blocks(vec![Block::Paragraph(vec![Inline::Image {
src: Url::unresolved("photo.png"),
alt: "a".into(),
title: None,
is_wikilink: false,
wikilink_pothole: None,
}])]);
let snap = empty_snapshot();
let graph = empty_graph();
let reg = empty_registry();
let _ = dispatch_wikilink_embeds(&mut doc, &snap, &graph, ®, "post.md");
match &doc.blocks[0] {
Block::Paragraph(inlines) => match &inlines[0] {
Inline::Image { is_wikilink, .. } => assert!(!is_wikilink),
_ => panic!("expected Image"),
},
_ => panic!("expected Paragraph"),
}
}
#[test]
fn inline_wikilink_image_with_surrounding_text_is_not_dispatched() {
let mut doc = Document::from_blocks(vec![Block::Paragraph(vec![
Inline::Text("hello ".into()),
Inline::Image {
src: Url::unresolved("icon.png"),
alt: String::new(),
title: None,
is_wikilink: true,
wikilink_pothole: None,
},
Inline::Text(" world".into()),
])]);
let snap = empty_snapshot();
let graph = empty_graph();
let reg = empty_registry();
let _ = dispatch_wikilink_embeds(&mut doc, &snap, &graph, ®, "post.md");
match &doc.blocks[0] {
Block::Paragraph(inlines) => {
assert_eq!(inlines.len(), 3);
assert!(matches!(
&inlines[1],
Inline::Image {
is_wikilink: true,
..
}
));
}
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn empty_document_is_a_no_op() {
let mut doc = Document::from_blocks(vec![]);
let snap = empty_snapshot();
let graph = empty_graph();
let reg = empty_registry();
let result = dispatch_wikilink_embeds(&mut doc, &snap, &graph, ®, "post.md");
assert!(doc.blocks.is_empty());
assert!(result.outgoing_links.is_empty());
assert!(result.diagnostics.is_empty());
}
fn find_any_wikilink_image(blocks: &[Block]) -> bool {
for block in blocks {
if block_has_wikilink_image(block) {
return true;
}
}
false
}
fn block_has_wikilink_image(block: &Block) -> bool {
match block {
Block::Paragraph(inlines) => inlines.iter().any(|i| {
matches!(
i,
Inline::Image {
is_wikilink: true,
..
}
)
}),
Block::Figure { image, .. } => matches!(
image,
Inline::Image {
is_wikilink: true,
..
}
),
Block::BlockQuote(children) | Block::Callout { children, .. } => {
children.iter().any(block_has_wikilink_image)
}
Block::List { items, .. } => items
.iter()
.any(|item| item.iter().any(block_has_wikilink_image)),
Block::LinkCard { children, .. } => children.iter().any(block_has_wikilink_image),
Block::Shortcode(sc) => shortcode_has_wikilink_image(sc),
_ => false,
}
}
fn shortcode_has_wikilink_image(sc: &super::super::shortcode::Shortcode) -> bool {
use super::super::shortcode::Shortcode;
match sc {
Shortcode::Subscribe(_)
| Shortcode::Buttons(_)
| Shortcode::Gallery(_)
| Shortcode::Recent(_)
| Shortcode::Apply(_) => false,
Shortcode::Hero(args) => args.overlay.iter().any(block_has_wikilink_image),
Shortcode::Grid(args) => args
.cells
.iter()
.any(|cell| cell.iter().any(block_has_wikilink_image)),
}
}
#[test]
fn grid_cell_wikilink_embed_is_dispatched() {
use super::super::shortcode::{GridShortcode, Shortcode};
let cell = vec![Block::Paragraph(vec![Inline::Image {
src: Url::unresolved("photo.png"),
alt: String::new(),
title: None,
is_wikilink: true,
wikilink_pothole: None,
}])];
let mut doc =
Document::from_blocks(vec![Block::Shortcode(Shortcode::Grid(GridShortcode {
columns: 1,
ratio: None,
classes: String::new(),
cells: vec![cell],
width: None,
}))]);
let snap = empty_snapshot();
let graph = empty_graph();
let reg = empty_registry();
let _ = dispatch_wikilink_embeds(&mut doc, &snap, &graph, ®, "post.md");
let has_wikilink_image = find_any_wikilink_image(&doc.blocks);
assert!(
!has_wikilink_image,
"dispatch should descend into Grid cells and remove the wikilink image"
);
}
#[test]
fn hero_overlay_wikilink_embed_is_dispatched() {
use super::super::shortcode::{HeroShortcode, Shortcode};
let overlay = vec![Block::Paragraph(vec![Inline::Image {
src: Url::unresolved("overlay.png"),
alt: String::new(),
title: None,
is_wikilink: true,
wikilink_pothole: None,
}])];
let mut doc =
Document::from_blocks(vec![Block::Shortcode(Shortcode::Hero(HeroShortcode {
image: None,
extra_images: Vec::new(),
attrs: String::new(),
classes: String::new(),
overlay,
overlay_text: String::new(),
width: None,
mobile: None,
caption: String::new(),
}))]);
let snap = empty_snapshot();
let graph = empty_graph();
let reg = empty_registry();
let _ = dispatch_wikilink_embeds(&mut doc, &snap, &graph, ®, "post.md");
let has_wikilink_image = find_any_wikilink_image(&doc.blocks);
assert!(
!has_wikilink_image,
"dispatch should descend into Hero overlay and remove the wikilink image"
);
}
fn parse_and_dispatch(md: &str, files: &[&str]) -> Vec<Block> {
let mut doc = crate::ast::parse(md);
let mut b = crate::content_graph::ContentGraphBuilder::new();
for p in files {
let slug = std::path::Path::new(p)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(p);
b.add_file(p, slug);
}
let graph = b.build();
let snap = empty_snapshot();
let reg = empty_registry();
let _ = dispatch_wikilink_embeds(&mut doc, &snap, &graph, ®, "post.md");
doc.blocks
}
fn dispatched_html(blocks: &[Block]) -> &str {
match blocks {
[Block::Other(html)] => html,
other => panic!("expected one dispatched Block::Other, got {other:?}"),
}
}
#[test]
fn video_plain_dispatches_to_video_synth() {
let blocks = parse_and_dispatch("![[clip.mov]]\n", &["clip.mov"]);
let html = dispatched_html(&blocks);
assert!(html.contains("<video"), "got: {html}");
assert!(html.contains("clip.mp4"), "mov→mp4 swap missing: {html}");
}
#[test]
fn video_percent_keeps_video_and_width() {
let blocks = parse_and_dispatch("![[clip.mov|77%]]\n", &["clip.mov"]);
let html = dispatched_html(&blocks);
assert!(html.contains("<video"), "got: {html}");
assert!(
html.contains(r#"width="77%""#),
"percent width dropped: {html}"
);
assert!(
!html.contains("<img"),
"video must not render as <img>: {html}"
);
}
#[test]
fn video_box_sizing_keeps_video_and_dims() {
let blocks = parse_and_dispatch("![[clip.mov|640x360]]\n", &["clip.mov"]);
let html = dispatched_html(&blocks);
assert!(html.contains("<video"), "got: {html}");
assert!(html.contains(r#"width="640px""#), "got: {html}");
assert!(html.contains(r#"height="360px""#), "got: {html}");
assert!(
!html.contains("figcaption"),
"sizing alias must not become a caption: {html}"
);
}
#[test]
fn image_percent_still_promotes_to_figure() {
let blocks = parse_and_dispatch("![[pic.jpg|55%]]\n", &["pic.jpg"]);
match &blocks[..] {
[Block::Figure { width, .. }] => {
assert_eq!(width.as_deref(), Some("55%"));
}
other => panic!("expected Figure for image percent, got {other:?}"),
}
}
}