use crate::asset_snapshot::AssetSnapshot;
use crate::content_graph::ContentGraph;
use crate::path_ext::path_extension;
use crate::media::{
extract_width_from_alias, parse_media_attrs, AlignSide, Fit, MediaAttrs, Position,
};
use super::embed_renderer::{
lookup_renderer, EmbedRenderer, ParsedEmbed, RenderedEmbed, Sizing, IMAGE_EXTENSIONS,
};
use super::fuzzy_path::{resolve_reference, ResolvedRef};
use super::title_params::TitleParams;
use super::{Diagnostic, DiagnosticKind, LinkType, OutgoingLink};
#[derive(Debug, Clone, PartialEq)]
pub enum PotholeContent {
Empty,
WidthToken {
width: &'static str,
rest_alias: String,
},
Params(TitleParams),
Alias(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct SplitDestUrl<'a> {
pub file: &'a str,
pub section: Option<&'a str>,
pub query: Option<&'a str>,
}
#[derive(Debug, Clone)]
pub struct WikilinkEmit {
pub output: EmitKind,
pub outgoing_link: Option<OutgoingLink>,
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum EmitKind {
Inline(String),
Html(String),
Deferred(String),
Link(String),
Block(Box<crate::ast::node::Block>),
}
pub fn parse_pothole_params(text: &str) -> PotholeContent {
let trimmed = text.trim();
if trimmed.is_empty() {
return PotholeContent::Empty;
}
let (width, rest_alias) = extract_width_from_alias(trimmed);
if let Some(w) = width {
return PotholeContent::WidthToken {
width: w,
rest_alias,
};
}
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
if !tokens.is_empty() && tokens.iter().all(|t| is_kv_token(t)) {
let mut params = TitleParams::default();
for token in &tokens {
if let Some((k, v)) = token.split_once('=') {
params.insert(k, v);
}
}
return PotholeContent::Params(params);
}
PotholeContent::Alias(text.to_string())
}
fn is_kv_token(token: &str) -> bool {
let Some((key, _value)) = token.split_once('=') else {
return false;
};
if key.is_empty() {
return false;
}
let mut chars = key.chars();
let Some(first) = chars.next() else {
return false; };
if !first.is_ascii_lowercase() {
return false;
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
}
pub fn split_dest_url(dest_url: &str) -> SplitDestUrl<'_> {
let hash_pos = dest_url.find('#');
let query_pos = dest_url.find('?');
#[allow(clippy::string_slice)]
match (hash_pos, query_pos) {
(None, None) => SplitDestUrl {
file: dest_url,
section: None,
query: None,
},
(Some(h), None) => SplitDestUrl {
file: &dest_url[..h],
section: Some(&dest_url[h + 1..]),
query: None,
},
(None, Some(q)) => SplitDestUrl {
file: &dest_url[..q],
section: None,
query: Some(&dest_url[q + 1..]),
},
(Some(h), Some(q)) if h < q => SplitDestUrl {
file: &dest_url[..h],
section: Some(&dest_url[h + 1..q]),
query: Some(&dest_url[q + 1..]),
},
(Some(h), Some(q)) => SplitDestUrl {
file: &dest_url[..q],
section: Some(&dest_url[h + 1..]),
query: Some(&dest_url[q + 1..h]),
},
}
}
fn build_anchor(section: Option<&str>) -> String {
use crate::heading::anchor::obsidian_heading_anchor;
match section {
None => String::new(),
Some("") => String::new(),
Some(s) => {
if let Some(block_id) = s.strip_prefix('^') {
format!("#{}", block_id)
} else {
format!("#{}", obsidian_heading_anchor(s))
}
}
}
}
pub fn dispatch_wikilink_embed(
dest_url: &str,
pothole: Option<&str>,
is_embed: bool,
graph: &ContentGraph,
from_path: &str,
assets: &AssetSnapshot,
) -> WikilinkEmit {
dispatch_wikilink_embed_with_lookup(
dest_url,
pothole,
is_embed,
graph,
from_path,
assets,
&|ext| lookup_renderer(ext).map(|r| r as &dyn EmbedRenderer),
)
}
pub fn dispatch_wikilink_embed_with_registry(
dest_url: &str,
pothole: Option<&str>,
is_embed: bool,
graph: &ContentGraph,
from_path: &str,
assets: &AssetSnapshot,
registry: &super::registry::RendererRegistry,
) -> WikilinkEmit {
dispatch_wikilink_embed_with_lookup(
dest_url,
pothole,
is_embed,
graph,
from_path,
assets,
&|ext| registry.lookup(ext).map(|r| r as &dyn EmbedRenderer),
)
}
fn dispatch_wikilink_embed_with_lookup(
dest_url: &str,
pothole: Option<&str>,
is_embed: bool,
graph: &ContentGraph,
from_path: &str,
assets: &AssetSnapshot,
lookup: &dyn Fn(&str) -> Option<&dyn EmbedRenderer>,
) -> WikilinkEmit {
let split = split_dest_url(dest_url);
let pothole_content = match pothole {
None => PotholeContent::Empty,
Some(s) => parse_pothole_params(s),
};
if is_embed {
dispatch_embed_form(&split, pothole_content, graph, from_path, assets, lookup)
} else {
dispatch_wikilink_form(&split, pothole_content, graph, from_path)
}
}
fn reassemble_url(split: &SplitDestUrl<'_>) -> String {
let mut url = split.file.to_string();
if let Some(q) = split.query {
url.push('?');
url.push_str(q);
}
if let Some(s) = split.section {
url.push('#');
url.push_str(s);
}
url
}
fn dispatch_embed_form(
split: &SplitDestUrl<'_>,
pothole: PotholeContent,
graph: &ContentGraph,
from_path: &str,
assets: &AssetSnapshot,
lookup: &dyn Fn(&str) -> Option<&dyn EmbedRenderer>,
) -> WikilinkEmit {
let mut diagnostics: Vec<Diagnostic> = Vec::new();
if split.file.starts_with("http://") || split.file.starts_with("https://") {
let full_url = reassemble_url(split);
let html = crate::render::url_embed::synthesize_url_embed_html(
&full_url,
&pothole,
assets,
);
return WikilinkEmit {
output: EmitKind::Html(html),
outgoing_link: None,
diagnostics: vec![],
};
}
if !split.file.is_empty() && split.file.ends_with('/') {
let pothole_raw = match &pothole {
PotholeContent::Empty => String::new(),
PotholeContent::WidthToken { rest_alias, .. } => rest_alias.clone(),
PotholeContent::Params(_) => String::new(),
PotholeContent::Alias(s) => s.clone(),
};
let params = super::embed_renderer::folder_list::parse_params(&pothole_raw);
let marker =
super::embed_renderer::folder_list::emit_marker(split.file, from_path, ¶ms);
return WikilinkEmit {
output: EmitKind::Html(marker),
outgoing_link: Some(OutgoingLink {
target_path: split.file.to_string(),
display_text: split.file.to_string(),
link_type: LinkType::Embed,
}),
diagnostics,
};
}
let resolved = if split.file.is_empty() {
ResolvedRef::Found(from_path.to_string())
} else {
resolve_reference(split.file, graph, from_path)
};
let (alias_owned, width): (Option<String>, Option<&'static str>) = match &pothole {
PotholeContent::Empty => (None, None),
PotholeContent::WidthToken { width, rest_alias } => (
if rest_alias.is_empty() {
None
} else {
Some(rest_alias.clone())
},
Some(*width),
),
PotholeContent::Params(_) => (None, None),
PotholeContent::Alias(s) => (Some(s.clone()), None),
};
match resolved {
ResolvedRef::Found(target_path) => {
let outgoing = OutgoingLink {
target_path: target_path.clone(),
display_text: split.file.to_string(),
link_type: LinkType::Embed,
};
let pinned_url = graph.pinned_url(&target_path);
let parsed = ParsedEmbed {
resolved_path: &target_path,
from_path,
pinned_url: &pinned_url,
query: split.query,
section: split.section,
alias: alias_owned.as_deref(),
width,
attrs: None,
};
let ext = path_extension(&target_path);
let url = pinned_url.clone();
if let Some(synth_kind) = ext.as_deref().and_then(synth_kind_for_ext) {
let params = build_synth_params(synth_kind, &parsed, &pothole);
let html = match synth_kind {
SynthKind::Video => {
crate::render::video::synthesize_video_html(¶ms, &url, assets)
}
SynthKind::Pdf => {
crate::render::pdf::synthesize_pdf_html(¶ms, &url, assets)
}
SynthKind::Audio => {
crate::render::audio::synthesize_audio_html(¶ms, &url, assets)
}
SynthKind::Iframe => {
crate::render::iframe::synthesize_iframe_html(¶ms, &url, assets)
}
SynthKind::Model => {
crate::render::model::synthesize_model_html(¶ms, &url, assets)
}
};
return WikilinkEmit {
output: EmitKind::Html(html),
outgoing_link: Some(outgoing),
diagnostics,
};
}
if matches!(ext.as_deref(), Some(e) if IMAGE_EXTENSIONS.iter().any(|x| *x == e)) {
let media = build_image_media_attrs(&pothole, parsed.attrs.as_ref());
let (alias_no_width, pct_width): (Option<String>, Option<String>) =
match parsed.alias {
Some(a) => {
let (rest, w) = crate::media::split_alt_width(a);
(Some(rest), w)
}
None => (None, None),
};
let alias_class =
crate::media::classify_image_alias(alias_no_width.as_deref());
let alt = alias_class.caption.clone().unwrap_or_default();
let caption: Option<Vec<crate::ast::node::Inline>> = alias_class
.caption
.map(|c| vec![crate::ast::node::Inline::Text(c)]);
let align = media.align.map(|side| side.css_class().to_string());
let img_style = media.to_inline_style();
let figure_width: Option<String> = width
.map(|w| w.to_string())
.or_else(|| {
alias_class.display_keywords.as_deref().and_then(|kw| {
kw.split_whitespace()
.find_map(crate::media::match_width_token)
.map(|w| w.to_string())
})
})
.or(pct_width);
let figure = crate::ast::node::Block::Figure {
image: crate::ast::node::Inline::Image {
src: crate::ast::url::Url::resolved(
url.clone(),
crate::ast::url::UrlKind::Asset,
),
alt,
title: None,
is_wikilink: true,
wikilink_pothole: None,
},
caption,
width: figure_width,
align,
class_names: media.class_names,
img_style,
};
return WikilinkEmit {
output: EmitKind::Block(Box::new(figure)),
outgoing_link: Some(outgoing),
diagnostics,
};
}
let emit = match ext.as_deref().and_then(lookup) {
Some(r) => match r.render(&parsed) {
RenderedEmbed::Inline(s) => EmitKind::Inline(s),
RenderedEmbed::Html(s) => EmitKind::Html(s),
RenderedEmbed::Deferred { marker } => EmitKind::Deferred(marker),
},
None => {
EmitKind::Inline(format!("[{}]({})", split.file, url))
}
};
WikilinkEmit {
output: emit,
outgoing_link: Some(outgoing),
diagnostics,
}
}
ResolvedRef::Unresolved => {
diagnostics.push(Diagnostic {
message: format!("Unresolved embed: ![[{}]]", split.file),
source_path: from_path.to_string(),
reference: split.file.to_string(),
kind: DiagnosticKind::Other,
});
WikilinkEmit {
output: EmitKind::Inline(format!(
"[{}](moss-unresolved:{})",
split.file, split.file
)),
outgoing_link: Some(OutgoingLink {
target_path: split.file.to_string(),
display_text: split.file.to_string(),
link_type: LinkType::Embed,
}),
diagnostics,
}
}
}
}
fn dispatch_wikilink_form(
split: &SplitDestUrl<'_>,
pothole: PotholeContent,
graph: &ContentGraph,
from_path: &str,
) -> WikilinkEmit {
let mut diagnostics = Vec::new();
let alias_display = match &pothole {
PotholeContent::Alias(s) => Some(s.clone()),
PotholeContent::WidthToken { rest_alias, .. } if !rest_alias.is_empty() => {
Some(rest_alias.clone())
}
_ => None,
};
let display_text = if let Some(a) = alias_display {
a
} else if let Some(sec) = split.section {
if split.file.is_empty() {
sec.to_string()
} else {
format!("{} > {}", split.file, sec)
}
} else {
split.file.to_string()
};
let resolved = if split.file.is_empty() {
ResolvedRef::Found(from_path.to_string())
} else {
resolve_reference(split.file, graph, from_path)
};
match resolved {
ResolvedRef::Found(target_path) => {
let outgoing = OutgoingLink {
target_path: target_path.clone(),
display_text: display_text.clone(),
link_type: LinkType::Wikilink,
};
let anchor = build_anchor(split.section);
let link = if split.file.is_empty() {
format!("[{}]({})", display_text, anchor)
} else {
format!(
"[{}](moss-resolved:{}{})",
display_text, target_path, anchor
)
};
WikilinkEmit {
output: EmitKind::Link(link),
outgoing_link: Some(outgoing),
diagnostics,
}
}
ResolvedRef::Unresolved => {
diagnostics.push(Diagnostic {
message: format!("Unresolved wikilink: [[{}]]", split.file),
source_path: from_path.to_string(),
reference: split.file.to_string(),
kind: DiagnosticKind::Other,
});
WikilinkEmit {
output: EmitKind::Link(format!(
"[{}](moss-unresolved:{})",
display_text, split.file
)),
outgoing_link: Some(OutgoingLink {
target_path: split.file.to_string(),
display_text,
link_type: LinkType::Wikilink,
}),
diagnostics,
}
}
}
}
fn build_image_media_attrs(
pothole: &PotholeContent,
_attrs: Option<&crate::ast::attrs::AttrBlock>,
) -> MediaAttrs {
let mut media = MediaAttrs::default();
let alias_text = match pothole {
PotholeContent::Alias(s) => Some(s.as_str()),
PotholeContent::WidthToken { rest_alias, .. } if !rest_alias.is_empty() => {
Some(rest_alias.as_str())
}
_ => None,
};
if let Some(text) = alias_text {
let cleaned: Vec<&str> = text
.split_whitespace()
.filter(|t| crate::media::match_width_token(t).is_none())
.collect();
let cleaned_str = cleaned.join(" ");
if !cleaned_str.is_empty() && crate::media::is_all_display_keywords(&cleaned_str) {
let parsed = parse_media_attrs(&cleaned_str);
media.fit = parsed.fit;
media.position = parsed.position;
media.align = parsed.align;
media.class_names.extend(parsed.class_names);
for (k, v) in parsed.extra_attrs {
media.extra_attrs.insert(k, v);
}
}
}
if let PotholeContent::Params(params) = pothole {
for (k, v) in ¶ms.params {
match k.as_str() {
"fit" => {
if let Some(fit) = Fit::from_keyword(v) {
media.fit = Some(fit);
}
}
"position" => {
if let Some(pos) = Position::from_keyword(v) {
media.position = Some(pos);
}
}
"align" => {
if let Some(side) = AlignSide::from_keyword(v) {
media.align = Some(side);
}
}
"width" | "data-width" => {}
"classes" => {
for c in v.split_whitespace() {
if !media.class_names.iter().any(|x| x == c) {
media.class_names.push(c.to_string());
}
}
}
"style" => {}
_ => {
media.extra_attrs.insert(k.clone(), v.clone());
}
}
}
}
media
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SynthKind {
Video,
Pdf,
Audio,
Iframe,
Model,
}
fn synth_kind_for_ext(ext: &str) -> Option<SynthKind> {
use crate::resolve::ext_kind::{reference_kind_for_ext, ExtKind};
match reference_kind_for_ext(ext) {
ExtKind::Video => Some(SynthKind::Video),
ExtKind::Pdf => Some(SynthKind::Pdf),
ExtKind::Audio => Some(SynthKind::Audio),
ExtKind::Iframe => Some(SynthKind::Iframe),
ExtKind::Model => Some(SynthKind::Model),
ExtKind::Image | ExtKind::Transclusion | ExtKind::Notebook | ExtKind::Table | ExtKind::Other => None,
}
}
fn build_synth_params(
kind: SynthKind,
embed: &ParsedEmbed<'_>,
pothole: &PotholeContent,
) -> TitleParams {
let mut params = TitleParams::default();
if let Some(w) = embed.width {
params.insert("data-width", w);
}
if matches!(kind, SynthKind::Iframe | SynthKind::Pdf) {
if let Some(q) = embed.query {
params.insert("query", q);
}
if let Some(f) = embed.section {
params.insert("fragment", f);
}
}
if let Some(alias) = embed.alias {
match kind {
SynthKind::Video => {
let mut tokens: Vec<&str> = alias.split_whitespace().collect();
let loop_pos = tokens
.iter()
.position(|t| t.eq_ignore_ascii_case("loop"));
if let Some(pos) = loop_pos {
tokens.remove(pos);
params.insert("loop", "1");
}
let remainder = tokens.join(" ");
if !remainder.is_empty() {
match Sizing::parse(&remainder) {
Some(Sizing::Width(w)) => {
params.insert("width", w.to_css());
}
Some(Sizing::Box(w, h)) => {
params.insert("width", w.to_css());
params.insert("height", h.to_css());
}
None => {}
}
}
}
SynthKind::Pdf | SynthKind::Model => match Sizing::parse(alias) {
Some(Sizing::Width(w)) => {
params.insert("width", w.to_css());
}
Some(Sizing::Box(w, h)) => {
params.insert("width", w.to_css());
params.insert("height", h.to_css());
}
None => {}
},
SynthKind::Iframe => match Sizing::parse(alias) {
Some(Sizing::Width(w)) => {
params.insert("width", w.to_css());
}
Some(Sizing::Box(w, h)) => {
params.insert("width", w.to_css());
params.insert("height", h.to_css());
}
None => {
params.insert("title", alias);
}
},
SynthKind::Audio => {
}
}
}
if let PotholeContent::Params(p) = pothole {
for (k, v) in &p.params {
params.insert(k.clone(), v.clone());
}
}
params
}
#[cfg(test)]
#[path = "wikilink_dispatch_tests.rs"]
mod tests;