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::{resolve_reference, ResolvedRef};
use crate::resolve::{Diagnostic, DiagnosticKind, 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)
}
}
#[derive(Debug, Default)]
pub struct UrlResolution {
pub outgoing: Vec<OutgoingLink>,
pub diagnostics: Vec<Diagnostic>,
}
pub fn resolve_urls(
doc: &mut Document,
graph: &ContentGraph,
source_path: &str,
) -> UrlResolution {
let mut found = UrlResolution::default();
resolve_image_urls(doc, graph, source_path, &mut found);
resolve_link_urls(doc, graph, source_path, &mut found);
found
}
fn resolve_image_urls(
doc: &mut Document,
graph: &ContentGraph,
source_path: &str,
found: &mut UrlResolution,
) {
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, found);
});
for block in &mut doc.blocks {
resolve_shortcode_image_urls(block, graph, source_path, found);
}
}
fn resolve_asset_url(
url: &mut Url,
alt: &str,
graph: &ContentGraph,
source_path: &str,
found: &mut UrlResolution,
) {
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;
}
match resolve_asset_ref(&raw, source_path, &GraphAssetIndex(graph)) {
AssetResolution::Resolved { root_rel, provenance: _ } => {
pin_asset_url(url, root_rel, alt, graph, found);
}
AssetResolution::Ambiguous { chosen, candidates: _ } => {
pin_asset_url(url, chosen, alt, graph, found);
}
AssetResolution::NotFound => {
found.diagnostics.push(Diagnostic {
message: format!("Unresolved asset reference: {raw}"),
source_path: source_path.to_string(),
reference: raw.clone(),
kind: DiagnosticKind::MissingAsset,
});
*url = Url::Resolved(ResolvedUrl::new(raw, UrlKind::Asset));
}
}
}
fn pin_asset_url(
url: &mut Url,
root_rel: String,
alt: &str,
graph: &ContentGraph,
found: &mut UrlResolution,
) {
let pinned = graph.pinned_url(&root_rel);
found.outgoing.push(OutgoingLink {
target_path: root_rel,
display_text: alt.to_string(),
link_type: LinkType::Standard,
});
*url = Url::Resolved(ResolvedUrl::new(pinned, UrlKind::Asset));
}
fn resolve_shortcode_image_urls(
block: &mut Block,
graph: &ContentGraph,
source_path: &str,
found: &mut UrlResolution,
) {
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, found);
}
for image_url in &mut args.extra_images {
resolve_asset_url(image_url, "", graph, source_path, found);
}
for nested in &mut args.overlay {
resolve_shortcode_image_urls(nested, graph, source_path, found);
}
}
Shortcode::Gallery(args) => {
for item in &mut args.items {
let alt = item.alt.clone();
resolve_asset_url(&mut item.src, &alt, graph, source_path, found);
}
}
Shortcode::Grid(args) => {
for cell in &mut args.cells {
for nested in cell {
resolve_shortcode_image_urls(nested, graph, source_path, found);
}
}
}
Shortcode::Subscribe(_) | Shortcode::Buttons(_) | Shortcode::Recent(_) | Shortcode::Apply(_) => {}
},
Block::Callout { children, .. }
| Block::BlockQuote(children)
| Block::FootnoteDefinition { children, .. } => {
for nested in children {
resolve_shortcode_image_urls(nested, graph, source_path, found);
}
}
Block::List { items, .. } => {
for item_blocks in items {
for nested in item_blocks {
resolve_shortcode_image_urls(nested, graph, source_path, found);
}
}
}
Block::LinkCard { children, .. } => {
for nested in children {
resolve_shortcode_image_urls(nested, graph, source_path, found);
}
}
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,
found: &mut UrlResolution,
) {
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) => {
found.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 => {
found.diagnostics.push(Diagnostic {
message: format!("Unresolved link target: {raw}"),
source_path: source_path.to_string(),
reference: raw.clone(),
kind: DiagnosticKind::Other,
});
*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),
}
}
pub 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)
| Block::FootnoteDefinition { 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) | Inline::Strikethrough(children) => {
for nested in children {
walk_images_in_inline(nested, f);
}
}
Inline::Text(_)
| Inline::Code(_)
| Inline::LineBreak
| Inline::FootnoteRef(_)
| Inline::TaskMarker(_)
| 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)
| Block::FootnoteDefinition { 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) | Inline::Strikethrough(children) => {
for nested in children {
walk_links_in_inline(nested, f);
}
}
Inline::Text(_)
| Inline::Code(_)
| Inline::LineBreak
| Inline::FootnoteRef(_)
| Inline::TaskMarker(_)
| 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) | Inline::Strikethrough(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::FootnoteRef(_) | Inline::TaskMarker(_) | 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)]
#[path = "resolve_urls_tests.rs"]
mod tests;