use super::*;
use crate::ast::parser::parse;
use crate::content_graph::ContentGraphBuilder;
fn graph_with(paths: &[&str]) -> crate::content_graph::ContentGraph {
let mut b = ContentGraphBuilder::new();
for p in paths {
b.add_file(p, p);
}
b.build()
}
#[test]
fn markdown_link_fragment_preserved_raw_not_slugged() {
let (path, suffix) = split_path_suffix("page#My Heading");
assert_eq!(path, "page");
assert_eq!(suffix, Some("#My Heading")); }
#[test]
fn resolves_standard_markdown_link_to_internal() {
let mut doc = parse("[文字](文字.md)");
let graph = graph_with(&["index.md", "文字/文字.md"]);
let outgoing = resolve_urls(&mut doc, &graph, "index.md").outgoing;
assert_eq!(outgoing.len(), 1);
assert_eq!(outgoing[0].target_path, "文字/文字.md");
assert_eq!(outgoing[0].display_text, "文字");
assert_eq!(outgoing[0].link_type, LinkType::Standard);
match &doc.blocks[0] {
Block::Paragraph(children) => match &children[0] {
Inline::Link { url, .. } => {
assert!(url.is_unresolved(), "expected sentinel, got: {url:?}");
match url {
Url::Unresolved(s) => assert_eq!(s, "moss-resolved:文字/文字.md"),
Url::Resolved(_) => unreachable!(),
}
}
_ => panic!("expected Link"),
},
_ => panic!("expected Paragraph"),
}
}
#[test]
fn passes_through_external_link() {
let mut doc = parse("[ex](https://example.com)");
let graph = graph_with(&["index.md"]);
let outgoing = resolve_urls(&mut doc, &graph, "index.md").outgoing;
assert!(outgoing.is_empty());
match &doc.blocks[0] {
Block::Paragraph(children) => match &children[0] {
Inline::Link { url, .. } => {
let Url::Resolved(r) = url else {
panic!("expected Resolved, got {url:?}")
};
assert_eq!(r.kind, UrlKind::External);
assert_eq!(r.href, "https://example.com");
}
_ => panic!("expected Link"),
},
_ => panic!("expected Paragraph"),
}
}
#[test]
fn classifies_anchor_link() {
let mut doc = parse("[top](#top)");
let graph = graph_with(&["index.md"]);
let outgoing = resolve_urls(&mut doc, &graph, "index.md").outgoing;
assert!(outgoing.is_empty());
match &doc.blocks[0] {
Block::Paragraph(children) => match &children[0] {
Inline::Link { url, .. } => {
let Url::Resolved(r) = url else {
panic!("expected Resolved, got {url:?}")
};
assert_eq!(r.kind, UrlKind::Anchor);
assert_eq!(r.href, "#top");
}
_ => panic!("expected Link"),
},
_ => panic!("expected Paragraph"),
}
}
#[test]
fn classifies_mailto() {
let mut doc = parse("[Mail](mailto:test@example.com)");
let graph = graph_with(&["index.md"]);
let _ = resolve_urls(&mut doc, &graph, "index.md").outgoing;
match &doc.blocks[0] {
Block::Paragraph(children) => match &children[0] {
Inline::Link { url, .. } => {
let Url::Resolved(r) = url else {
panic!("expected Resolved, got {url:?}")
};
assert_eq!(r.kind, UrlKind::Mailto);
assert_eq!(r.href, "mailto:test@example.com");
}
_ => panic!("expected Link"),
},
_ => panic!("expected Paragraph"),
}
}
#[test]
fn resolves_bare_filename_image_against_graph() {
let mut doc = parse("");
let mut b = ContentGraphBuilder::new();
b.add_file("assets/photo.jpg", "photo");
let graph = b.build();
let outgoing = resolve_urls(&mut doc, &graph, "articles/post.md").outgoing;
assert_eq!(outgoing.len(), 1);
assert_eq!(outgoing[0].target_path, "assets/photo.jpg");
assert_eq!(outgoing[0].display_text, "My Photo");
assert_eq!(outgoing[0].link_type, LinkType::Standard);
match &doc.blocks[0] {
Block::Paragraph(children) => match &children[0] {
Inline::Image { src, .. } => {
let Url::Resolved(r) = src else {
panic!("expected Resolved, got {src:?}")
};
assert_eq!(r.href, "/assets/photo.jpg");
assert_eq!(r.kind, UrlKind::Asset);
}
Inline::Link {
children: link_kids,
..
} => {
if let Some(Inline::Image { src, .. }) = link_kids.first() {
let Url::Resolved(r) = src else {
panic!("expected Resolved, got {src:?}")
};
assert_eq!(r.href, "/assets/photo.jpg");
}
}
_ => panic!("expected Image, got {children:?}"),
},
Block::Figure { image, .. } => {
if let Inline::Image { src, .. } = image {
let Url::Resolved(r) = src else {
panic!("expected Resolved, got {src:?}")
};
assert_eq!(r.href, "/assets/photo.jpg");
}
}
_ => panic!("expected Paragraph or Figure, got {:?}", doc.blocks[0]),
}
}
#[test]
fn unresolved_bare_filename_passes_through() {
let mut doc = parse("");
let graph = graph_with(&["index.md"]);
let outgoing = resolve_urls(&mut doc, &graph, "articles/post.md").outgoing;
assert!(outgoing.is_empty());
let mut found_image = false;
for block in &doc.blocks {
if let Block::Paragraph(children) = block {
for inline in children {
if let Inline::Image { src, .. } = inline {
let Url::Resolved(r) = src else {
panic!("expected Resolved, got {src:?}")
};
assert_eq!(r.href, "nonexistent.jpg");
assert_eq!(r.kind, UrlKind::Asset);
found_image = true;
}
}
}
if let Block::Figure { image, .. } = block {
if let Inline::Image { src, .. } = image {
let Url::Resolved(r) = src else {
panic!("expected Resolved, got {src:?}")
};
assert_eq!(r.href, "nonexistent.jpg");
found_image = true;
}
}
}
assert!(found_image, "expected an Inline::Image in the parsed doc");
}
#[test]
fn does_not_resolve_url_inside_code_block() {
let mut doc = parse("```\n[link](inside.md)\n```\n");
let graph = graph_with(&["index.md", "inside.md"]);
let outgoing = resolve_urls(&mut doc, &graph, "index.md").outgoing;
assert!(
outgoing.is_empty(),
"code block content must not produce OutgoingLink"
);
}
#[test]
fn fragment_preserved_on_internal_link() {
let mut doc = parse("[x](文字/文字.md#sec)");
let graph = graph_with(&["index.md", "文字/文字.md"]);
let outgoing = resolve_urls(&mut doc, &graph, "index.md").outgoing;
assert_eq!(outgoing.len(), 1);
assert_eq!(outgoing[0].target_path, "文字/文字.md");
match &doc.blocks[0] {
Block::Paragraph(children) => match &children[0] {
Inline::Link { url, .. } => match url {
Url::Unresolved(s) => assert_eq!(s, "moss-resolved:文字/文字.md#sec"),
Url::Resolved(r) => panic!("expected sentinel, got Resolved({r:?})"),
},
_ => panic!("expected Link"),
},
_ => panic!("expected Paragraph"),
}
}
#[test]
fn query_string_preserved_on_internal_link() {
let mut b = ContentGraphBuilder::new();
b.add_file("index.md", "x");
b.add_file("assets/scale-compare.html", "h");
let graph = b.build();
let mut doc = parse("[demo](scale-compare.html?a=major_pent&r=major_pent%3AD)");
let outgoing = resolve_urls(&mut doc, &graph, "index.md").outgoing;
assert_eq!(outgoing.len(), 1);
assert_eq!(outgoing[0].target_path, "assets/scale-compare.html");
match &doc.blocks[0] {
Block::Paragraph(children) => match &children[0] {
Inline::Link { url, .. } => match url {
Url::Unresolved(s) => assert_eq!(
s,
"moss-resolved:assets/scale-compare.html?a=major_pent&r=major_pent%3AD"
),
Url::Resolved(r) => panic!("expected sentinel, got Resolved({r:?})"),
},
_ => panic!("expected Link"),
},
_ => panic!("expected Paragraph"),
}
}
#[test]
fn standard_markdown_link_emits_sentinel() {
let source = "index.md";
let content = "[文字](文字.md)";
let graph = graph_with(&["index.md", "文字/文字.md"]);
let mut doc = parse(content);
let visitor = resolve_urls(&mut doc, &graph, source).outgoing;
assert_eq!(visitor.len(), 1);
assert_eq!(visitor[0].target_path, "文字/文字.md");
assert_eq!(visitor[0].display_text, "文字");
assert_eq!(visitor[0].link_type, LinkType::Standard);
match &doc.blocks[0] {
Block::Paragraph(children) => match &children[0] {
Inline::Link {
url: Url::Unresolved(s),
..
} => {
assert_eq!(s, "moss-resolved:文字/文字.md");
}
_ => panic!("expected Url::Unresolved sentinel, got {:?}", children[0]),
},
_ => panic!("expected Paragraph"),
}
}
#[test]
fn multiple_links_one_line_emit_sentinels() {
let source = "index.md";
let content = "[a](foo.md) and [b](bar.md)";
let graph = graph_with(&["index.md", "foo.md", "bar.md"]);
let mut doc = parse(content);
let visitor = resolve_urls(&mut doc, &graph, source).outgoing;
assert_eq!(visitor.len(), 2);
assert_eq!(visitor[0].target_path, "foo.md");
assert_eq!(visitor[1].target_path, "bar.md");
}
#[test]
fn external_links_no_outgoing() {
let source = "index.md";
let content = "[ext](https://example.com) [anchor](#top) [mail](mailto:a@b)";
let graph = graph_with(&["index.md"]);
let mut doc = parse(content);
let visitor = resolve_urls(&mut doc, &graph, source).outgoing;
assert!(visitor.is_empty());
}
#[test]
fn unresolved_link_no_outgoing() {
let source = "index.md";
let content = "[missing](missing.md)";
let graph = graph_with(&["index.md"]);
let mut doc = parse(content);
let visitor = resolve_urls(&mut doc, &graph, source).outgoing;
assert!(visitor.is_empty());
match &doc.blocks[0] {
Block::Paragraph(children) => match &children[0] {
Inline::Link { url, .. } => {
let Url::Resolved(r) = url else {
panic!("expected Resolved, got {url:?}")
};
assert_eq!(r.href, "missing.md");
assert_eq!(r.kind, UrlKind::Internal);
}
_ => panic!("expected Link"),
},
_ => panic!("expected Paragraph"),
}
}
#[test]
fn code_block_urls_not_visited() {
let source = "index.md";
let content =
"Before\n\n```\n[link](inside.md)\n\n```\n\nAfter [link](inside.md).";
let mut b = ContentGraphBuilder::new();
b.add_file("index.md", "x");
b.add_file("inside.md", "i");
b.add_file("assets/photo.jpg", "p");
let graph = b.build();
let mut doc = parse(content);
let visitor = resolve_urls(&mut doc, &graph, source).outgoing;
assert_eq!(visitor.len(), 1);
assert_eq!(visitor[0].target_path, "inside.md");
}
#[test]
fn query_and_fragment_sentinel_shape() {
let source = "index.md";
let content = "[d](app.html?x=1#sec)";
let mut b = ContentGraphBuilder::new();
b.add_file("index.md", "x");
b.add_file("assets/app.html", "h");
let graph = b.build();
let mut doc = parse(content);
let visitor = resolve_urls(&mut doc, &graph, source).outgoing;
assert_eq!(visitor.len(), 1);
assert_eq!(visitor[0].target_path, "assets/app.html");
match &doc.blocks[0] {
Block::Paragraph(children) => match &children[0] {
Inline::Link {
url: Url::Unresolved(s),
..
} => {
assert_eq!(s, "moss-resolved:assets/app.html?x=1#sec");
}
_ => panic!("expected sentinel, got {:?}", children[0]),
},
_ => panic!("expected Paragraph"),
}
}
#[test]
fn link_wrapping_image_target_path() {
let source = "index.md";
let content = "[](scale-compare.html?a=major_pent&r=major_pent%3AD)";
let mut b = ContentGraphBuilder::new();
b.add_file("index.md", "x");
b.add_file("assets/scale-compare.html", "h");
b.add_file("assets/scale-compare.png", "p");
let graph = b.build();
let mut doc = parse(content);
let visitor = resolve_urls(&mut doc, &graph, source).outgoing;
assert_eq!(
visitor.len(),
2,
"expected image + link OutgoingLinks, got: {visitor:?}"
);
let link_entry = visitor
.iter()
.find(|o| o.target_path == "assets/scale-compare.html")
.expect("OutgoingLink for scale-compare.html not found");
assert_eq!(link_entry.link_type, LinkType::Standard);
assert_eq!(link_entry.display_text, "scale-compare");
assert!(
visitor
.iter()
.any(|o| o.target_path == "assets/scale-compare.png"),
"OutgoingLink for scale-compare.png not found"
);
}
#[test]
fn pipe_bearing_image_url_unchanged() {
let mut doc = parse("");
let mut b = ContentGraphBuilder::new();
b.add_file("assets/photo.jpg", "p");
let graph = b.build();
let outgoing = resolve_urls(&mut doc, &graph, "articles/post.md").outgoing;
assert!(outgoing.is_empty());
}
#[test]
fn idempotent_on_already_resolved_url() {
let mut doc = parse("[文字](文字.md)");
let graph = graph_with(&["index.md", "文字/文字.md"]);
let outgoing1 = resolve_urls(&mut doc, &graph, "index.md").outgoing;
let outgoing2 = resolve_urls(&mut doc, &graph, "index.md").outgoing;
assert!(
outgoing2.is_empty(),
"idempotency violated: {:?}",
outgoing2
);
assert_eq!(outgoing1.len(), 1);
}
#[test]
fn absolute_path_passes_through() {
let mut doc = parse("[abs](/about.html)");
let graph = graph_with(&["index.md", "about.html"]);
let outgoing = resolve_urls(&mut doc, &graph, "index.md").outgoing;
assert!(outgoing.is_empty());
match &doc.blocks[0] {
Block::Paragraph(children) => match &children[0] {
Inline::Link { url, .. } => {
let Url::Resolved(r) = url else {
panic!("expected Resolved, got {url:?}")
};
assert_eq!(r.href, "/about.html");
}
_ => panic!("expected Link"),
},
_ => panic!("expected Paragraph"),
}
}
fn extract_hero_image_href(doc: &Document) -> Option<String> {
for block in &doc.blocks {
if let Block::Shortcode(Shortcode::Hero(args)) = block {
if let Some(Url::Resolved(r)) = &args.image {
return Some(r.href.clone());
}
return None;
}
}
None
}
#[test]
fn hero_body_wikilink_resolves_against_graph_at_depth_0() {
let mut doc = parse(":::hero\n![[hero.jpg]]\n# Welcome\n:::\n");
let mut b = ContentGraphBuilder::new();
b.add_file("index.md", "home");
b.add_file("assets/hero.jpg", "hero");
let graph = b.build();
let outgoing = resolve_urls(&mut doc, &graph, "index.md").outgoing;
let href = extract_hero_image_href(&doc).expect("hero image must be Resolved");
assert_eq!(
href, "/assets/hero.jpg",
"hero body-wikilink must resolve to the asset's pinned URL, got {href:?}"
);
assert!(
outgoing.iter().any(|o| o.target_path == "assets/hero.jpg"),
"expected OutgoingLink to assets/hero.jpg, got {outgoing:?}"
);
}
#[test]
fn hero_extra_images_resolve_against_graph_like_the_primary() {
let mut doc = parse(":::hero\n![[hero.jpg]]\n![[second.jpg]]\n# Welcome\n:::\n");
let mut b = ContentGraphBuilder::new();
b.add_file("index.md", "home");
b.add_file("assets/hero.jpg", "hero");
b.add_file("assets/second.jpg", "second");
let graph = b.build();
let outgoing = resolve_urls(&mut doc, &graph, "index.md").outgoing;
let extras: Vec<String> = doc
.blocks
.iter()
.find_map(|blk| match blk {
Block::Shortcode(Shortcode::Hero(h)) => Some(
h.extra_images
.iter()
.map(|u| match u {
Url::Resolved(r) => r.href.clone(),
Url::Unresolved(s) => format!("UNRESOLVED:{s}"),
})
.collect(),
),
_ => None,
})
.expect("hero present");
assert_eq!(extras, vec!["/assets/second.jpg".to_string()], "{extras:?}");
assert!(
outgoing
.iter()
.any(|o| o.target_path == "assets/second.jpg"),
"expected OutgoingLink to assets/second.jpg, got {outgoing:?}"
);
}
#[test]
fn hero_body_wikilink_href_does_not_depend_on_referencing_depth() {
let mut doc = parse(":::hero\n![[hero.jpg]]\n:::\n");
let mut b = ContentGraphBuilder::new();
b.add_file("articles/post.md", "post");
b.add_file("index.md", "home");
b.add_file("assets/hero.jpg", "hero");
let graph = b.build();
let _ = resolve_urls(&mut doc, &graph, "articles/post.md").outgoing;
let deep = extract_hero_image_href(&doc).expect("hero image must be Resolved");
let mut doc_root = parse(":::hero\n![[hero.jpg]]\n:::\n");
let _ = resolve_urls(&mut doc_root, &graph, "index.md").outgoing;
let root = extract_hero_image_href(&doc_root).expect("hero image must be Resolved");
assert_eq!(deep, "/assets/hero.jpg", "got {deep:?}");
assert_eq!(deep, root, "depth must not change the emitted href");
}
#[test]
fn hero_unresolved_wikilink_passes_through() {
let mut doc = parse(":::hero\n![[missing.jpg]]\n:::\n");
let graph = graph_with(&["index.md"]);
let _ = resolve_urls(&mut doc, &graph, "index.md").outgoing;
let href = extract_hero_image_href(&doc).expect("hero image must be Resolved");
assert_eq!(href, "missing.jpg");
}
fn first_link_href(doc: &Document) -> String {
match &doc.blocks[0] {
Block::Paragraph(children) => {
let link = children
.iter()
.find(|i| matches!(i, Inline::Link { .. }))
.expect("expected an Inline::Link");
match link {
Inline::Link { url, .. } => match url {
Url::Unresolved(s) => s.clone(),
Url::Resolved(r) => r.href.clone(),
},
_ => unreachable!(),
}
}
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn wikilink_cross_page_fragment_is_slugged() {
let mut doc = parse("[[other#Getting Started]]");
let graph = graph_with(&["index.md", "other.md"]);
let _ = resolve_urls(&mut doc, &graph, "index.md").outgoing;
assert_eq!(
first_link_href(&doc),
"moss-resolved:other.md#getting-started"
);
}
#[test]
fn wikilink_same_page_fragment_is_slugged() {
let mut doc = parse("[[#Local Section]]");
let graph = graph_with(&["index.md"]);
let _ = resolve_urls(&mut doc, &graph, "index.md").outgoing;
assert_eq!(first_link_href(&doc), "#local-section");
}
#[test]
fn markdown_link_fragment_stays_raw_not_slugged() {
let mut doc = parse("[x](other#GettingStarted)");
let graph = graph_with(&["index.md", "other.md"]);
let _ = resolve_urls(&mut doc, &graph, "index.md").outgoing;
assert_eq!(
first_link_href(&doc),
"moss-resolved:other.md#GettingStarted"
);
}
#[test]
fn wikilink_block_ref_keeps_id_raw() {
let mut doc = parse("[[other#^Block Id]]");
let graph = graph_with(&["index.md", "other.md"]);
let _ = resolve_urls(&mut doc, &graph, "index.md").outgoing;
let href = first_link_href(&doc);
assert!(
href.contains("#Block Id"),
"expected raw block-ref, got: {href}"
);
assert!(!href.contains("#block-id"), "block-ref was slugged: {href}");
}
#[test]
fn wikilink_cjk_fragment_preserved() {
let mut doc = parse("[[other#中文标题]]");
let graph = graph_with(&["index.md", "other.md"]);
let _ = resolve_urls(&mut doc, &graph, "index.md").outgoing;
assert_eq!(first_link_href(&doc), "moss-resolved:other.md#中文标题");
}
#[test]
fn slug_wikilink_suffix_preserves_query() {
assert_eq!(slug_wikilink_suffix("?a=1#My Heading"), "?a=1#my-heading");
assert_eq!(slug_wikilink_suffix("?a=1"), "?a=1");
assert_eq!(slug_wikilink_suffix("#My Heading"), "#my-heading");
assert_eq!(slug_wikilink_suffix("#^Block Id"), "#Block Id");
}
fn resolve_image_src(
raw: &str,
source_path: &str,
graph: &crate::content_graph::ContentGraph,
) -> String {
let mut url = Url::Unresolved(raw.to_string());
let mut found = UrlResolution::default();
resolve_asset_url(&mut url, "", graph, source_path, &mut found);
match url {
Url::Resolved(r) => r.href,
Url::Unresolved(s) => s,
}
}
#[test]
fn image_separator_fallback_rebases_to_root() {
let graph = graph_with(&["assets/AGU2025.jpg", "News/post.md"]);
assert_eq!(
resolve_image_src("./assets/AGU2025.jpg", "News/post.md", &graph),
"/assets/AGU2025.jpg"
);
}
#[test]
fn image_absolute_stays_absolute() {
let graph = graph_with(&["assets/x.jpg"]);
assert_eq!(
resolve_image_src("/assets/x.jpg", "News/post.md", &graph),
"/assets/x.jpg"
);
}
#[test]
fn image_case_mismatch_emits_canonical() {
let graph = graph_with(&["assets/Hoon.JPG"]);
assert_eq!(
resolve_image_src("./assets/Hoon.jpg", "Team.md", &graph),
"/assets/Hoon.JPG"
);
}
#[test]
fn image_href_is_identical_from_root_and_from_a_nested_note() {
let graph = graph_with(&[
"MIRROR/在場/cover-IMG.png",
"index.md",
"MIRROR/在場/note.md",
]);
let from_root = resolve_image_src("cover-IMG.png", "index.md", &graph);
let from_deep = resolve_image_src("cover-IMG.png", "MIRROR/在場/note.md", &graph);
assert_eq!(from_root, "/mirror/%E5%9C%A8%E5%A0%B4/cover-IMG.png");
assert_eq!(
from_root, from_deep,
"the referencing note's depth must not change the emitted URL"
);
}
#[test]
fn unresolvable_image_ref_reports_instead_of_guessing() {
let graph = graph_with(&["assets/photo.jpg", "post.md"]);
let mut doc = parse("");
let found = resolve_urls(&mut doc, &graph, "post.md");
assert!(found.outgoing.is_empty(), "{:?}", found.outgoing);
assert_eq!(found.diagnostics.len(), 1, "{:?}", found.diagnostics);
assert_eq!(found.diagnostics[0].reference, "nope.jpg");
assert_eq!(found.diagnostics[0].source_path, "post.md");
}
#[test]
fn every_kind_of_missing_media_is_reported_as_blocking() {
let graph = graph_with(&["assets/photo.jpg", "post.md"]);
for markdown in [
"",
"",
"",
"",
"![[clip.mp4]]",
] {
let mut doc = parse(markdown);
let found = resolve_urls(&mut doc, &graph, "post.md");
let blocking: Vec<_> = found
.diagnostics
.iter()
.filter(|d| d.kind == DiagnosticKind::MissingAsset)
.collect();
assert_eq!(
blocking.len(),
1,
"{markdown} must report exactly one missing reference, got {:?}",
found.diagnostics
);
}
}
#[test]
fn a_resolvable_reference_reports_nothing_to_block_on() {
let graph = graph_with(&["assets/photo.jpg", "assets/clip.mp4", "post.md"]);
for markdown in ["", "![[clip.mp4]]"] {
let mut doc = parse(markdown);
let found = resolve_urls(&mut doc, &graph, "post.md");
assert!(
!found
.diagnostics
.iter()
.any(|d| d.kind == DiagnosticKind::MissingAsset),
"{markdown} resolves, so nothing may block the publish: {:?}",
found.diagnostics
);
}
}
#[test]
fn image_bare_unchanged_from_today() {
let graph = graph_with(&["assets/photo.jpg", "post.md"]);
assert_eq!(
resolve_image_src("photo.jpg", "post.md", &graph),
"/assets/photo.jpg"
);
}