use super::document::Document;
use super::node::{Block, Inline};
use super::shortcode::Shortcode;
use super::url::{ResolvedUrl, Url, UrlKind};
use super::visit::visit_urls_mut;
use crate::content_graph::ContentGraph;
use crate::resolve::asset_class::{resolve_asset_ref, AssetIndex, AssetResolution};
use crate::resolve::fuzzy_path::{relative_asset_path, resolve_reference, ResolvedRef};
use crate::resolve::{LinkType, OutgoingLink};
pub struct GraphAssetIndex<'a>(pub &'a ContentGraph);
impl<'a> AssetIndex for GraphAssetIndex<'a> {
fn contains(&self, p: &str) -> bool {
self.0.asset_contains(p)
}
fn contains_ci(&self, p: &str) -> Option<String> {
self.0.asset_contains_ci(p)
}
fn find_by_suffix(&self, s: &str) -> Vec<String> {
self.0.asset_find_by_suffix(s)
}
}
pub fn resolve_urls(
doc: &mut Document,
graph: &ContentGraph,
source_path: &str,
) -> Vec<OutgoingLink> {
let mut outgoing: Vec<OutgoingLink> = Vec::new();
resolve_image_urls(doc, graph, source_path, &mut outgoing);
resolve_link_urls(doc, graph, source_path, &mut outgoing);
outgoing
}
fn resolve_image_urls(
doc: &mut Document,
graph: &ContentGraph,
source_path: &str,
outgoing: &mut Vec<OutgoingLink>,
) {
walk_inline_images_mut(doc, &mut |inline| {
let (src, alt) = match inline {
Inline::Image { src, alt, .. } => (src, alt.clone()),
_ => return,
};
resolve_asset_url(src, &alt, graph, source_path, outgoing);
});
for block in &mut doc.blocks {
resolve_shortcode_image_urls(block, graph, source_path, outgoing);
}
}
fn resolve_asset_url(
url: &mut Url,
alt: &str,
graph: &ContentGraph,
source_path: &str,
outgoing: &mut Vec<OutgoingLink>,
) {
let raw = match url {
Url::Unresolved(s) => s.clone(),
Url::Resolved(_) => return,
};
if raw.contains('|') {
*url = Url::Resolved(ResolvedUrl::new(raw, UrlKind::Asset));
return;
}
if raw.starts_with('#')
|| raw.starts_with("http://")
|| raw.starts_with("https://")
|| raw.starts_with("//")
|| raw.starts_with("data:")
|| raw.starts_with("mailto:")
{
*url = Url::Resolved(ResolvedUrl::new(raw, UrlKind::Asset));
return;
}
let is_absolute = raw.starts_with('/');
match resolve_asset_ref(&raw, source_path, &GraphAssetIndex(graph)) {
AssetResolution::Resolved { root_rel, provenance: _ } => {
if is_absolute {
*url = Url::Resolved(ResolvedUrl::new(format!("/{root_rel}"), UrlKind::Asset));
return;
}
let rel = relative_asset_path(source_path, &root_rel);
outgoing.push(OutgoingLink {
target_path: root_rel,
display_text: alt.to_string(),
link_type: LinkType::Standard,
});
*url = Url::Resolved(ResolvedUrl::new(rel, UrlKind::Asset));
}
AssetResolution::Ambiguous { chosen, candidates: _ } => {
let rel = relative_asset_path(source_path, &chosen);
outgoing.push(OutgoingLink {
target_path: chosen,
display_text: alt.to_string(),
link_type: LinkType::Standard,
});
*url = Url::Resolved(ResolvedUrl::new(rel, UrlKind::Asset));
}
AssetResolution::NotFound => {
*url = Url::Resolved(ResolvedUrl::new(raw, UrlKind::Asset));
}
}
}
fn resolve_shortcode_image_urls(
block: &mut Block,
graph: &ContentGraph,
source_path: &str,
outgoing: &mut Vec<OutgoingLink>,
) {
match block {
Block::Shortcode(sc) => match sc {
Shortcode::Hero(args) => {
if let Some(image_url) = args.image.as_mut() {
resolve_asset_url(image_url, "", graph, source_path, outgoing);
}
for nested in &mut args.overlay {
resolve_shortcode_image_urls(nested, graph, source_path, outgoing);
}
}
Shortcode::Gallery(args) => {
for item in &mut args.items {
let alt = item.alt.clone();
resolve_asset_url(&mut item.src, &alt, graph, source_path, outgoing);
}
}
Shortcode::Grid(args) => {
for cell in &mut args.cells {
for nested in cell {
resolve_shortcode_image_urls(nested, graph, source_path, outgoing);
}
}
}
Shortcode::Subscribe(_) | Shortcode::Buttons(_) | Shortcode::Recent(_) | Shortcode::Apply(_) => {}
},
Block::Callout { children, .. } | Block::BlockQuote(children) => {
for nested in children {
resolve_shortcode_image_urls(nested, graph, source_path, outgoing);
}
}
Block::List { items, .. } => {
for item_blocks in items {
for nested in item_blocks {
resolve_shortcode_image_urls(nested, graph, source_path, outgoing);
}
}
}
Block::LinkCard { children, .. } => {
for nested in children {
resolve_shortcode_image_urls(nested, graph, source_path, outgoing);
}
}
Block::Heading { .. }
| Block::Paragraph(_)
| Block::Table { .. }
| Block::Figure { .. }
| Block::CodeBlock { .. }
| Block::ThematicBreak
| Block::Other(_) => {}
}
}
fn resolve_link_urls(
doc: &mut Document,
graph: &ContentGraph,
source_path: &str,
outgoing: &mut Vec<OutgoingLink>,
) {
walk_links_mut(doc, &mut |link_url, display_text, is_wikilink| {
let raw = match link_url {
Url::Unresolved(s) => s.clone(),
Url::Resolved(_) => return,
};
if let Some(rest) = raw.strip_prefix("mailto:") {
*link_url = Url::Resolved(ResolvedUrl::new(format!("mailto:{rest}"), UrlKind::Mailto));
return;
}
if let Some(rest) = raw.strip_prefix("tel:") {
*link_url = Url::Resolved(ResolvedUrl::new(format!("tel:{rest}"), UrlKind::Tel));
return;
}
if raw.starts_with('#') {
let href = if is_wikilink {
slug_wikilink_suffix(&raw)
} else {
raw
};
*link_url = Url::Resolved(ResolvedUrl::new(href, UrlKind::Anchor));
return;
}
if raw.starts_with("http://")
|| raw.starts_with("https://")
|| raw.starts_with("//")
|| raw.starts_with("data:")
{
*link_url = Url::Resolved(ResolvedUrl::new(raw, UrlKind::External));
return;
}
if raw.starts_with("moss-resolved:")
|| raw.starts_with("moss-newtab:")
|| raw.starts_with("wikilink:")
{
return;
}
if raw.starts_with('/') {
*link_url = Url::Resolved(ResolvedUrl::new(raw, UrlKind::Internal));
return;
}
let (path_part, suffix) = split_path_suffix(&raw);
match resolve_reference(path_part, graph, source_path) {
ResolvedRef::Found(resolved) => {
outgoing.push(OutgoingLink {
target_path: resolved.clone(),
display_text: display_text.to_string(),
link_type: LinkType::Standard,
});
let sentinel = match suffix {
Some(s) => {
let s = if is_wikilink {
slug_wikilink_suffix(s)
} else {
s.to_string()
};
format!("moss-resolved:{}{}", resolved, s)
}
None => format!("moss-resolved:{}", resolved),
};
*link_url = Url::Unresolved(sentinel);
}
ResolvedRef::Unresolved => {
*link_url = Url::Resolved(ResolvedUrl::new(raw, UrlKind::Internal));
}
}
});
}
fn split_path_suffix(url: &str) -> (&str, Option<&str>) {
let q = url.find('?');
let h = url.find('#');
let cut = match (q, h) {
(Some(a), Some(b)) => Some(a.min(b)),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
};
match cut {
#[allow(clippy::string_slice)]
Some(pos) => (&url[..pos], Some(&url[pos..])),
None => (url, None),
}
}
fn slug_wikilink_suffix(suffix: &str) -> String {
use crate::heading::anchor::obsidian_heading_anchor;
match suffix.find('#') {
None => suffix.to_string(), Some(h) => {
#[allow(clippy::string_slice)]
let (head, frag_with_hash) = (&suffix[..h], &suffix[h + 1..]);
let slugged = if let Some(block_id) = frag_with_hash.strip_prefix('^') {
block_id.to_string()
} else {
obsidian_heading_anchor(frag_with_hash)
};
format!("{head}#{slugged}")
}
}
}
pub fn classify_remaining_urls(doc: &mut Document) {
visit_urls_mut(doc, |url| {
if let Url::Unresolved(raw) = url {
let kind = classify_unresolved_kind(raw);
let raw_owned = std::mem::take(raw);
*url = Url::Resolved(ResolvedUrl::new(raw_owned, kind));
}
});
}
fn classify_unresolved_kind(raw: &str) -> UrlKind {
if raw.starts_with("mailto:") {
UrlKind::Mailto
} else if raw.starts_with("tel:") {
UrlKind::Tel
} else if raw.starts_with('#') {
UrlKind::Anchor
} else if raw.starts_with("http://")
|| raw.starts_with("https://")
|| raw.starts_with("//")
|| raw.starts_with("data:")
{
UrlKind::External
} else {
UrlKind::Internal
}
}
fn walk_inline_images_mut<F>(doc: &mut Document, f: &mut F)
where
F: FnMut(&mut Inline),
{
for block in &mut doc.blocks {
walk_images_in_block(block, f);
}
}
fn walk_images_in_block<F>(block: &mut Block, f: &mut F)
where
F: FnMut(&mut Inline),
{
match block {
Block::Heading { children, .. } | Block::Paragraph(children) => {
for inline in children {
walk_images_in_inline(inline, f);
}
}
Block::Callout { children, .. } | Block::BlockQuote(children) => {
for nested in children {
walk_images_in_block(nested, f);
}
}
Block::List { items, .. } => {
for item_blocks in items {
for nested in item_blocks {
walk_images_in_block(nested, f);
}
}
}
Block::Table { header, rows, .. } => {
for cell in header {
for inline in cell {
walk_images_in_inline(inline, f);
}
}
for row in rows {
for cell in row {
for inline in cell {
walk_images_in_inline(inline, f);
}
}
}
}
Block::Shortcode(sc) => {
walk_images_in_shortcode(sc, f);
}
Block::Figure { image, caption, .. } => {
walk_images_in_inline(image, f);
if let Some(cap) = caption {
for inline in cap {
walk_images_in_inline(inline, f);
}
}
}
Block::LinkCard { children, .. } => {
for nested in children {
walk_images_in_block(nested, f);
}
}
Block::CodeBlock { .. } | Block::ThematicBreak | Block::Other(_) => {}
}
}
fn walk_images_in_shortcode<F>(sc: &mut Shortcode, f: &mut F)
where
F: FnMut(&mut Inline),
{
match sc {
Shortcode::Subscribe(_) | Shortcode::Buttons(_) | Shortcode::Recent(_) | Shortcode::Apply(_) => {}
Shortcode::Gallery(args) => {
let _ = args;
}
Shortcode::Hero(args) => {
for block in &mut args.overlay {
walk_images_in_block(block, f);
}
}
Shortcode::Grid(args) => {
for cell_blocks in &mut args.cells {
for block in cell_blocks {
walk_images_in_block(block, f);
}
}
}
}
}
fn walk_images_in_inline<F>(inline: &mut Inline, f: &mut F)
where
F: FnMut(&mut Inline),
{
match inline {
Inline::Image { .. } => {
f(inline);
}
Inline::Link { children, .. } => {
for nested in children {
walk_images_in_inline(nested, f);
}
}
Inline::Emphasis(children) | Inline::Strong(children) => {
for nested in children {
walk_images_in_inline(nested, f);
}
}
Inline::Text(_) | Inline::Code(_) | Inline::LineBreak | Inline::Other(_) => {}
}
}
fn walk_links_mut<F>(doc: &mut Document, f: &mut F)
where
F: FnMut(&mut Url, &str, bool),
{
for block in &mut doc.blocks {
walk_links_in_block(block, f);
}
}
fn walk_links_in_block<F>(block: &mut Block, f: &mut F)
where
F: FnMut(&mut Url, &str, bool),
{
match block {
Block::Heading { children, .. } | Block::Paragraph(children) => {
for inline in children {
walk_links_in_inline(inline, f);
}
}
Block::Callout { children, .. } | Block::BlockQuote(children) => {
for nested in children {
walk_links_in_block(nested, f);
}
}
Block::List { items, .. } => {
for item_blocks in items {
for nested in item_blocks {
walk_links_in_block(nested, f);
}
}
}
Block::Table { header, rows, .. } => {
for cell in header {
for inline in cell {
walk_links_in_inline(inline, f);
}
}
for row in rows {
for cell in row {
for inline in cell {
walk_links_in_inline(inline, f);
}
}
}
}
Block::Shortcode(sc) => {
walk_links_in_shortcode(sc, f);
}
Block::Figure { caption, .. } => {
if let Some(cap) = caption {
for inline in cap {
walk_links_in_inline(inline, f);
}
}
}
Block::LinkCard { url, children } => {
let display = gather_text_blocks(children);
f(url, &display, false);
for nested in children {
walk_links_in_block(nested, f);
}
}
Block::CodeBlock { .. } | Block::ThematicBreak | Block::Other(_) => {}
}
}
fn walk_links_in_shortcode<F>(sc: &mut Shortcode, f: &mut F)
where
F: FnMut(&mut Url, &str, bool),
{
match sc {
Shortcode::Subscribe(_) | Shortcode::Recent(_) | Shortcode::Apply(_) => {}
Shortcode::Buttons(args) => {
for item in &mut args.items {
let text = item.text.clone();
f(&mut item.url, &text, false);
}
}
Shortcode::Gallery(_) => {
}
Shortcode::Hero(args) => {
for block in &mut args.overlay {
walk_links_in_block(block, f);
}
}
Shortcode::Grid(args) => {
for cell_blocks in &mut args.cells {
for block in cell_blocks {
walk_links_in_block(block, f);
}
}
}
}
}
fn walk_links_in_inline<F>(inline: &mut Inline, f: &mut F)
where
F: FnMut(&mut Url, &str, bool),
{
match inline {
Inline::Link {
url,
children,
is_wikilink,
..
} => {
let display = gather_text_inlines(children);
f(url, &display, *is_wikilink);
for nested in children {
walk_links_in_inline(nested, f);
}
}
Inline::Image { .. } => {
}
Inline::Emphasis(children) | Inline::Strong(children) => {
for nested in children {
walk_links_in_inline(nested, f);
}
}
Inline::Text(_) | Inline::Code(_) | Inline::LineBreak | Inline::Other(_) => {}
}
}
fn gather_text_inlines(inlines: &[Inline]) -> String {
let mut s = String::new();
for inline in inlines {
gather_text_inline(inline, &mut s);
}
s
}
fn gather_text_inline(inline: &Inline, out: &mut String) {
match inline {
Inline::Text(t) => out.push_str(t),
Inline::Code(c) => out.push_str(c),
Inline::Emphasis(children) | Inline::Strong(children) => {
for nested in children {
gather_text_inline(nested, out);
}
}
Inline::Link { children, .. } => {
for nested in children {
gather_text_inline(nested, out);
}
}
Inline::Image { alt, .. } => out.push_str(alt),
Inline::LineBreak => out.push('\n'),
Inline::Other(_) => {}
}
}
fn gather_text_blocks(blocks: &[Block]) -> String {
let mut s = String::new();
for block in blocks {
gather_text_block(block, &mut s);
}
s
}
fn gather_text_block(block: &Block, out: &mut String) {
match block {
Block::Heading { children, .. } | Block::Paragraph(children) => {
for inline in children {
gather_text_inline(inline, out);
}
}
Block::Figure { image, caption, .. } => {
if let Inline::Image { alt, .. } = image {
out.push_str(alt);
}
if let Some(cap) = caption {
for inline in cap {
gather_text_inline(inline, out);
}
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
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");
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");
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");
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");
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");
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");
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");
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");
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");
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);
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);
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);
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);
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);
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);
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);
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");
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");
let outgoing2 = resolve_urls(&mut doc, &graph, "index.md");
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");
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");
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 depth-0 asset path, got {href:?}"
);
assert!(
outgoing.iter().any(|o| o.target_path == "assets/hero.jpg"),
"expected OutgoingLink to assets/hero.jpg, got {outgoing:?}"
);
}
#[test]
fn hero_body_wikilink_resolves_with_relative_prefix_at_depth_1() {
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("assets/hero.jpg", "hero");
let graph = b.build();
let _ = resolve_urls(&mut doc, &graph, "articles/post.md");
let href = extract_hero_image_href(&doc).expect("hero image must be Resolved");
assert_eq!(
href, "../assets/hero.jpg",
"hero body-wikilink at source-depth 1 must resolve with `../` prefix, got {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");
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");
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");
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");
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");
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");
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 outgoing = Vec::new();
resolve_asset_url(&mut url, "", graph, source_path, &mut outgoing);
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_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"
);
}
}