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::{relative_asset_path, resolve_reference, ResolvedRef};
use super::title_params::TitleParams;
use super::{Diagnostic, 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 parsed = ParsedEmbed {
resolved_path: &target_path,
from_path,
query: split.query,
section: split.section,
alias: alias_owned.as_deref(),
width,
attrs: None,
};
let ext = path_extension(&target_path);
let url = relative_asset_path(from_path, &target_path);
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(),
});
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(),
});
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 | 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)]
mod tests {
use super::*;
use crate::content_graph::{ContentGraph, ContentGraphBuilder};
#[test]
fn pothole_empty_string_is_empty() {
assert_eq!(parse_pothole_params(""), PotholeContent::Empty);
assert_eq!(parse_pothole_params(" "), PotholeContent::Empty);
}
#[test]
fn pothole_pure_digit_is_alias_not_width_token() {
match parse_pothole_params("400") {
PotholeContent::Alias(s) => assert_eq!(s, "400"),
other => panic!("expected Alias, got {:?}", other),
}
}
#[test]
fn pothole_plain_alias() {
match parse_pothole_params("My alias") {
PotholeContent::Alias(s) => assert_eq!(s, "My alias"),
other => panic!("expected Alias, got {:?}", other),
}
}
#[test]
fn pothole_kv_pair_is_params() {
match parse_pothole_params("width=400 align=left") {
PotholeContent::Params(p) => {
assert_eq!(p.get("width"), Some("400"));
assert_eq!(p.get("align"), Some("left"));
}
other => panic!("expected Params, got {:?}", other),
}
}
#[test]
fn pothole_single_kv_is_params() {
match parse_pothole_params("width=400") {
PotholeContent::Params(p) => {
assert_eq!(p.get("width"), Some("400"));
}
other => panic!("expected Params, got {:?}", other),
}
}
#[test]
fn pothole_bare_alt_blocks_kv_parse() {
match parse_pothole_params("alt text=cover") {
PotholeContent::Alias(s) => assert_eq!(s, "alt text=cover"),
other => panic!("expected Alias, got {:?}", other),
}
}
#[test]
fn pothole_uppercase_key_blocks_kv_parse() {
match parse_pothole_params("My Notes=Important") {
PotholeContent::Alias(s) => assert_eq!(s, "My Notes=Important"),
other => panic!("expected Alias, got {:?}", other),
}
}
#[test]
fn pothole_no_equals_is_alias() {
match parse_pothole_params("width 400") {
PotholeContent::Alias(s) => assert_eq!(s, "width 400"),
other => panic!("expected Alias, got {:?}", other),
}
}
#[test]
fn pothole_partial_kv_falls_through_to_alias() {
match parse_pothole_params("width=400 caption text") {
PotholeContent::Alias(s) => assert_eq!(s, "width=400 caption text"),
other => panic!("expected Alias, got {:?}", other),
}
}
#[test]
fn pothole_kv_with_hyphenated_key() {
match parse_pothole_params("aria-label=primary data_id=42") {
PotholeContent::Params(p) => {
assert_eq!(p.get("aria-label"), Some("primary"));
assert_eq!(p.get("data_id"), Some("42"));
}
other => panic!("expected Params, got {:?}", other),
}
}
#[test]
fn pothole_obsidian_width_keyword() {
match parse_pothole_params("wide") {
PotholeContent::WidthToken { width, rest_alias } => {
assert_eq!(width, "wide");
assert!(rest_alias.is_empty());
}
other => panic!("expected WidthToken, got {:?}", other),
}
}
#[test]
fn split_dest_url_plain_file() {
let s = split_dest_url("notes");
assert_eq!(s.file, "notes");
assert_eq!(s.section, None);
assert_eq!(s.query, None);
}
#[test]
fn split_dest_url_with_anchor() {
let s = split_dest_url("notes#section");
assert_eq!(s.file, "notes");
assert_eq!(s.section, Some("section"));
assert_eq!(s.query, None);
}
#[test]
fn split_dest_url_with_query() {
let s = split_dest_url("page.html?x=1");
assert_eq!(s.file, "page.html");
assert_eq!(s.query, Some("x=1"));
}
#[test]
fn split_dest_url_anchor_then_query() {
let s = split_dest_url("page.html#frag?x=1");
assert_eq!(s.file, "page.html");
assert_eq!(s.section, Some("frag"));
assert_eq!(s.query, Some("x=1"));
}
#[test]
fn split_dest_url_query_then_anchor() {
let s = split_dest_url("page.html?x=1#frag");
assert_eq!(s.file, "page.html");
assert_eq!(s.query, Some("x=1"));
assert_eq!(s.section, Some("frag"));
}
fn build_graph(paths: &[&str]) -> ContentGraph {
let mut b = ContentGraphBuilder::new();
for p in paths {
let slug = std::path::Path::new(p)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(p);
b.add_file(p, slug);
}
b.build()
}
fn empty_snapshot() -> AssetSnapshot {
AssetSnapshot::new()
}
#[test]
fn dispatch_bare_wikilink_is_link() {
let graph = build_graph(&["notes.md"]);
let emit = dispatch_wikilink_embed(
"notes",
None,
false,
&graph,
"index.md",
&empty_snapshot(),
);
match emit.output {
EmitKind::Link(link) => {
assert!(link.contains("notes"));
assert!(link.contains("moss-resolved:"));
}
other => panic!("expected Link, got {:?}", other),
}
assert!(emit.outgoing_link.is_some());
assert!(emit.diagnostics.is_empty());
}
#[test]
fn dispatch_wikilink_with_alias_uses_alias_text() {
let graph = build_graph(&["notes.md"]);
let emit = dispatch_wikilink_embed(
"notes",
Some("My alias"),
false,
&graph,
"index.md",
&empty_snapshot(),
);
match emit.output {
EmitKind::Link(link) => {
assert!(link.starts_with("[My alias]"));
}
other => panic!("expected Link, got {:?}", other),
}
}
#[test]
fn dispatch_unresolved_wikilink_emits_diagnostic() {
let graph = build_graph(&[]);
let emit = dispatch_wikilink_embed(
"missing",
None,
false,
&graph,
"index.md",
&empty_snapshot(),
);
assert_eq!(emit.diagnostics.len(), 1);
match emit.output {
EmitKind::Link(link) => assert!(link.contains("moss-unresolved:")),
other => panic!("expected Link, got {:?}", other),
}
}
#[test]
fn dispatch_anchor_wikilink_preserves_section_in_href() {
let graph = build_graph(&["notes.md"]);
let emit = dispatch_wikilink_embed(
"notes#section",
None,
false,
&graph,
"index.md",
&empty_snapshot(),
);
match emit.output {
EmitKind::Link(link) => {
assert!(link.contains("moss-resolved:"));
assert!(link.contains("#section"), "got: {}", link);
}
other => panic!("expected Link, got {:?}", other),
}
}
#[test]
fn build_anchor_slugs_section_fragment() {
let graph = build_graph(&["notes.md"]);
let emit = dispatch_wikilink_embed(
"notes#My Heading",
None,
false,
&graph,
"index.md",
&empty_snapshot(),
);
match emit.output {
EmitKind::Link(link) => {
assert!(link.contains("#my-heading"), "got: {}", link);
assert!(link.contains("moss-resolved:"), "got: {}", link);
}
other => panic!("expected Link, got {:?}", other),
}
}
#[test]
fn build_anchor_same_page_emits_bare_anchor() {
let graph = build_graph(&["notes.md"]);
let emit = dispatch_wikilink_embed(
"#My Heading",
None,
false,
&graph,
"notes.md",
&empty_snapshot(),
);
match emit.output {
EmitKind::Link(link) => {
assert!(link.contains("(#my-heading)"), "got: {}", link);
assert!(!link.contains("moss-resolved:"), "got: {}", link);
}
other => panic!("expected Link, got {:?}", other),
}
}
#[test]
fn build_anchor_block_ref_is_not_slugged() {
let graph = build_graph(&["notes.md"]);
let emit = dispatch_wikilink_embed(
"notes#^Block Id",
None,
false,
&graph,
"index.md",
&empty_snapshot(),
);
match emit.output {
EmitKind::Link(link) => {
assert!(link.contains("#Block Id"), "expected raw block-ref, got: {}", link);
assert!(!link.contains("#block-id"), "block-ref was slugged: {}", link);
}
other => panic!("expected Link, got {:?}", other),
}
}
#[test]
fn dispatch_video_extension_routes_to_synth() {
let graph = build_graph(&["clip.mp4"]);
let emit = dispatch_wikilink_embed(
"clip.mp4",
None,
true,
&graph,
"index.md",
&empty_snapshot(),
);
match emit.output {
EmitKind::Html(s) => {
assert!(s.contains("<video"), "expected <video>, got: {}", s);
assert!(s.contains(r#"src="clip.mp4""#), "expected src=, got: {}", s);
assert!(s.contains("moss-embed-video"), "expected class, got: {}", s);
}
other => panic!("expected Html, got: {:?}", other),
}
}
#[test]
fn dispatch_pdf_extension_routes_to_synth() {
let graph = build_graph(&["report.pdf"]);
let emit = dispatch_wikilink_embed(
"report.pdf",
None,
true,
&graph,
"index.md",
&empty_snapshot(),
);
match emit.output {
EmitKind::Html(s) => {
assert!(s.contains("<object"), "expected <object>, got: {}", s);
assert!(
s.contains(r#"data="report.pdf""#),
"expected data=, got: {}",
s
);
assert!(
s.contains(r#"type="application/pdf""#),
"expected type=, got: {}",
s
);
}
other => panic!("expected Html, got: {:?}", other),
}
}
#[test]
fn dispatch_audio_extension_routes_to_synth() {
let graph = build_graph(&["song.mp3"]);
let emit = dispatch_wikilink_embed(
"song.mp3",
None,
true,
&graph,
"index.md",
&empty_snapshot(),
);
match emit.output {
EmitKind::Html(s) => {
assert!(s.contains("<audio"), "expected <audio>, got: {}", s);
assert!(s.contains(r#"src="song.mp3""#), "expected src=, got: {}", s);
assert!(
s.contains(r#"type="audio/mpeg""#),
"expected MIME, got: {}",
s
);
}
other => panic!("expected Html, got: {:?}", other),
}
}
#[test]
fn dispatch_iframe_extension_routes_to_synth() {
let graph = build_graph(&["widget.html"]);
let emit = dispatch_wikilink_embed(
"widget.html",
None,
true,
&graph,
"index.md",
&empty_snapshot(),
);
match emit.output {
EmitKind::Html(s) => {
assert!(s.contains("<iframe"), "expected <iframe>, got: {}", s);
assert!(
s.contains(r#"src="widget.html""#),
"expected src=, got: {}",
s
);
}
other => panic!("expected Html, got: {:?}", other),
}
}
#[test]
fn dispatch_model_extension_routes_to_synth() {
let graph = build_graph(&["scene.glb"]);
let emit = dispatch_wikilink_embed(
"scene.glb",
None,
true,
&graph,
"index.md",
&empty_snapshot(),
);
match emit.output {
EmitKind::Html(s) => {
assert!(
s.contains("<model-viewer"),
"expected <model-viewer>, got: {}",
s
);
assert!(
s.contains(r#"src="scene.glb""#),
"expected src=, got: {}",
s
);
}
other => panic!("expected Html, got: {:?}", other),
}
}
#[test]
fn dispatch_iframe_alias_carries_title() {
let graph = build_graph(&["widget.html"]);
let emit = dispatch_wikilink_embed(
"widget.html",
Some("Embedded Widget"),
true,
&graph,
"index.md",
&empty_snapshot(),
);
match emit.output {
EmitKind::Html(s) => {
assert!(s.contains(r#"title="Embedded Widget""#), "got: {}", s);
}
other => panic!("expected Html, got: {:?}", other),
}
}
#[test]
fn dispatch_video_sizing_alias_propagates_dims() {
let graph = build_graph(&["clip.mp4"]);
let emit = dispatch_wikilink_embed(
"clip.mp4",
Some("640x360"),
true,
&graph,
"index.md",
&empty_snapshot(),
);
match emit.output {
EmitKind::Html(s) => {
assert!(s.contains(r#"width="640px""#), "got: {}", s);
assert!(s.contains(r#"height="360px""#), "got: {}", s);
}
other => panic!("expected Html, got: {:?}", other),
}
}
#[test]
fn dispatch_only_fires_for_wikilink_caller() {
}
fn figure_of(emit: &WikilinkEmit) -> &crate::ast::node::Block {
match &emit.output {
EmitKind::Block(b) => b.as_ref(),
other => panic!("expected EmitKind::Block(Figure), got {other:?}"),
}
}
fn render_figure(emit: &WikilinkEmit) -> String {
let block = match emit.output.clone() {
EmitKind::Block(b) => *b,
other => panic!("expected EmitKind::Block(Figure), got {other:?}"),
};
let doc = crate::ast::Document::from_blocks(vec![block]);
crate::ast::render_document(&doc, &crate::ast::DefaultHooks::new())
}
fn dispatch_img(alias: Option<&str>) -> WikilinkEmit {
let graph = build_graph(&["photo.jpg", "hero.jpg"]);
dispatch_wikilink_embed("photo.jpg", alias, true, &graph, "index.md", &empty_snapshot())
}
#[test]
fn dispatch_image_plain_emits_figure_block() {
use crate::ast::node::{Block, Inline};
let emit = dispatch_img(None);
match figure_of(&emit) {
Block::Figure { image, caption, width, align, class_names, img_style } => {
assert!(caption.is_none(), "plain embed: no caption");
assert!(width.is_none());
assert!(align.is_none());
assert!(class_names.is_empty());
assert!(img_style.is_none());
match image {
Inline::Image { src, alt, is_wikilink, .. } => {
assert!(src.is_resolved());
assert_eq!(alt, "");
assert!(*is_wikilink);
}
other => panic!("expected Image, got {other:?}"),
}
}
other => panic!("expected Figure, got {other:?}"),
}
}
#[test]
fn dispatch_image_caption_text_sets_alt_and_figcaption() {
use crate::ast::node::{Block, Inline};
let emit = dispatch_img(Some("My caption"));
match figure_of(&emit) {
Block::Figure { image, caption, .. } => {
let cap = caption.as_ref().expect("caption present");
assert_eq!(cap.len(), 1);
match &cap[0] {
Inline::Text(t) => assert_eq!(t, "My caption"),
other => panic!("expected caption Text, got {other:?}"),
}
match image {
Inline::Image { alt, .. } => assert_eq!(alt, "My caption"),
other => panic!("expected Image, got {other:?}"),
}
}
other => panic!("expected Figure, got {other:?}"),
}
let html = render_figure(&emit);
assert!(html.contains(r#"alt="My caption""#), "got: {html}");
assert!(html.contains("<figcaption>My caption</figcaption>"), "got: {html}");
}
#[test]
fn dispatch_image_width_token_preserved_as_data_width() {
use crate::ast::node::Block;
let emit = dispatch_img(Some("wide"));
match figure_of(&emit) {
Block::Figure { width, caption, .. } => {
assert_eq!(width.as_deref(), Some("wide"));
assert!(caption.is_none(), "width token is not a caption");
}
other => panic!("expected Figure, got {other:?}"),
}
let html = render_figure(&emit);
assert!(html.contains(r#"data-width="wide""#), "got: {html}");
}
#[test]
fn dispatch_image_cover_emits_object_fit_on_inner_img() {
use crate::ast::node::Block;
let emit = dispatch_img(Some("cover"));
match figure_of(&emit) {
Block::Figure { img_style, caption, .. } => {
assert_eq!(img_style.as_deref(), Some("object-fit:cover"));
assert!(caption.is_none(), "structural alias is not a caption");
}
other => panic!("expected Figure, got {other:?}"),
}
let html = render_figure(&emit);
assert!(html.contains("object-fit:cover"), "got: {html}");
assert!(html.contains(r#"<figure class="moss-image""#), "got: {html}");
}
#[test]
fn dispatch_image_cover_left_emits_fit_and_position() {
use crate::ast::node::Block;
let emit = dispatch_img(Some("cover left"));
match figure_of(&emit) {
Block::Figure { img_style, .. } => {
let style = img_style.as_deref().expect("style present");
assert!(style.contains("object-fit:cover"), "got: {style}");
assert!(style.contains("object-position:left"), "got: {style}");
}
other => panic!("expected Figure, got {other:?}"),
}
}
#[test]
fn dispatch_image_params_form_emits_object_fit() {
use crate::ast::node::Block;
let emit = dispatch_img(Some("fit=cover"));
match figure_of(&emit) {
Block::Figure { img_style, .. } => {
assert_eq!(img_style.as_deref(), Some("object-fit:cover"));
}
other => panic!("expected Figure, got {other:?}"),
}
}
#[test]
fn dispatch_image_two_word_position_combines() {
use crate::ast::node::Block;
let emit = dispatch_img(Some("cover top left"));
match figure_of(&emit) {
Block::Figure { img_style, .. } => {
let style = img_style.as_deref().expect("style present");
assert!(style.contains("object-fit:cover"), "got: {style}");
assert!(style.contains("object-position:top left"), "got: {style}");
}
other => panic!("expected Figure, got {other:?}"),
}
}
#[test]
fn dispatch_image_wide_cover_combines_width_and_fit() {
use crate::ast::node::Block;
let emit = dispatch_img(Some("wide cover"));
match figure_of(&emit) {
Block::Figure { width, img_style, .. } => {
assert_eq!(width.as_deref(), Some("wide"));
assert_eq!(img_style.as_deref(), Some("object-fit:cover"));
}
other => panic!("expected Figure, got {other:?}"),
}
let html = render_figure(&emit);
assert!(html.contains(r#"data-width="wide""#), "got: {html}");
assert!(html.contains("object-fit:cover"), "got: {html}");
}
#[test]
fn dispatch_image_inner_img_has_single_style_attr() {
let emit = dispatch_img(Some("fit=cover"));
let html = render_figure(&emit);
let n = html.matches("style=").count();
assert_eq!(n, 1, "exactly one style= attr, got {n}: {html}");
}
#[test]
fn wikilink_image_percent_carries_width() {
use crate::ast::node::Block;
let emit = dispatch_img(Some("55%"));
match figure_of(&emit) {
Block::Figure { width, caption, .. } => {
assert_eq!(width.as_deref(), Some("55%"));
assert!(caption.is_none(), "percent must not become a caption");
}
other => panic!("expected Figure, got {other:?}"),
}
}
#[test]
fn wikilink_image_percent_with_caption() {
use crate::ast::node::{Block, Inline};
let emit = dispatch_img(Some("My cap|55%"));
match figure_of(&emit) {
Block::Figure { width, caption, .. } => {
assert_eq!(width.as_deref(), Some("55%"));
let cap = caption.as_ref().expect("caption present");
assert!(
matches!(cap.as_slice(), [Inline::Text(t)] if t == "My cap"),
"caption should be the non-width segment, got {cap:?}"
);
}
other => panic!("expected Figure, got {other:?}"),
}
}
#[test]
fn dispatch_external_url_youtube_emits_html() {
let graph = build_graph(&[]);
let emit = dispatch_wikilink_embed(
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
None,
true,
&graph,
"index.md",
&empty_snapshot(),
);
match emit.output {
EmitKind::Html(s) => {
assert!(s.contains("<iframe"), "got: {s}");
assert!(s.contains("youtube.com/embed"), "got: {s}");
assert!(s.contains(r#"data-provider="youtube""#), "got: {s}");
}
other => panic!("expected Html, got: {other:?}"),
}
assert!(emit.outgoing_link.is_none(), "external URLs must not register in ContentGraph");
assert!(emit.diagnostics.is_empty());
}
#[test]
fn dispatch_external_url_generic_emits_html() {
let graph = build_graph(&[]);
let emit = dispatch_wikilink_embed(
"https://example.com/embed",
None,
true,
&graph,
"index.md",
&empty_snapshot(),
);
match emit.output {
EmitKind::Html(s) => {
assert!(s.contains("<iframe"), "got: {s}");
assert!(s.contains(r#"src="https://example.com/embed""#), "got: {s}");
assert!(!s.contains("data-provider="), "generic must not have data-provider, got: {s}");
}
other => panic!("expected Html, got: {other:?}"),
}
assert!(emit.outgoing_link.is_none());
}
#[test]
fn dispatch_external_url_http_also_works() {
let graph = build_graph(&[]);
let emit = dispatch_wikilink_embed(
"http://example.com/embed",
None,
true,
&graph,
"index.md",
&empty_snapshot(),
);
match emit.output {
EmitKind::Html(s) => assert!(s.contains("<iframe"), "got: {s}"),
other => panic!("expected Html, got: {other:?}"),
}
}
#[test]
fn dispatch_external_url_with_width_pothole() {
let graph = build_graph(&[]);
let emit = dispatch_wikilink_embed(
"https://vimeo.com/123456789",
Some("wide"),
true,
&graph,
"index.md",
&empty_snapshot(),
);
match emit.output {
EmitKind::Html(s) => assert!(s.contains(r#"data-width="wide""#), "got: {s}"),
other => panic!("expected Html, got: {other:?}"),
}
}
}