moss_core/resolve/wikilink_dispatch.rs
1//! Phase 3: Stage 2 entry point for wikilink embed dispatch.
2//!
3//! This module is the sole dispatcher for `[[…]]` / `![[…]]` events
4//! emitted by pulldown-cmark with `Options::ENABLE_WIKILINKS`. The
5//! src-tauri pipeline's `transform_events` (in
6//! `src-tauri/src/build/markdown/pipeline.rs`) calls
7//! [`dispatch_wikilink_embed_with_registry`] once per WikiLink-typed
8//! event, swallows the event range, and substitutes the renderer-
9//! produced HTML.
10//!
11//! # History
12//!
13//! - **PR1 (`c2fbdd593`)**: this module landed as a dormant API alongside
14//! the dispatch arm shape in `transform_events` (also dormant — gated
15//! by the absence of `ENABLE_WIKILINKS`).
16//! - **PR2 (this change)**: enabled `ENABLE_WIKILINKS` at every
17//! `Parser::new_ext` site, wired the dispatcher closure into
18//! `transform_events`, and deleted the prior Stage 1 string-rewriter
19//! (`crates/moss-core/src/resolve/wikilinks.rs`, ~2155 LOC).
20//!
21//! # What this reuses
22//!
23//! - Extension routing goes through [`super::embed_renderer::lookup_renderer`]
24//! (the same registry the pre-PR2 Stage 1 resolver used). No parallel
25//! dispatcher.
26//! - Anchor / query splitting on `dest_url` mirrors the pre-PR2
27//! `wikilinks::parse_wikilink_inner`'s `#` / `?` priority logic.
28//! - Width-token extraction uses [`crate::media::extract_width_from_alias`].
29//!
30//! # What's new
31//!
32//! - [`parse_pothole_params`] reads the pothole text (the `bar` in
33//! `[[foo|bar]]`) and classifies it as one of:
34//! * empty — no pothole
35//! * width-token — Obsidian `[[img.jpg|400]]` shorthand
36//! * params — `width=400 align=left` (every-token-K=V rule)
37//! * alias — plain display text
38//!
39//! The every-token-K=V rule (locked by arch review) prevents free-text
40//! captions like `alt text=cover` from being mis-parsed as `text=cover`.
41
42use crate::asset_snapshot::AssetSnapshot;
43use crate::content_graph::ContentGraph;
44use crate::path_ext::path_extension;
45use crate::media::{
46 extract_width_from_alias, parse_media_attrs, AlignSide, Fit, MediaAttrs, Position,
47};
48
49use super::embed_renderer::{
50 lookup_renderer, EmbedRenderer, ParsedEmbed, RenderedEmbed, Sizing, IMAGE_EXTENSIONS,
51};
52use super::fuzzy_path::{relative_asset_path, resolve_reference, ResolvedRef};
53use super::title_params::TitleParams;
54use super::{Diagnostic, LinkType, OutgoingLink};
55
56/// Classification of pothole text (`|...` in `[[file|...]]`).
57#[derive(Debug, Clone, PartialEq)]
58pub enum PotholeContent {
59 /// No pothole or pothole is whitespace-only.
60 Empty,
61 /// Obsidian width-token shorthand: `[[img.jpg|400]]`, `[[img.jpg|100%]]`,
62 /// `[[img.jpg|200x150]]`. Carries the canonical width string
63 /// (one of `body | wide | page | screen` after token-matching) and the
64 /// trailing alias remainder (often empty).
65 WidthToken {
66 width: &'static str,
67 rest_alias: String,
68 },
69 /// Typed params: every whitespace-separated token matched
70 /// `^[a-z][a-z0-9_-]*=...`.
71 Params(TitleParams),
72 /// Plain alias display text (Obsidian default).
73 Alias(String),
74}
75
76/// Result of splitting a wikilink `dest_url` into its `file`, `section`,
77/// `query` components.
78///
79/// Pulldown-cmark hands us `dest_url` verbatim — `[[foo#bar?baz]]` arrives as
80/// `dest_url="foo#bar?baz"`. We still need to split for renderer dispatch
81/// (image / markdown / iframe / etc.) and for emitted-href construction.
82#[derive(Debug, Clone, PartialEq)]
83pub struct SplitDestUrl<'a> {
84 pub file: &'a str,
85 pub section: Option<&'a str>,
86 pub query: Option<&'a str>,
87}
88
89/// Output of [`dispatch_wikilink_embed`].
90#[derive(Debug, Clone)]
91pub struct WikilinkEmit {
92 /// Rendered HTML or markdown to splice into the event stream.
93 /// For an embed (`![[…]]`) this is the renderer's output. For a plain
94 /// wikilink (`[[…]]`) this is a markdown link the caller can let
95 /// pulldown-cmark re-parse, or a final HTML fragment.
96 pub output: EmitKind,
97 /// Outgoing link to register with ContentGraph.
98 pub outgoing_link: Option<OutgoingLink>,
99 /// Diagnostics (e.g. unresolved reference).
100 pub diagnostics: Vec<Diagnostic>,
101}
102
103/// The shape of the dispatcher's emitted content. Mirrors
104/// [`super::embed_renderer::RenderedEmbed`] for embeds, plus a separate
105/// variant for non-embed wikilinks (`[[file]]`).
106#[derive(Debug, Clone, PartialEq)]
107pub enum EmitKind {
108 /// Markdown-level text that downstream CommonMark will re-process.
109 /// Example: image renderer returns ``.
110 Inline(String),
111 /// Final HTML — must NOT be re-parsed by the markdown engine.
112 /// Example: iframe renderer.
113 Html(String),
114 /// A marker comment for a post-pass resolver (notebook, table, plugin).
115 Deferred(String),
116 /// A standard markdown link string. Used for non-embed wikilinks
117 /// (`[[file]]` rather than `![[file]]`).
118 Link(String),
119 /// A typed AST block to splice in directly (image-embed synth-collapse).
120 /// Unlike [`EmitKind::Html`] (which lands as an opaque `Block::Other`
121 /// carrying no `BlockMeta`), a typed block placed 1:1 at `blocks[i]`
122 /// inherits the source paragraph's `block_meta[i]` — so a lone image
123 /// embed rendered as `Block::Figure` keeps its `data-source-line` in
124 /// preview/site builds. The image arm uses this; non-image embeds keep
125 /// emitting `EmitKind::Html`.
126 Block(Box<crate::ast::node::Block>),
127}
128
129/// Parse pothole text using the every-token-K=V rule.
130///
131/// Order of attempts:
132/// 1. Empty → [`PotholeContent::Empty`].
133/// 2. Obsidian width-token (`400`, `100%`, `200x150`, `full`, etc.) via
134/// [`extract_width_from_alias`] → [`PotholeContent::WidthToken`].
135/// 3. Every whitespace-separated token matches `^[a-z][a-z0-9_-]*=...`
136/// → [`PotholeContent::Params`].
137/// 4. Otherwise → [`PotholeContent::Alias`].
138///
139/// The every-token rule is critical: `[[file|alt text=cover]]` must be
140/// recognized as alias text (because `alt` is bare), not as a `text=cover`
141/// param. See plan v2 revision notes.
142pub fn parse_pothole_params(text: &str) -> PotholeContent {
143 let trimmed = text.trim();
144 if trimmed.is_empty() {
145 return PotholeContent::Empty;
146 }
147
148 // Step 2: try Obsidian width-token shorthand. This recognizes single
149 // tokens like `400`, `100%`, `200x150`, `wide`, `full`, etc. The width
150 // matcher only fires on isolated tokens; multi-word free text like
151 // "wide angle photo" is not classified as a width token.
152 let (width, rest_alias) = extract_width_from_alias(trimmed);
153 if let Some(w) = width {
154 return PotholeContent::WidthToken {
155 width: w,
156 rest_alias,
157 };
158 }
159
160 // Step 3: every-token-K=V rule. Each whitespace-separated token must
161 // match `^[a-z][a-z0-9_-]*=`. If ANY token fails the pattern, the
162 // entire pothole falls through to alias.
163 let tokens: Vec<&str> = trimmed.split_whitespace().collect();
164 if !tokens.is_empty() && tokens.iter().all(|t| is_kv_token(t)) {
165 let mut params = TitleParams::default();
166 for token in &tokens {
167 if let Some((k, v)) = token.split_once('=') {
168 params.insert(k, v);
169 }
170 }
171 return PotholeContent::Params(params);
172 }
173
174 // Step 4: fallback. Preserve original text exactly (caller may want
175 // verbatim alias display).
176 PotholeContent::Alias(text.to_string())
177}
178
179/// Test if a single token matches the K=V pattern: `^[a-z][a-z0-9_-]*=...`.
180///
181/// The key must start with a lowercase ASCII letter and continue with
182/// lowercase ASCII letters / digits / underscore / hyphen, followed by
183/// `=`. The value side is not constrained here.
184fn is_kv_token(token: &str) -> bool {
185 let Some((key, _value)) = token.split_once('=') else {
186 return false;
187 };
188 if key.is_empty() {
189 return false;
190 }
191 let mut chars = key.chars();
192 let Some(first) = chars.next() else {
193 return false; // empty key (guarded above, but keep the type-safe form)
194 };
195 if !first.is_ascii_lowercase() {
196 return false;
197 }
198 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
199}
200
201/// Split a pulldown-cmark wikilink `dest_url` into `file`, `section`, `query`.
202///
203/// Ported from the pre-Phase-3 `wikilinks::parse_wikilink_inner` (the
204/// `before-pipe` half — the `|alias` part is handled by pulldown-cmark
205/// via pothole events, so it doesn't appear in `dest_url`).
206///
207/// Whichever of `#` or `?` appears first in `dest_url` owns its tail; the
208/// other is split out of that tail. Matches Obsidian's heading-ref priority
209/// (`[[file#section]]`) while accepting URL-style mixes
210/// (`[[file.html?x=1#frag]]`).
211pub fn split_dest_url(dest_url: &str) -> SplitDestUrl<'_> {
212 let hash_pos = dest_url.find('#');
213 let query_pos = dest_url.find('?');
214
215 // char-aligned: `h`/`q` are byte indices of ASCII `#`/`?`, each a
216 // single-byte UTF-8 char. `h+1`/`q+1` step over the single byte.
217 #[allow(clippy::string_slice)]
218 match (hash_pos, query_pos) {
219 (None, None) => SplitDestUrl {
220 file: dest_url,
221 section: None,
222 query: None,
223 },
224 (Some(h), None) => SplitDestUrl {
225 file: &dest_url[..h],
226 section: Some(&dest_url[h + 1..]),
227 query: None,
228 },
229 (None, Some(q)) => SplitDestUrl {
230 file: &dest_url[..q],
231 section: None,
232 query: Some(&dest_url[q + 1..]),
233 },
234 (Some(h), Some(q)) if h < q => SplitDestUrl {
235 file: &dest_url[..h],
236 section: Some(&dest_url[h + 1..q]),
237 query: Some(&dest_url[q + 1..]),
238 },
239 (Some(h), Some(q)) => SplitDestUrl {
240 file: &dest_url[..q],
241 section: Some(&dest_url[h + 1..]),
242 query: Some(&dest_url[q + 1..h]),
243 },
244 }
245}
246
247/// Build the anchor fragment (e.g. `#getting-started` or `#block-id`) from a
248/// section reference. Mirrors [`super::wikilinks`]'s `build_anchor`.
249///
250/// # Live-build scope (read before relying on this)
251///
252/// `build_anchor` is reached ONLY from [`dispatch_wikilink_form`] (the
253/// `is_embed: false` branch). That branch is DORMANT in production: the sole
254/// runtime caller of this dispatcher — the AST visitor
255/// [`crate::ast::dispatch_wikilink_embeds`] — hard-codes `is_embed: true`
256/// (it walks only `![[…]]` image embeds). The user-facing
257/// `[[Page#Heading]]` text-link fragment slugging is performed instead by
258/// `crate::ast::resolve_urls::slug_wikilink_suffix`, which this function
259/// mirrors. Keep the two in sync; the resolve_urls tests are the real guards.
260fn build_anchor(section: Option<&str>) -> String {
261 use crate::heading_anchor::obsidian_heading_anchor;
262 match section {
263 None => String::new(),
264 Some("") => String::new(),
265 Some(s) => {
266 if let Some(block_id) = s.strip_prefix('^') {
267 format!("#{}", block_id)
268 } else {
269 format!("#{}", obsidian_heading_anchor(s))
270 }
271 }
272 }
273}
274
275/// Phase 3 PR1: Stage 2 entry point for wikilink dispatch.
276///
277/// Reads a parsed wikilink (the `dest_url` and pothole-text fields from
278/// pulldown-cmark's `Tag::Link { link_type: LinkType::WikiLink { has_pothole } }`
279/// or `Tag::Image { … LinkType::WikiLink … }`) and produces rendered output
280/// via the existing [`super::embed_renderer`] registry.
281///
282/// # Arguments
283///
284/// * `dest_url` — pulldown-cmark's `dest_url` (everything before `|` in
285/// the source; may carry `#section` and/or `?query` fragments).
286/// * `pothole` — the pothole text (everything after `|`), or `None` if
287/// `has_pothole=false`.
288/// * `is_embed` — `true` for `![[…]]` (image-form), `false` for `[[…]]`.
289/// Routes embeds through the registry; routes plain wikilinks to a
290/// standard markdown link.
291/// * `graph` — content graph for path resolution.
292/// * `from_path` — calling file's path (for relative URL computation +
293/// diagnostics).
294///
295/// # Status (Phase 3 PR1, dormant)
296///
297/// This function compiles and is unit-tested, but no caller wires it in
298/// at runtime yet. PR2 enables `ENABLE_WIKILINKS` and adds the call from
299/// `src-tauri/src/build/markdown/pipeline.rs::transform_events`.
300pub fn dispatch_wikilink_embed(
301 dest_url: &str,
302 pothole: Option<&str>,
303 is_embed: bool,
304 graph: &ContentGraph,
305 from_path: &str,
306 assets: &AssetSnapshot,
307) -> WikilinkEmit {
308 dispatch_wikilink_embed_with_lookup(
309 dest_url,
310 pothole,
311 is_embed,
312 graph,
313 from_path,
314 assets,
315 &|ext| lookup_renderer(ext).map(|r| r as &dyn EmbedRenderer),
316 )
317}
318
319/// Like [`dispatch_wikilink_embed`] but threads a custom registry lookup.
320/// Used when the caller has plugin-registered renderers.
321pub fn dispatch_wikilink_embed_with_registry(
322 dest_url: &str,
323 pothole: Option<&str>,
324 is_embed: bool,
325 graph: &ContentGraph,
326 from_path: &str,
327 assets: &AssetSnapshot,
328 registry: &super::registry::RendererRegistry,
329) -> WikilinkEmit {
330 dispatch_wikilink_embed_with_lookup(
331 dest_url,
332 pothole,
333 is_embed,
334 graph,
335 from_path,
336 assets,
337 &|ext| registry.lookup(ext).map(|r| r as &dyn EmbedRenderer),
338 )
339}
340
341fn dispatch_wikilink_embed_with_lookup(
342 dest_url: &str,
343 pothole: Option<&str>,
344 is_embed: bool,
345 graph: &ContentGraph,
346 from_path: &str,
347 assets: &AssetSnapshot,
348 lookup: &dyn Fn(&str) -> Option<&dyn EmbedRenderer>,
349) -> WikilinkEmit {
350 let split = split_dest_url(dest_url);
351 let pothole_content = match pothole {
352 None => PotholeContent::Empty,
353 Some(s) => parse_pothole_params(s),
354 };
355
356 if is_embed {
357 dispatch_embed_form(&split, pothole_content, graph, from_path, assets, lookup)
358 } else {
359 dispatch_wikilink_form(&split, pothole_content, graph, from_path)
360 }
361}
362
363/// Dispatch for `![[…]]` (embed form). Mirrors `resolve_embed`'s body.
364fn dispatch_embed_form(
365 split: &SplitDestUrl<'_>,
366 pothole: PotholeContent,
367 graph: &ContentGraph,
368 from_path: &str,
369 assets: &AssetSnapshot,
370 lookup: &dyn Fn(&str) -> Option<&dyn EmbedRenderer>,
371) -> WikilinkEmit {
372 let mut diagnostics: Vec<Diagnostic> = Vec::new();
373
374 // Phase 3 PR2: trailing-slash dispatch is the folder-list embed
375 // (`![[/journal/]]`). We must check this BEFORE `resolve_reference`
376 // because ContentGraph::resolve_path normalizes trailing slashes
377 // away — running it first would always discard the folder-embed
378 // signal. The actual listing is rendered by the src-tauri marker
379 // resolver (Task 16) which has `all_docs` available; here we just
380 // emit a marker carrying the user-written path + the source file
381 // path (for relative resolution).
382 //
383 // Pothole text after `|` becomes the folder-list params string
384 // (e.g. `limit:5,more,sort:date`). We parse it back from whatever
385 // pothole shape pulldown-cmark gave us.
386 if !split.file.is_empty() && split.file.ends_with('/') {
387 let pothole_raw = match &pothole {
388 PotholeContent::Empty => String::new(),
389 PotholeContent::WidthToken { rest_alias, .. } => rest_alias.clone(),
390 PotholeContent::Params(_) => String::new(),
391 PotholeContent::Alias(s) => s.clone(),
392 };
393 let params = super::embed_renderer::folder_list::parse_params(&pothole_raw);
394 let marker =
395 super::embed_renderer::folder_list::emit_marker(split.file, from_path, ¶ms);
396 return WikilinkEmit {
397 output: EmitKind::Html(marker),
398 outgoing_link: Some(OutgoingLink {
399 target_path: split.file.to_string(),
400 display_text: split.file.to_string(),
401 link_type: LinkType::Embed,
402 }),
403 diagnostics,
404 };
405 }
406
407 // Resolve. Same logic as resolve_embed: empty file → same file;
408 // non-empty → fuzzy resolve.
409 let resolved = if split.file.is_empty() {
410 ResolvedRef::Found(from_path.to_string())
411 } else {
412 resolve_reference(split.file, graph, from_path)
413 };
414
415 // Derive `alias` and `width` for ParsedEmbed from the pothole.
416 // For PotholeContent::Params we surface no alias; the params are
417 // carried via TitleParams (consumers in PR4 onward can read them
418 // directly without round-tripping through the `moss:` title channel).
419 // For PR1 the params get folded into the renderer via the same path
420 // Stage 1 uses today: there's no Stage-2 consumer yet, so we forward
421 // alias as None when we have pure params.
422 let (alias_owned, width): (Option<String>, Option<&'static str>) = match &pothole {
423 PotholeContent::Empty => (None, None),
424 PotholeContent::WidthToken { width, rest_alias } => (
425 if rest_alias.is_empty() {
426 None
427 } else {
428 Some(rest_alias.clone())
429 },
430 Some(*width),
431 ),
432 PotholeContent::Params(_) => (None, None),
433 PotholeContent::Alias(s) => (Some(s.clone()), None),
434 };
435
436 match resolved {
437 ResolvedRef::Found(target_path) => {
438 let outgoing = OutgoingLink {
439 target_path: target_path.clone(),
440 display_text: split.file.to_string(),
441 link_type: LinkType::Embed,
442 };
443
444 let parsed = ParsedEmbed {
445 resolved_path: &target_path,
446 from_path,
447 query: split.query,
448 section: split.section,
449 alias: alias_owned.as_deref(),
450 width,
451 attrs: None,
452 };
453
454 // Phase 3 PR4.5 (2026-05-27): non-image wikilink embeds
455 // (video / pdf / audio / iframe / 3D) route DIRECTLY to the
456 // typed-HTML synthesizer here. Previously they emitted
457 // `EmitKind::Inline(markdown_link)` with a `moss:kind=…`
458 // title that Stage 2 was supposed to read back via
459 // `parse_title` — but PR4 deleted `parse_title`, and PR2's
460 // markdown round-trip had already been dropping the title.
461 // The result was non-image embeds rendering as plain
462 // `<a href>` links. The fix is to skip the round-trip
463 // entirely: derive `TitleParams` from the pothole content
464 // and the resolved URL, then hand them straight to the
465 // per-kind synthesizer. Image embeds keep their inline-
466 // markdown emission so `<picture>` / `<figure>` wrap stays
467 // in the markdown round-trip path that already worked.
468 let ext = path_extension(&target_path);
469 let url = relative_asset_path(from_path, &target_path);
470 if let Some(synth_kind) = ext.as_deref().and_then(synth_kind_for_ext) {
471 let params = build_synth_params(synth_kind, &parsed, &pothole);
472 let html = match synth_kind {
473 SynthKind::Video => {
474 crate::render::video::synthesize_video_html(¶ms, &url, assets)
475 }
476 SynthKind::Pdf => {
477 crate::render::pdf::synthesize_pdf_html(¶ms, &url, assets)
478 }
479 SynthKind::Audio => {
480 crate::render::audio::synthesize_audio_html(¶ms, &url, assets)
481 }
482 SynthKind::Iframe => {
483 crate::render::iframe::synthesize_iframe_html(¶ms, &url, assets)
484 }
485 SynthKind::Model => {
486 crate::render::model::synthesize_model_html(¶ms, &url, assets)
487 }
488 };
489 return WikilinkEmit {
490 output: EmitKind::Html(html),
491 outgoing_link: Some(outgoing),
492 diagnostics,
493 };
494 }
495
496 // Image embeds — the unified arm (image-embed synth-collapse).
497 //
498 // ALL `![[photo.jpg]]` forms route through here to a typed
499 // `Block::Figure`, the SAME node the CommonMark `` path
500 // produces. This replaced the prior split (a fit/position
501 // "fast-path" that emitted bare `<picture>` + an
502 // `ImageRenderer::render_to_markdown` round-trip that dropped
503 // width via `let _ = params`). Six embed kinds already went
504 // dispatch → synth → Html; image now matches via
505 // dispatch → Block::Figure → render_document.
506 //
507 // Four sources of display params are assembled into the figure:
508 // 1. width ← `embed.width` (canonical WidthToken) → figure `data-width=`
509 // 2. caption + alt ← `classify_image_alias` (structural → none,
510 // caption-text → both, empty → none; never `Some("")`)
511 // 3. fit/position ← `build_image_media_attrs` → `to_inline_style()`
512 // → inner `<img>` `style=` (NOT the figure)
513 // 4. align + class_names ← `build_image_media_attrs` → figure class list
514 //
515 // Emitting `EmitKind::Block` (not `EmitKind::Html`) keeps the
516 // 1:1 `apply_emit` substitution so the figure inherits the source
517 // paragraph's `block_meta` → `data-source-line` survives.
518 //
519 // `find_lone_wikilink_image` guarantees the dispatcher is only
520 // reached for a lone embed (within its container), so the figure
521 // shape is always correct here.
522 if matches!(ext.as_deref(), Some(e) if IMAGE_EXTENSIONS.iter().any(|x| *x == e)) {
523 let media = build_image_media_attrs(&pothole, parsed.attrs.as_ref());
524 let alias_class = crate::media::classify_image_alias(parsed.alias);
525 let alt = alias_class.caption.clone().unwrap_or_default();
526 let caption: Option<Vec<crate::ast::node::Inline>> = alias_class
527 .caption
528 .map(|c| vec![crate::ast::node::Inline::Text(c)]);
529 // `AlignSide::css_class()` returns the canonical
530 // `moss-align-left` / `moss-align-right` class verbatim —
531 // the same class the figure renderer appends.
532 let align = media.align.map(|side| side.css_class().to_string());
533 let img_style = media.to_inline_style();
534 // Width source: canonical pothole WidthToken (`|wide`) OR a
535 // width token embedded in a structural alias (`|wide cover`
536 // parses as Alias, so `width` is None) — recover it here so
537 // the figure `data-width` survives either spelling. The
538 // pre-collapse fast-path dropped the embedded case (the bug).
539 let figure_width: Option<&'static str> = width.or_else(|| {
540 alias_class.display_keywords.as_deref().and_then(|kw| {
541 kw.split_whitespace().find_map(crate::media::match_width_token)
542 })
543 });
544 let figure = crate::ast::node::Block::Figure {
545 image: crate::ast::node::Inline::Image {
546 // `Asset` is the canonical kind for an `<img src>`
547 // (matches resolve_urls' image-URL classification).
548 src: crate::ast::url::Url::resolved(
549 url.clone(),
550 crate::ast::url::UrlKind::Asset,
551 ),
552 alt,
553 title: None,
554 is_wikilink: true,
555 wikilink_pothole: None,
556 },
557 caption,
558 // Canonical `&'static str` width token; the node stores
559 // `Option<String>` (for Deserialize).
560 width: figure_width.map(|w| w.to_string()),
561 align,
562 class_names: media.class_names,
563 img_style,
564 };
565 return WikilinkEmit {
566 output: EmitKind::Block(Box::new(figure)),
567 outgoing_link: Some(outgoing),
568 diagnostics,
569 };
570 }
571
572 let emit = match ext.as_deref().and_then(lookup) {
573 Some(r) => match r.render(&parsed) {
574 RenderedEmbed::Inline(s) => EmitKind::Inline(s),
575 RenderedEmbed::Html(s) => EmitKind::Html(s),
576 RenderedEmbed::Deferred { marker } => EmitKind::Deferred(marker),
577 },
578 None => {
579 // Fallback: plain file link (Obsidian parity for
580 // unknown extensions).
581 EmitKind::Inline(format!("[{}]({})", split.file, url))
582 }
583 };
584
585 WikilinkEmit {
586 output: emit,
587 outgoing_link: Some(outgoing),
588 diagnostics,
589 }
590 }
591 ResolvedRef::Unresolved => {
592 diagnostics.push(Diagnostic {
593 message: format!("Unresolved embed: ![[{}]]", split.file),
594 source_path: from_path.to_string(),
595 reference: split.file.to_string(),
596 });
597
598 WikilinkEmit {
599 output: EmitKind::Inline(format!(
600 "[{}](moss-unresolved:{})",
601 split.file, split.file
602 )),
603 outgoing_link: Some(OutgoingLink {
604 target_path: split.file.to_string(),
605 display_text: split.file.to_string(),
606 link_type: LinkType::Embed,
607 }),
608 diagnostics,
609 }
610 }
611 }
612}
613
614/// Dispatch for `[[…]]` (plain wikilink). Mirrors `resolve_wikilink`'s body
615/// (the non-embed case).
616///
617/// DORMANT in the live build: the only production caller
618/// ([`crate::ast::dispatch_wikilink_embeds`]) always passes `is_embed: true`,
619/// so plain `[[…]]` text links never route here. They reach the typed AST as
620/// `Inline::Link { is_wikilink: true }` and are resolved by
621/// `crate::ast::resolve_urls` instead. This function remains as a tested
622/// helper (and for any future plugin/CLI caller that passes `is_embed:
623/// false`).
624fn dispatch_wikilink_form(
625 split: &SplitDestUrl<'_>,
626 pothole: PotholeContent,
627 graph: &ContentGraph,
628 from_path: &str,
629) -> WikilinkEmit {
630 let mut diagnostics = Vec::new();
631
632 // For plain wikilinks, only Alias-shaped potholes contribute to
633 // display text. Width tokens and params are meaningless on a
634 // non-embed wikilink — preserve Stage 1 behavior by ignoring them.
635 let alias_display = match &pothole {
636 PotholeContent::Alias(s) => Some(s.clone()),
637 PotholeContent::WidthToken { rest_alias, .. } if !rest_alias.is_empty() => {
638 Some(rest_alias.clone())
639 }
640 _ => None,
641 };
642
643 let display_text = if let Some(a) = alias_display {
644 a
645 } else if let Some(sec) = split.section {
646 if split.file.is_empty() {
647 sec.to_string()
648 } else {
649 format!("{} > {}", split.file, sec)
650 }
651 } else {
652 split.file.to_string()
653 };
654
655 let resolved = if split.file.is_empty() {
656 ResolvedRef::Found(from_path.to_string())
657 } else {
658 resolve_reference(split.file, graph, from_path)
659 };
660
661 match resolved {
662 ResolvedRef::Found(target_path) => {
663 let outgoing = OutgoingLink {
664 target_path: target_path.clone(),
665 display_text: display_text.clone(),
666 link_type: LinkType::Wikilink,
667 };
668
669 let anchor = build_anchor(split.section);
670 let link = if split.file.is_empty() {
671 format!("[{}]({})", display_text, anchor)
672 } else {
673 format!(
674 "[{}](moss-resolved:{}{})",
675 display_text, target_path, anchor
676 )
677 };
678
679 WikilinkEmit {
680 output: EmitKind::Link(link),
681 outgoing_link: Some(outgoing),
682 diagnostics,
683 }
684 }
685 ResolvedRef::Unresolved => {
686 diagnostics.push(Diagnostic {
687 message: format!("Unresolved wikilink: [[{}]]", split.file),
688 source_path: from_path.to_string(),
689 reference: split.file.to_string(),
690 });
691 WikilinkEmit {
692 output: EmitKind::Link(format!(
693 "[{}](moss-unresolved:{})",
694 display_text, split.file
695 )),
696 outgoing_link: Some(OutgoingLink {
697 target_path: split.file.to_string(),
698 display_text,
699 link_type: LinkType::Wikilink,
700 }),
701 diagnostics,
702 }
703 }
704 }
705}
706
707/// Build [`MediaAttrs`] from a pothole's alias / params.
708///
709/// Two active sources of display vocabulary for image embeds:
710///
711/// - Alias form (`![[hero.jpg|cover left]]`) — whitespace-separated
712/// display keywords. The pothole arrives as
713/// [`PotholeContent::Alias`] or [`PotholeContent::WidthToken::rest_alias`]
714/// when a width token preceded the keywords. `parse_media_attrs` decodes
715/// them into typed `fit` / `position` / `align` fields.
716/// - Params form (`![[hero.jpg|fit=cover position=left]]`) — every token
717/// is `key=value`. The pothole arrives as
718/// [`PotholeContent::Params`] carrying a `TitleParams` bag; we look up
719/// `fit` / `position` / `align` by name and convert their values via the
720/// per-enum `from_keyword`. Unknown keys flow through as `extra_attrs`.
721///
722/// Pandoc attribute blocks (`![[hero.jpg|cover]]{.theme-rounded x="y"}`) are
723/// a third potential source, but [`ParsedEmbed::attrs`] is currently
724/// hard-coded to `None` at the dispatcher's image branch (see
725/// `dispatch_embed_form`). The `attrs` parameter is plumbed through for
726/// future wiring; today the function ignores it. Don't grow the merge
727/// logic here until a caller actually populates `parsed.attrs`.
728fn build_image_media_attrs(
729 pothole: &PotholeContent,
730 _attrs: Option<&crate::ast::attrs::AttrBlock>,
731) -> MediaAttrs {
732 let mut media = MediaAttrs::default();
733
734 // Source 1: alias form. Only fold when the entire alias is structural
735 // (every token is a display keyword) — non-structural aliases are
736 // caption text and don't contribute display params.
737 let alias_text = match pothole {
738 PotholeContent::Alias(s) => Some(s.as_str()),
739 PotholeContent::WidthToken { rest_alias, .. } if !rest_alias.is_empty() => {
740 Some(rest_alias.as_str())
741 }
742 _ => None,
743 };
744 if let Some(text) = alias_text {
745 // Width tokens (`wide`, `screen`, etc.) may appear adjacent to fit /
746 // position keywords in space-separated aliases like
747 // `![[hero|wide cover]]`. They ride on the figure wrapper via
748 // `embed.width`, not the inner `<img>`; strip them here so the
749 // remainder ("cover") parses cleanly through `parse_media_attrs`.
750 // Without this, `is_all_display_keywords("wide cover")` returns
751 // `false` (because "wide" isn't a display keyword) and we'd
752 // silently drop the fit/position — the same regression this branch
753 // exists to fix.
754 let cleaned: Vec<&str> = text
755 .split_whitespace()
756 .filter(|t| crate::media::match_width_token(t).is_none())
757 .collect();
758 let cleaned_str = cleaned.join(" ");
759 if !cleaned_str.is_empty() && crate::media::is_all_display_keywords(&cleaned_str) {
760 let parsed = parse_media_attrs(&cleaned_str);
761 media.fit = parsed.fit;
762 media.position = parsed.position;
763 media.align = parsed.align;
764 // `parse_media_attrs` doesn't populate `class_names` or
765 // `extra_attrs` today (those come from Pandoc blocks, which
766 // aren't wired). The extends here are forward-looking scaffolding
767 // — harmless no-ops on current `MediaAttrs` shape.
768 media.class_names.extend(parsed.class_names);
769 for (k, v) in parsed.extra_attrs {
770 media.extra_attrs.insert(k, v);
771 }
772 }
773 }
774
775 // Source 2: Params form (K=V pothole). Recognized keys override; the
776 // rest flow through as `extra_attrs`.
777 //
778 // `style` is filtered OUT here because `synthesize_image_with_media_attrs`
779 // builds the `style="…"` attribute from `MediaAttrs::to_inline_style()`;
780 // letting an author-typed `style=foo` ALSO flow into `extra_attrs` would
781 // emit two `style=` attributes on the same `<img>` and the browser would
782 // honor the last one, silently dropping moss's object-fit / object-position.
783 if let PotholeContent::Params(params) = pothole {
784 for (k, v) in ¶ms.params {
785 match k.as_str() {
786 "fit" => {
787 if let Some(fit) = Fit::from_keyword(v) {
788 media.fit = Some(fit);
789 }
790 }
791 "position" => {
792 if let Some(pos) = Position::from_keyword(v) {
793 media.position = Some(pos);
794 }
795 }
796 "align" => {
797 if let Some(side) = AlignSide::from_keyword(v) {
798 media.align = Some(side);
799 }
800 }
801 // `width` / `data-width` ride on the figure wrapper, not the
802 // inner `<img>` — handled upstream via `embed.width`.
803 "width" | "data-width" => {}
804 "classes" => {
805 for c in v.split_whitespace() {
806 if !media.class_names.iter().any(|x| x == c) {
807 media.class_names.push(c.to_string());
808 }
809 }
810 }
811 // Drop `style=` to avoid duplicate-attribute emission;
812 // see function-level note above.
813 "style" => {}
814 _ => {
815 media.extra_attrs.insert(k.clone(), v.clone());
816 }
817 }
818 }
819 }
820
821 media
822}
823
824/// Discriminant for the per-kind HTML synthesizer the dispatcher routes to
825/// directly (Phase 3 PR4.5). Non-image / non-deferred extensions skip the
826/// markdown round-trip and emit `EmitKind::Html` straight from the synth
827/// function — see the dispatcher branch in `dispatch_embed_form`.
828#[derive(Debug, Clone, Copy, PartialEq, Eq)]
829enum SynthKind {
830 Video,
831 Pdf,
832 Audio,
833 Iframe,
834 Model,
835}
836
837/// Classify a file extension into a [`SynthKind`] when the dispatcher should
838/// emit final HTML directly. Returns `None` for image (`png`/`jpg`/...) —
839/// which keeps its inline-markdown round-trip — and for deferred kinds
840/// (`md`/`ipynb`/`csv`/`tsv`) which still need src-tauri post-passes.
841///
842/// Extension tables MUST stay in sync with the corresponding `EmbedRenderer`
843/// `extensions()` slices in `embed_renderer.rs`. A future refactor that
844/// surfaces the kind on the renderer trait could remove this duplication.
845fn synth_kind_for_ext(ext: &str) -> Option<SynthKind> {
846 let lower = ext.to_ascii_lowercase();
847 match lower.as_str() {
848 "mp4" | "webm" | "mov" | "m4v" => Some(SynthKind::Video),
849 "pdf" => Some(SynthKind::Pdf),
850 "mp3" | "wav" | "ogg" | "flac" | "m4a" | "opus" => Some(SynthKind::Audio),
851 "html" | "htm" => Some(SynthKind::Iframe),
852 "glb" | "gltf" => Some(SynthKind::Model),
853 _ => None,
854 }
855}
856
857/// Build the [`TitleParams`] handed to a per-kind synthesizer.
858///
859/// Mirrors the `*_extra_params` helpers in `embed_renderer.rs` (which fed
860/// the legacy `moss:title` round-trip) — they are the canonical reference
861/// for which params each synth function reads. Notable shape:
862///
863/// - **`data-width`** carries the canonical wrapper width (`body | wide |
864/// page | screen`) when the pothole was an Obsidian width-token. Synth
865/// functions emit it as the `data-width=` attribute on the wrapping
866/// element.
867/// - **`width` / `height`** come from `|WxH` sizing aliases parsed via
868/// [`Sizing`]. Pixel/percent/vh values are CSS-formatted.
869/// - **`title`** (iframe only) carries non-sizing alias text as the
870/// iframe's accessible name (legacy behaviour: `[[widget.html|My Widget]]`).
871/// - **`query` / `fragment`** (iframe/pdf only) reconstruct the served URL
872/// from the split dest-url — pulldown-cmark percent-encodes `?` and `#`
873/// if they stay in the URL slot, so the dispatcher hands them out-of-band.
874/// - **Pothole `Params`** are folded last so author-typed `width=400` etc.
875/// override the alias-derived values (every-token-K=V rule wins).
876fn build_synth_params(
877 kind: SynthKind,
878 embed: &ParsedEmbed<'_>,
879 pothole: &PotholeContent,
880) -> TitleParams {
881 let mut params = TitleParams::default();
882 if let Some(w) = embed.width {
883 params.insert("data-width", w);
884 }
885
886 // iframe / pdf carry ?query and #fragment out-of-band on the synth side.
887 if matches!(kind, SynthKind::Iframe | SynthKind::Pdf) {
888 if let Some(q) = embed.query {
889 params.insert("query", q);
890 }
891 if let Some(f) = embed.section {
892 params.insert("fragment", f);
893 }
894 }
895
896 // Per-kind alias handling. `embed.alias` is the pothole's alias-shaped
897 // remainder (already excludes width tokens) — for non-image kinds it
898 // overwhelmingly looks like a `|WxH` sizing hint, but iframe also
899 // supports free-text titles.
900 if let Some(alias) = embed.alias {
901 match kind {
902 SynthKind::Video | SynthKind::Pdf | SynthKind::Model => match Sizing::parse(alias) {
903 Some(Sizing::Width(w)) => {
904 params.insert("width", w.to_css());
905 }
906 Some(Sizing::Box(w, h)) => {
907 params.insert("width", w.to_css());
908 params.insert("height", h.to_css());
909 }
910 None => {}
911 },
912 SynthKind::Iframe => match Sizing::parse(alias) {
913 Some(Sizing::Width(w)) => {
914 params.insert("width", w.to_css());
915 }
916 Some(Sizing::Box(w, h)) => {
917 params.insert("width", w.to_css());
918 params.insert("height", h.to_css());
919 }
920 None => {
921 // Non-sizing alias text → iframe accessible name.
922 params.insert("title", alias);
923 }
924 },
925 SynthKind::Audio => {
926 // Audio synthesizer reads no alias-derived params today
927 // (controls / preload defaults are unconditional). Leave
928 // params untouched.
929 }
930 }
931 }
932
933 // Author-typed K=V params win over alias-derived values (every-token
934 // rule already validated by `parse_pothole_params`).
935 if let PotholeContent::Params(p) = pothole {
936 for (k, v) in &p.params {
937 params.insert(k.clone(), v.clone());
938 }
939 }
940
941 params
942}
943
944// ---------------------------------------------------------------------------
945// Tests
946// ---------------------------------------------------------------------------
947
948#[cfg(test)]
949mod tests {
950 use super::*;
951 use crate::content_graph::{ContentGraph, ContentGraphBuilder};
952
953 // --- parse_pothole_params edge cases ----------------------------------
954
955 #[test]
956 fn pothole_empty_string_is_empty() {
957 assert_eq!(parse_pothole_params(""), PotholeContent::Empty);
958 assert_eq!(parse_pothole_params(" "), PotholeContent::Empty);
959 }
960
961 #[test]
962 fn pothole_pure_digit_is_alias_not_width_token() {
963 // `[[img.jpg|400]]` — `400` is NOT a spec § P9 width keyword
964 // (only `body|wide|page|screen|full` match). Pure-pixel widths
965 // are handled downstream by the relevant renderer's `Sizing::parse`
966 // on the alias. parse_pothole_params therefore classifies `400`
967 // as a plain alias here; the image / video renderer's existing
968 // alias-based sizing logic (carry through to ParsedEmbed.alias)
969 // does the rest.
970 match parse_pothole_params("400") {
971 PotholeContent::Alias(s) => assert_eq!(s, "400"),
972 other => panic!("expected Alias, got {:?}", other),
973 }
974 }
975
976 #[test]
977 fn pothole_plain_alias() {
978 // `[[file|My alias]]`
979 match parse_pothole_params("My alias") {
980 PotholeContent::Alias(s) => assert_eq!(s, "My alias"),
981 other => panic!("expected Alias, got {:?}", other),
982 }
983 }
984
985 #[test]
986 fn pothole_kv_pair_is_params() {
987 // `[[file|width=400 align=left]]`
988 match parse_pothole_params("width=400 align=left") {
989 PotholeContent::Params(p) => {
990 assert_eq!(p.get("width"), Some("400"));
991 assert_eq!(p.get("align"), Some("left"));
992 }
993 other => panic!("expected Params, got {:?}", other),
994 }
995 }
996
997 #[test]
998 fn pothole_single_kv_is_params() {
999 // `[[file|width=400]]`
1000 match parse_pothole_params("width=400") {
1001 PotholeContent::Params(p) => {
1002 assert_eq!(p.get("width"), Some("400"));
1003 }
1004 other => panic!("expected Params, got {:?}", other),
1005 }
1006 }
1007
1008 #[test]
1009 fn pothole_bare_alt_blocks_kv_parse() {
1010 // CRITICAL: `[[file|alt text=cover]]` — `alt` is bare (no `=`),
1011 // so the whole thing must be classified as alias text, NOT as
1012 // a `text=cover` param.
1013 match parse_pothole_params("alt text=cover") {
1014 PotholeContent::Alias(s) => assert_eq!(s, "alt text=cover"),
1015 other => panic!("expected Alias, got {:?}", other),
1016 }
1017 }
1018
1019 #[test]
1020 fn pothole_uppercase_key_blocks_kv_parse() {
1021 // `[[file|My Notes=Important]]` — `My` doesn't start with
1022 // lowercase letter; whole thing falls through to alias.
1023 match parse_pothole_params("My Notes=Important") {
1024 PotholeContent::Alias(s) => assert_eq!(s, "My Notes=Important"),
1025 other => panic!("expected Alias, got {:?}", other),
1026 }
1027 }
1028
1029 #[test]
1030 fn pothole_no_equals_is_alias() {
1031 // `[[file|width 400]]` — no `=` on `width` token; alias.
1032 match parse_pothole_params("width 400") {
1033 PotholeContent::Alias(s) => assert_eq!(s, "width 400"),
1034 other => panic!("expected Alias, got {:?}", other),
1035 }
1036 }
1037
1038 #[test]
1039 fn pothole_partial_kv_falls_through_to_alias() {
1040 // `[[file|width=400 caption text]]` — first token is K=V but
1041 // `caption` and `text` aren't. Every-token rule fails → alias.
1042 match parse_pothole_params("width=400 caption text") {
1043 PotholeContent::Alias(s) => assert_eq!(s, "width=400 caption text"),
1044 other => panic!("expected Alias, got {:?}", other),
1045 }
1046 }
1047
1048 #[test]
1049 fn pothole_kv_with_hyphenated_key() {
1050 // Hyphen and underscore allowed in keys.
1051 match parse_pothole_params("aria-label=primary data_id=42") {
1052 PotholeContent::Params(p) => {
1053 assert_eq!(p.get("aria-label"), Some("primary"));
1054 assert_eq!(p.get("data_id"), Some("42"));
1055 }
1056 other => panic!("expected Params, got {:?}", other),
1057 }
1058 }
1059
1060 #[test]
1061 fn pothole_obsidian_width_keyword() {
1062 // `[[img.jpg|wide]]` — `wide` is a known width keyword.
1063 match parse_pothole_params("wide") {
1064 PotholeContent::WidthToken { width, rest_alias } => {
1065 assert_eq!(width, "wide");
1066 assert!(rest_alias.is_empty());
1067 }
1068 other => panic!("expected WidthToken, got {:?}", other),
1069 }
1070 }
1071
1072 // --- split_dest_url cases --------------------------------------------
1073
1074 #[test]
1075 fn split_dest_url_plain_file() {
1076 let s = split_dest_url("notes");
1077 assert_eq!(s.file, "notes");
1078 assert_eq!(s.section, None);
1079 assert_eq!(s.query, None);
1080 }
1081
1082 #[test]
1083 fn split_dest_url_with_anchor() {
1084 let s = split_dest_url("notes#section");
1085 assert_eq!(s.file, "notes");
1086 assert_eq!(s.section, Some("section"));
1087 assert_eq!(s.query, None);
1088 }
1089
1090 #[test]
1091 fn split_dest_url_with_query() {
1092 let s = split_dest_url("page.html?x=1");
1093 assert_eq!(s.file, "page.html");
1094 assert_eq!(s.query, Some("x=1"));
1095 }
1096
1097 #[test]
1098 fn split_dest_url_anchor_then_query() {
1099 let s = split_dest_url("page.html#frag?x=1");
1100 assert_eq!(s.file, "page.html");
1101 assert_eq!(s.section, Some("frag"));
1102 assert_eq!(s.query, Some("x=1"));
1103 }
1104
1105 #[test]
1106 fn split_dest_url_query_then_anchor() {
1107 // Both '?' and '#' present, '?' first — query owns its tail; '#' splits out.
1108 let s = split_dest_url("page.html?x=1#frag");
1109 assert_eq!(s.file, "page.html");
1110 // query is [q+1..h] => "x=1"
1111 assert_eq!(s.query, Some("x=1"));
1112 // section is [h+1..] => "frag"
1113 assert_eq!(s.section, Some("frag"));
1114 }
1115
1116 // --- dispatch_wikilink_embed integration -----------------------------
1117 //
1118 // Use a minimal ContentGraph that registers a few paths. We rely on
1119 // ContentGraph::resolve_path() to map bare names back to filesystem-
1120 // looking paths (the same surface Stage 1 uses).
1121
1122 fn build_graph(paths: &[&str]) -> ContentGraph {
1123 let mut b = ContentGraphBuilder::new();
1124 for p in paths {
1125 // Derive a simple slug from the filename stem; the slug is
1126 // only relevant for slug-based resolution which our tests
1127 // don't exercise (they use bare filenames matching `path`).
1128 let slug = std::path::Path::new(p)
1129 .file_stem()
1130 .and_then(|s| s.to_str())
1131 .unwrap_or(p);
1132 b.add_file(p, slug);
1133 }
1134 b.build()
1135 }
1136
1137 /// Helper: empty AssetSnapshot. Phase 3 PR4.5 (2026-05-27) added the
1138 /// `assets` parameter to dispatch_wikilink_embed so non-image embed
1139 /// kinds can route directly to their HTML synthesizers.
1140 fn empty_snapshot() -> AssetSnapshot {
1141 AssetSnapshot::new()
1142 }
1143
1144 #[test]
1145 fn dispatch_bare_wikilink_is_link() {
1146 let graph = build_graph(&["notes.md"]);
1147 let emit = dispatch_wikilink_embed(
1148 "notes",
1149 None,
1150 /* is_embed */ false,
1151 &graph,
1152 "index.md",
1153 &empty_snapshot(),
1154 );
1155 match emit.output {
1156 EmitKind::Link(link) => {
1157 assert!(link.contains("notes"));
1158 assert!(link.contains("moss-resolved:"));
1159 }
1160 other => panic!("expected Link, got {:?}", other),
1161 }
1162 assert!(emit.outgoing_link.is_some());
1163 assert!(emit.diagnostics.is_empty());
1164 }
1165
1166 #[test]
1167 fn dispatch_wikilink_with_alias_uses_alias_text() {
1168 let graph = build_graph(&["notes.md"]);
1169 let emit = dispatch_wikilink_embed(
1170 "notes",
1171 Some("My alias"),
1172 false,
1173 &graph,
1174 "index.md",
1175 &empty_snapshot(),
1176 );
1177 match emit.output {
1178 EmitKind::Link(link) => {
1179 assert!(link.starts_with("[My alias]"));
1180 }
1181 other => panic!("expected Link, got {:?}", other),
1182 }
1183 }
1184
1185 #[test]
1186 fn dispatch_unresolved_wikilink_emits_diagnostic() {
1187 let graph = build_graph(&[]);
1188 let emit = dispatch_wikilink_embed(
1189 "missing",
1190 None,
1191 false,
1192 &graph,
1193 "index.md",
1194 &empty_snapshot(),
1195 );
1196 assert_eq!(emit.diagnostics.len(), 1);
1197 match emit.output {
1198 EmitKind::Link(link) => assert!(link.contains("moss-unresolved:")),
1199 other => panic!("expected Link, got {:?}", other),
1200 }
1201 }
1202
1203 #[test]
1204 fn dispatch_anchor_wikilink_preserves_section_in_href() {
1205 let graph = build_graph(&["notes.md"]);
1206 let emit = dispatch_wikilink_embed(
1207 "notes#section",
1208 None,
1209 false,
1210 &graph,
1211 "index.md",
1212 &empty_snapshot(),
1213 );
1214 match emit.output {
1215 EmitKind::Link(link) => {
1216 assert!(link.contains("moss-resolved:"));
1217 // Anchor preserved (Obsidian-style heading-anchor slug).
1218 assert!(link.contains("#section"), "got: {}", link);
1219 }
1220 other => panic!("expected Link, got {:?}", other),
1221 }
1222 }
1223
1224 // --- build_anchor / dispatch_wikilink_form (`[[…]]` text-link) -------
1225 //
1226 // SCOPE WARNING — read before trusting these as link-path coverage.
1227 //
1228 // The three tests below drive `dispatch_wikilink_embed(..., is_embed:
1229 // false, ..)`, i.e. the `dispatch_wikilink_form` branch and its
1230 // `build_anchor` helper. That branch is the ONLY caller of `build_anchor`,
1231 // and in the LIVE build it is DORMANT: the sole production caller of this
1232 // dispatcher — the AST visitor `crate::ast::dispatch_wikilink_embeds`
1233 // (`ast/dispatch_wikilink_embeds.rs`) — hard-codes `is_embed: true`
1234 // (it only walks `![[…]]` image-embed `Inline::Image` nodes). Plain
1235 // `[[Page#Heading]]` TEXT links never reach this function in production;
1236 // they arrive as `Inline::Link { is_wikilink: true }` and are resolved
1237 // by `crate::ast::resolve_urls`, whose `slug_wikilink_suffix` performs
1238 // the user-facing `#Heading → #heading` slugging.
1239 //
1240 // ==> The REAL guards for `[[Page#Heading]]` text-link slugging live in
1241 // `crates/moss-core/src/ast/resolve_urls.rs`:
1242 // - wikilink_cross_page_fragment_is_slugged
1243 // - wikilink_same_page_fragment_is_slugged
1244 // - markdown_link_fragment_stays_raw_not_slugged
1245 // - wikilink_block_ref_keeps_id_raw
1246 // - wikilink_cjk_fragment_preserved
1247 // - slug_wikilink_suffix_preserves_query
1248 //
1249 // These three tests are kept because `build_anchor` is real code worth
1250 // locking (it mirrors `slug_wikilink_suffix`, and a plugin/CLI caller
1251 // could pass `is_embed: false`), NOT because they cover the live link
1252 // path. Their names are deliberately `build_anchor_*` so a future reader
1253 // is not misled into thinking text-link resolution is guarded here.
1254
1255 #[test]
1256 fn build_anchor_slugs_section_fragment() {
1257 // Helper-path test (NOT the live `[[…]]` link path — see SCOPE
1258 // WARNING above; the live guard is
1259 // `resolve_urls::wikilink_cross_page_fragment_is_slugged`).
1260 //
1261 // `dispatch_wikilink_form("notes#My Heading")` slugs the section
1262 // fragment via `build_anchor` → `obsidian_heading_anchor` →
1263 // `#my-heading`.
1264 // Emitted output: `[notes > My Heading](moss-resolved:notes.md#my-heading)`.
1265 let graph = build_graph(&["notes.md"]);
1266 let emit = dispatch_wikilink_embed(
1267 "notes#My Heading",
1268 None,
1269 false,
1270 &graph,
1271 "index.md",
1272 &empty_snapshot(),
1273 );
1274 match emit.output {
1275 EmitKind::Link(link) => {
1276 assert!(link.contains("#my-heading"), "got: {}", link);
1277 assert!(link.contains("moss-resolved:"), "got: {}", link);
1278 }
1279 other => panic!("expected Link, got {:?}", other),
1280 }
1281 }
1282
1283 #[test]
1284 fn build_anchor_same_page_emits_bare_anchor() {
1285 // Helper-path test (NOT the live `[[…]]` link path — see SCOPE
1286 // WARNING above; the live guard is
1287 // `resolve_urls::wikilink_same_page_fragment_is_slugged`).
1288 //
1289 // `dispatch_wikilink_form("#My Heading")` (empty file part) resolves
1290 // to a bare slugged anchor with no `moss-resolved:` prefix.
1291 // Emitted output: `[My Heading](#my-heading)`.
1292 let graph = build_graph(&["notes.md"]);
1293 let emit = dispatch_wikilink_embed(
1294 "#My Heading",
1295 None,
1296 false,
1297 &graph,
1298 "notes.md",
1299 &empty_snapshot(),
1300 );
1301 match emit.output {
1302 EmitKind::Link(link) => {
1303 assert!(link.contains("(#my-heading)"), "got: {}", link);
1304 assert!(!link.contains("moss-resolved:"), "got: {}", link);
1305 }
1306 other => panic!("expected Link, got {:?}", other),
1307 }
1308 }
1309
1310 #[test]
1311 fn build_anchor_block_ref_is_not_slugged() {
1312 // Helper-path test (NOT the live `[[…]]` link path — see SCOPE
1313 // WARNING above; the live guard is
1314 // `resolve_urls::wikilink_block_ref_keeps_id_raw`).
1315 //
1316 // Block refs (^id) are emitted RAW — NOT run through
1317 // obsidian_heading_anchor. Use a block-id with a space + uppercase
1318 // so slugging (which would yield "#block-id") is observably
1319 // different from the raw form ("#Block Id"). This fails loudly if
1320 // the `^` short-circuit in build_anchor regresses.
1321 // Emitted output: `[notes > ^Block Id](moss-resolved:notes.md#Block Id)`.
1322 let graph = build_graph(&["notes.md"]);
1323 let emit = dispatch_wikilink_embed(
1324 "notes#^Block Id",
1325 None,
1326 false,
1327 &graph,
1328 "index.md",
1329 &empty_snapshot(),
1330 );
1331 match emit.output {
1332 EmitKind::Link(link) => {
1333 assert!(link.contains("#Block Id"), "expected raw block-ref, got: {}", link);
1334 assert!(!link.contains("#block-id"), "block-ref was slugged: {}", link);
1335 }
1336 other => panic!("expected Link, got {:?}", other),
1337 }
1338 }
1339
1340 #[test]
1341 fn dispatch_video_extension_routes_to_synth() {
1342 // Phase 3 PR4.5 (2026-05-27): non-image wikilinks now route
1343 // DIRECTLY to the per-kind synthesizer — the markdown round-trip
1344 // is gone (it was dropping the `moss:kind=…` title since PR2 and
1345 // entirely silent after PR4 deleted `parse_title`). The dispatcher
1346 // returns `EmitKind::Html` carrying the `<video>` byte shape; we
1347 // pin only the structural identity (element + src) so byte-shape
1348 // changes are owned by the synth tests in `render/video.rs`.
1349 let graph = build_graph(&["clip.mp4"]);
1350 let emit = dispatch_wikilink_embed(
1351 "clip.mp4",
1352 None,
1353 true,
1354 &graph,
1355 "index.md",
1356 &empty_snapshot(),
1357 );
1358 match emit.output {
1359 EmitKind::Html(s) => {
1360 assert!(s.contains("<video"), "expected <video>, got: {}", s);
1361 assert!(s.contains(r#"src="clip.mp4""#), "expected src=, got: {}", s);
1362 assert!(s.contains("moss-embed-video"), "expected class, got: {}", s);
1363 }
1364 other => panic!("expected Html, got: {:?}", other),
1365 }
1366 }
1367
1368 #[test]
1369 fn dispatch_pdf_extension_routes_to_synth() {
1370 // See `dispatch_video_extension_routes_to_synth` for the PR4.5
1371 // routing rationale. PdfRenderer emits an `<object type="application/pdf">`.
1372 let graph = build_graph(&["report.pdf"]);
1373 let emit = dispatch_wikilink_embed(
1374 "report.pdf",
1375 None,
1376 true,
1377 &graph,
1378 "index.md",
1379 &empty_snapshot(),
1380 );
1381 match emit.output {
1382 EmitKind::Html(s) => {
1383 assert!(s.contains("<object"), "expected <object>, got: {}", s);
1384 assert!(
1385 s.contains(r#"data="report.pdf""#),
1386 "expected data=, got: {}",
1387 s
1388 );
1389 assert!(
1390 s.contains(r#"type="application/pdf""#),
1391 "expected type=, got: {}",
1392 s
1393 );
1394 }
1395 other => panic!("expected Html, got: {:?}", other),
1396 }
1397 }
1398
1399 #[test]
1400 fn dispatch_audio_extension_routes_to_synth() {
1401 let graph = build_graph(&["song.mp3"]);
1402 let emit = dispatch_wikilink_embed(
1403 "song.mp3",
1404 None,
1405 true,
1406 &graph,
1407 "index.md",
1408 &empty_snapshot(),
1409 );
1410 match emit.output {
1411 EmitKind::Html(s) => {
1412 assert!(s.contains("<audio"), "expected <audio>, got: {}", s);
1413 assert!(s.contains(r#"src="song.mp3""#), "expected src=, got: {}", s);
1414 assert!(
1415 s.contains(r#"type="audio/mpeg""#),
1416 "expected MIME, got: {}",
1417 s
1418 );
1419 }
1420 other => panic!("expected Html, got: {:?}", other),
1421 }
1422 }
1423
1424 #[test]
1425 fn dispatch_iframe_extension_routes_to_synth() {
1426 let graph = build_graph(&["widget.html"]);
1427 let emit = dispatch_wikilink_embed(
1428 "widget.html",
1429 None,
1430 true,
1431 &graph,
1432 "index.md",
1433 &empty_snapshot(),
1434 );
1435 match emit.output {
1436 EmitKind::Html(s) => {
1437 assert!(s.contains("<iframe"), "expected <iframe>, got: {}", s);
1438 assert!(
1439 s.contains(r#"src="widget.html""#),
1440 "expected src=, got: {}",
1441 s
1442 );
1443 }
1444 other => panic!("expected Html, got: {:?}", other),
1445 }
1446 }
1447
1448 #[test]
1449 fn dispatch_model_extension_routes_to_synth() {
1450 let graph = build_graph(&["scene.glb"]);
1451 let emit = dispatch_wikilink_embed(
1452 "scene.glb",
1453 None,
1454 true,
1455 &graph,
1456 "index.md",
1457 &empty_snapshot(),
1458 );
1459 match emit.output {
1460 EmitKind::Html(s) => {
1461 assert!(
1462 s.contains("<model-viewer"),
1463 "expected <model-viewer>, got: {}",
1464 s
1465 );
1466 assert!(
1467 s.contains(r#"src="scene.glb""#),
1468 "expected src=, got: {}",
1469 s
1470 );
1471 }
1472 other => panic!("expected Html, got: {:?}", other),
1473 }
1474 }
1475
1476 #[test]
1477 fn dispatch_iframe_alias_carries_title() {
1478 // `![[widget.html|Embedded Widget]]` — non-sizing alias text
1479 // surfaces on the iframe as the `title=` accessible name. The
1480 // synth function reads `params.get("title")`; `build_synth_params`
1481 // routes the alias there for iframe-kind.
1482 let graph = build_graph(&["widget.html"]);
1483 let emit = dispatch_wikilink_embed(
1484 "widget.html",
1485 Some("Embedded Widget"),
1486 true,
1487 &graph,
1488 "index.md",
1489 &empty_snapshot(),
1490 );
1491 match emit.output {
1492 EmitKind::Html(s) => {
1493 assert!(s.contains(r#"title="Embedded Widget""#), "got: {}", s);
1494 }
1495 other => panic!("expected Html, got: {:?}", other),
1496 }
1497 }
1498
1499 #[test]
1500 fn dispatch_video_sizing_alias_propagates_dims() {
1501 // `![[clip.mp4|640x360]]` — sizing alias becomes width/height
1502 // CSS-formatted on the <video>.
1503 let graph = build_graph(&["clip.mp4"]);
1504 let emit = dispatch_wikilink_embed(
1505 "clip.mp4",
1506 Some("640x360"),
1507 true,
1508 &graph,
1509 "index.md",
1510 &empty_snapshot(),
1511 );
1512 match emit.output {
1513 EmitKind::Html(s) => {
1514 assert!(s.contains(r#"width="640px""#), "got: {}", s);
1515 assert!(s.contains(r#"height="360px""#), "got: {}", s);
1516 }
1517 other => panic!("expected Html, got: {:?}", other),
1518 }
1519 }
1520
1521 // --- Image display-attr dispatch (fit / position threading) ----------
1522 //
1523 // The polish-pass plan (docs/plans/2026-05-27-polish-passes-followups.md
1524 // Item B) flagged that `![[hero.jpg|cover]]` and
1525 // `![[hero.jpg|fit=cover position=left]]` were silently dropping
1526 // fit/position. `ImageRenderer::render_to_markdown` builds `TitleParams`
1527 // from the alias / pothole, then explicitly discards them with
1528 // `let _ = params;` — the emitted markdown is bare ``. The
1529 // dispatcher now intercepts these cases ahead of the renderer registry
1530 // and emits a final `<img>` with the appropriate `style=`.
1531
1532 #[test]
1533 fn dispatch_only_fires_for_wikilink_caller() {
1534 // This test documents the safety rule from v2 revision notes:
1535 // dispatch_wikilink_embed is the ONLY public entry point for
1536 // wikilink-form events. There is no parallel function for
1537 // LinkType::Inline. Plain `[link](file.pdf)` events stay as
1538 // markdown links via pulldown-cmark's default emission.
1539 //
1540 // We can't directly test what the caller does (that's in pipeline.rs),
1541 // but we can pin the invariant by asserting the API surface:
1542 // the public function takes `is_embed: bool` for `![[…]]` vs
1543 // `[[…]]`, not a `LinkType` enum that could be confused with Inline.
1544
1545 // No assertion needed — the type signature itself is the check.
1546 }
1547 // ---- Image embed dispatch (image-embed synth-collapse) ---------------
1548 //
1549 // ALL `![[photo.jpg]]` forms now emit `EmitKind::Block(Block::Figure)`
1550 // with full param threading (width / caption / fit / position / align).
1551 // The OLD tests asserted `EmitKind::Inline("")` round-trips and
1552 // a fit/position fast-path that DROPPED width — they encoded the bug
1553 // this change fixes and were removed.
1554
1555 fn figure_of(emit: &WikilinkEmit) -> &crate::ast::node::Block {
1556 match &emit.output {
1557 EmitKind::Block(b) => b.as_ref(),
1558 other => panic!("expected EmitKind::Block(Figure), got {other:?}"),
1559 }
1560 }
1561
1562 fn render_figure(emit: &WikilinkEmit) -> String {
1563 let block = match emit.output.clone() {
1564 EmitKind::Block(b) => *b,
1565 other => panic!("expected EmitKind::Block(Figure), got {other:?}"),
1566 };
1567 let doc = crate::ast::Document::from_blocks(vec![block]);
1568 crate::ast::render_document(&doc, &crate::ast::DefaultHooks::new())
1569 }
1570
1571 fn dispatch_img(alias: Option<&str>) -> WikilinkEmit {
1572 let graph = build_graph(&["photo.jpg", "hero.jpg"]);
1573 dispatch_wikilink_embed("photo.jpg", alias, true, &graph, "index.md", &empty_snapshot())
1574 }
1575
1576 #[test]
1577 fn dispatch_image_plain_emits_figure_block() {
1578 use crate::ast::node::{Block, Inline};
1579 let emit = dispatch_img(None);
1580 match figure_of(&emit) {
1581 Block::Figure { image, caption, width, align, class_names, img_style } => {
1582 assert!(caption.is_none(), "plain embed: no caption");
1583 assert!(width.is_none());
1584 assert!(align.is_none());
1585 assert!(class_names.is_empty());
1586 assert!(img_style.is_none());
1587 match image {
1588 Inline::Image { src, alt, is_wikilink, .. } => {
1589 assert!(src.is_resolved());
1590 assert_eq!(alt, "");
1591 assert!(*is_wikilink);
1592 }
1593 other => panic!("expected Image, got {other:?}"),
1594 }
1595 }
1596 other => panic!("expected Figure, got {other:?}"),
1597 }
1598 }
1599
1600 #[test]
1601 fn dispatch_image_caption_text_sets_alt_and_figcaption() {
1602 use crate::ast::node::{Block, Inline};
1603 let emit = dispatch_img(Some("My caption"));
1604 match figure_of(&emit) {
1605 Block::Figure { image, caption, .. } => {
1606 let cap = caption.as_ref().expect("caption present");
1607 assert_eq!(cap.len(), 1);
1608 match &cap[0] {
1609 Inline::Text(t) => assert_eq!(t, "My caption"),
1610 other => panic!("expected caption Text, got {other:?}"),
1611 }
1612 match image {
1613 Inline::Image { alt, .. } => assert_eq!(alt, "My caption"),
1614 other => panic!("expected Image, got {other:?}"),
1615 }
1616 }
1617 other => panic!("expected Figure, got {other:?}"),
1618 }
1619 let html = render_figure(&emit);
1620 assert!(html.contains(r#"alt="My caption""#), "got: {html}");
1621 assert!(html.contains("<figcaption>My caption</figcaption>"), "got: {html}");
1622 }
1623
1624 #[test]
1625 fn dispatch_image_width_token_preserved_as_data_width() {
1626 use crate::ast::node::Block;
1627 // FIX: width is no longer dropped — it lands as figure data-width=.
1628 let emit = dispatch_img(Some("wide"));
1629 match figure_of(&emit) {
1630 Block::Figure { width, caption, .. } => {
1631 assert_eq!(width.as_deref(), Some("wide"));
1632 assert!(caption.is_none(), "width token is not a caption");
1633 }
1634 other => panic!("expected Figure, got {other:?}"),
1635 }
1636 let html = render_figure(&emit);
1637 assert!(html.contains(r#"data-width="wide""#), "got: {html}");
1638 }
1639
1640 #[test]
1641 fn dispatch_image_cover_emits_object_fit_on_inner_img() {
1642 use crate::ast::node::Block;
1643 let emit = dispatch_img(Some("cover"));
1644 match figure_of(&emit) {
1645 Block::Figure { img_style, caption, .. } => {
1646 assert_eq!(img_style.as_deref(), Some("object-fit:cover"));
1647 assert!(caption.is_none(), "structural alias is not a caption");
1648 }
1649 other => panic!("expected Figure, got {other:?}"),
1650 }
1651 let html = render_figure(&emit);
1652 assert!(html.contains("object-fit:cover"), "got: {html}");
1653 assert!(html.contains(r#"<figure class="moss-image""#), "got: {html}");
1654 }
1655
1656 #[test]
1657 fn dispatch_image_cover_left_emits_fit_and_position() {
1658 use crate::ast::node::Block;
1659 let emit = dispatch_img(Some("cover left"));
1660 match figure_of(&emit) {
1661 Block::Figure { img_style, .. } => {
1662 let style = img_style.as_deref().expect("style present");
1663 assert!(style.contains("object-fit:cover"), "got: {style}");
1664 assert!(style.contains("object-position:left"), "got: {style}");
1665 }
1666 other => panic!("expected Figure, got {other:?}"),
1667 }
1668 }
1669
1670 #[test]
1671 fn dispatch_image_params_form_emits_object_fit() {
1672 use crate::ast::node::Block;
1673 let emit = dispatch_img(Some("fit=cover"));
1674 match figure_of(&emit) {
1675 Block::Figure { img_style, .. } => {
1676 assert_eq!(img_style.as_deref(), Some("object-fit:cover"));
1677 }
1678 other => panic!("expected Figure, got {other:?}"),
1679 }
1680 }
1681
1682 #[test]
1683 fn dispatch_image_two_word_position_combines() {
1684 use crate::ast::node::Block;
1685 let emit = dispatch_img(Some("cover top left"));
1686 match figure_of(&emit) {
1687 Block::Figure { img_style, .. } => {
1688 let style = img_style.as_deref().expect("style present");
1689 assert!(style.contains("object-fit:cover"), "got: {style}");
1690 assert!(style.contains("object-position:top left"), "got: {style}");
1691 }
1692 other => panic!("expected Figure, got {other:?}"),
1693 }
1694 }
1695
1696 #[test]
1697 fn dispatch_image_wide_cover_combines_width_and_fit() {
1698 use crate::ast::node::Block;
1699 // width → figure data-width; fit → inner <img> style. Both survive
1700 // (the pre-collapse fast-path DROPPED width when fit was present).
1701 let emit = dispatch_img(Some("wide cover"));
1702 match figure_of(&emit) {
1703 Block::Figure { width, img_style, .. } => {
1704 assert_eq!(width.as_deref(), Some("wide"));
1705 assert_eq!(img_style.as_deref(), Some("object-fit:cover"));
1706 }
1707 other => panic!("expected Figure, got {other:?}"),
1708 }
1709 let html = render_figure(&emit);
1710 assert!(html.contains(r#"data-width="wide""#), "got: {html}");
1711 assert!(html.contains("object-fit:cover"), "got: {html}");
1712 }
1713
1714 #[test]
1715 fn dispatch_image_inner_img_has_single_style_attr() {
1716 // Inner <img> carries exactly one style= (object-fit), no LQIP dup
1717 // (no snapshot here).
1718 let emit = dispatch_img(Some("fit=cover"));
1719 let html = render_figure(&emit);
1720 let n = html.matches("style=").count();
1721 assert_eq!(n, 1, "exactly one style= attr, got {n}: {html}");
1722 }
1723
1724}