Skip to main content

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 `![alt](url)`.
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/// Reassemble a `SplitDestUrl` back into a single URL string.
364///
365/// Inverse of `split_dest_url`. Used before external-URL provider detection
366/// so the full URL (including `?query` and `#fragment`) is available.
367///
368/// Note: always emits in canonical `?query#fragment` order regardless of the
369/// original source order. For well-formed URLs (query before fragment) this is
370/// byte-identical to the input. Degenerate `#fragment?query` inputs are silently
371/// reordered — acceptable for external URL embeds where providers only accept
372/// canonical query-first URLs.
373fn reassemble_url(split: &SplitDestUrl<'_>) -> String {
374    let mut url = split.file.to_string();
375    if let Some(q) = split.query {
376        url.push('?');
377        url.push_str(q);
378    }
379    if let Some(s) = split.section {
380        url.push('#');
381        url.push_str(s);
382    }
383    url
384}
385
386/// Dispatch for `![[…]]` (embed form). Mirrors `resolve_embed`'s body.
387fn dispatch_embed_form(
388    split: &SplitDestUrl<'_>,
389    pothole: PotholeContent,
390    graph: &ContentGraph,
391    from_path: &str,
392    assets: &AssetSnapshot,
393    lookup: &dyn Fn(&str) -> Option<&dyn EmbedRenderer>,
394) -> WikilinkEmit {
395    let mut diagnostics: Vec<Diagnostic> = Vec::new();
396
397    // External URL embed: bypass ContentGraph resolution entirely.
398    // Any http:// or https:// URL is synthesized as an iframe directly.
399    // Provider detection (YouTube/Vimeo/CodePen) happens inside
400    // synthesize_url_embed_html; unrecognised URLs get a generic <iframe>.
401    if split.file.starts_with("http://") || split.file.starts_with("https://") {
402        let full_url = reassemble_url(split);
403        let html = crate::render::url_embed::synthesize_url_embed_html(
404            &full_url,
405            &pothole,
406            assets,
407        );
408        return WikilinkEmit {
409            output: EmitKind::Html(html),
410            outgoing_link: None,
411            diagnostics: vec![],
412        };
413    }
414
415    // Phase 3 PR2: trailing-slash dispatch is the folder-list embed
416    // (`![[/journal/]]`). We must check this BEFORE `resolve_reference`
417    // because ContentGraph::resolve_path normalizes trailing slashes
418    // away — running it first would always discard the folder-embed
419    // signal. The actual listing is rendered by the src-tauri marker
420    // resolver (Task 16) which has `all_docs` available; here we just
421    // emit a marker carrying the user-written path + the source file
422    // path (for relative resolution).
423    //
424    // Pothole text after `|` becomes the folder-list params string
425    // (e.g. `limit:5,more,sort:date`). We parse it back from whatever
426    // pothole shape pulldown-cmark gave us.
427    if !split.file.is_empty() && split.file.ends_with('/') {
428        let pothole_raw = match &pothole {
429            PotholeContent::Empty => String::new(),
430            PotholeContent::WidthToken { rest_alias, .. } => rest_alias.clone(),
431            PotholeContent::Params(_) => String::new(),
432            PotholeContent::Alias(s) => s.clone(),
433        };
434        let params = super::embed_renderer::folder_list::parse_params(&pothole_raw);
435        let marker =
436            super::embed_renderer::folder_list::emit_marker(split.file, from_path, &params);
437        return WikilinkEmit {
438            output: EmitKind::Html(marker),
439            outgoing_link: Some(OutgoingLink {
440                target_path: split.file.to_string(),
441                display_text: split.file.to_string(),
442                link_type: LinkType::Embed,
443            }),
444            diagnostics,
445        };
446    }
447
448    // Resolve. Same logic as resolve_embed: empty file → same file;
449    // non-empty → fuzzy resolve.
450    let resolved = if split.file.is_empty() {
451        ResolvedRef::Found(from_path.to_string())
452    } else {
453        resolve_reference(split.file, graph, from_path)
454    };
455
456    // Derive `alias` and `width` for ParsedEmbed from the pothole.
457    // For PotholeContent::Params we surface no alias; the params are
458    // carried via TitleParams (consumers in PR4 onward can read them
459    // directly without round-tripping through the `moss:` title channel).
460    // For PR1 the params get folded into the renderer via the same path
461    // Stage 1 uses today: there's no Stage-2 consumer yet, so we forward
462    // alias as None when we have pure params.
463    let (alias_owned, width): (Option<String>, Option<&'static str>) = match &pothole {
464        PotholeContent::Empty => (None, None),
465        PotholeContent::WidthToken { width, rest_alias } => (
466            if rest_alias.is_empty() {
467                None
468            } else {
469                Some(rest_alias.clone())
470            },
471            Some(*width),
472        ),
473        PotholeContent::Params(_) => (None, None),
474        PotholeContent::Alias(s) => (Some(s.clone()), None),
475    };
476
477    match resolved {
478        ResolvedRef::Found(target_path) => {
479            let outgoing = OutgoingLink {
480                target_path: target_path.clone(),
481                display_text: split.file.to_string(),
482                link_type: LinkType::Embed,
483            };
484
485            let parsed = ParsedEmbed {
486                resolved_path: &target_path,
487                from_path,
488                query: split.query,
489                section: split.section,
490                alias: alias_owned.as_deref(),
491                width,
492                attrs: None,
493            };
494
495            // Phase 3 PR4.5 (2026-05-27): non-image wikilink embeds
496            // (video / pdf / audio / iframe / 3D) route DIRECTLY to the
497            // typed-HTML synthesizer here. Previously they emitted
498            // `EmitKind::Inline(markdown_link)` with a `moss:kind=…`
499            // title that Stage 2 was supposed to read back via
500            // `parse_title` — but PR4 deleted `parse_title`, and PR2's
501            // markdown round-trip had already been dropping the title.
502            // The result was non-image embeds rendering as plain
503            // `<a href>` links. The fix is to skip the round-trip
504            // entirely: derive `TitleParams` from the pothole content
505            // and the resolved URL, then hand them straight to the
506            // per-kind synthesizer. Image embeds keep their inline-
507            // markdown emission so `<picture>` / `<figure>` wrap stays
508            // in the markdown round-trip path that already worked.
509            let ext = path_extension(&target_path);
510            let url = relative_asset_path(from_path, &target_path);
511            if let Some(synth_kind) = ext.as_deref().and_then(synth_kind_for_ext) {
512                let params = build_synth_params(synth_kind, &parsed, &pothole);
513                let html = match synth_kind {
514                    SynthKind::Video => {
515                        crate::render::video::synthesize_video_html(&params, &url, assets)
516                    }
517                    SynthKind::Pdf => {
518                        crate::render::pdf::synthesize_pdf_html(&params, &url, assets)
519                    }
520                    SynthKind::Audio => {
521                        crate::render::audio::synthesize_audio_html(&params, &url, assets)
522                    }
523                    SynthKind::Iframe => {
524                        crate::render::iframe::synthesize_iframe_html(&params, &url, assets)
525                    }
526                    SynthKind::Model => {
527                        crate::render::model::synthesize_model_html(&params, &url, assets)
528                    }
529                };
530                return WikilinkEmit {
531                    output: EmitKind::Html(html),
532                    outgoing_link: Some(outgoing),
533                    diagnostics,
534                };
535            }
536
537            // Image embeds — the unified arm (image-embed synth-collapse).
538            //
539            // ALL `![[photo.jpg]]` forms route through here to a typed
540            // `Block::Figure`, the SAME node the CommonMark `![](url)` path
541            // produces. This replaced the prior split (a fit/position
542            // "fast-path" that emitted bare `<picture>` + an
543            // `ImageRenderer::render_to_markdown` round-trip that dropped
544            // width via `let _ = params`). Six embed kinds already went
545            // dispatch → synth → Html; image now matches via
546            // dispatch → Block::Figure → render_document.
547            //
548            // Four sources of display params are assembled into the figure:
549            //   1. width  ← `embed.width` (canonical WidthToken) → figure `data-width=`
550            //   2. caption + alt ← `classify_image_alias` (structural → none,
551            //      caption-text → both, empty → none; never `Some("")`)
552            //   3. fit/position ← `build_image_media_attrs` → `to_inline_style()`
553            //      → inner `<img>` `style=` (NOT the figure)
554            //   4. align + class_names ← `build_image_media_attrs` → figure class list
555            //
556            // Emitting `EmitKind::Block` (not `EmitKind::Html`) keeps the
557            // 1:1 `apply_emit` substitution so the figure inherits the source
558            // paragraph's `block_meta` → `data-source-line` survives.
559            //
560            // `find_lone_wikilink_image` guarantees the dispatcher is only
561            // reached for a lone embed (within its container), so the figure
562            // shape is always correct here.
563            if matches!(ext.as_deref(), Some(e) if IMAGE_EXTENSIONS.iter().any(|x| *x == e)) {
564                let media = build_image_media_attrs(&pothole, parsed.attrs.as_ref());
565                // Recover a content-relative percent (`|55%`) from the alias.
566                // A percent isn't a named width token, so `parse_pothole_params`
567                // classifies it as `Alias` and it would otherwise leak into the
568                // caption. Split it here so the figure carries the width and the
569                // caption is the remaining (width-stripped) alias. Recovered here
570                // (not in `parse_pothole_params`) so the shared pothole classifier
571                // stays width-vocabulary-agnostic.
572                // Sync: the no-graph twin lives in ast/parser.rs::try_promote_to_figure
573                // (wikilink_pothole arm) — both split width via media::split_alt_width.
574                let (alias_no_width, pct_width): (Option<String>, Option<String>) =
575                    match parsed.alias {
576                        Some(a) => {
577                            let (rest, w) = crate::media::split_alt_width(a);
578                            (Some(rest), w)
579                        }
580                        None => (None, None),
581                    };
582                let alias_class =
583                    crate::media::classify_image_alias(alias_no_width.as_deref());
584                let alt = alias_class.caption.clone().unwrap_or_default();
585                let caption: Option<Vec<crate::ast::node::Inline>> = alias_class
586                    .caption
587                    .map(|c| vec![crate::ast::node::Inline::Text(c)]);
588                // `AlignSide::css_class()` returns the canonical
589                // `moss-align-left` / `moss-align-right` class verbatim —
590                // the same class the figure renderer appends.
591                let align = media.align.map(|side| side.css_class().to_string());
592                let img_style = media.to_inline_style();
593                // Width source, in priority order:
594                //  1. canonical pothole WidthToken (`|wide`) — `width`
595                //  2. a width token embedded in a structural alias (`|wide cover`)
596                //  3. a content-relative percent anywhere in the pothole (`|55%`)
597                let figure_width: Option<String> = width
598                    .map(|w| w.to_string())
599                    .or_else(|| {
600                        alias_class.display_keywords.as_deref().and_then(|kw| {
601                            kw.split_whitespace()
602                                .find_map(crate::media::match_width_token)
603                                .map(|w| w.to_string())
604                        })
605                    })
606                    .or(pct_width);
607                let figure = crate::ast::node::Block::Figure {
608                    image: crate::ast::node::Inline::Image {
609                        // `Asset` is the canonical kind for an `<img src>`
610                        // (matches resolve_urls' image-URL classification).
611                        src: crate::ast::url::Url::resolved(
612                            url.clone(),
613                            crate::ast::url::UrlKind::Asset,
614                        ),
615                        alt,
616                        title: None,
617                        is_wikilink: true,
618                        wikilink_pothole: None,
619                    },
620                    caption,
621                    // Named token OR `"NN%"` percent; the node stores
622                    // `Option<String>` (for Deserialize).
623                    width: figure_width,
624                    align,
625                    class_names: media.class_names,
626                    img_style,
627                };
628                return WikilinkEmit {
629                    output: EmitKind::Block(Box::new(figure)),
630                    outgoing_link: Some(outgoing),
631                    diagnostics,
632                };
633            }
634
635            let emit = match ext.as_deref().and_then(lookup) {
636                Some(r) => match r.render(&parsed) {
637                    RenderedEmbed::Inline(s) => EmitKind::Inline(s),
638                    RenderedEmbed::Html(s) => EmitKind::Html(s),
639                    RenderedEmbed::Deferred { marker } => EmitKind::Deferred(marker),
640                },
641                None => {
642                    // Fallback: plain file link (Obsidian parity for
643                    // unknown extensions).
644                    EmitKind::Inline(format!("[{}]({})", split.file, url))
645                }
646            };
647
648            WikilinkEmit {
649                output: emit,
650                outgoing_link: Some(outgoing),
651                diagnostics,
652            }
653        }
654        ResolvedRef::Unresolved => {
655            diagnostics.push(Diagnostic {
656                message: format!("Unresolved embed: ![[{}]]", split.file),
657                source_path: from_path.to_string(),
658                reference: split.file.to_string(),
659            });
660
661            WikilinkEmit {
662                output: EmitKind::Inline(format!(
663                    "[{}](moss-unresolved:{})",
664                    split.file, split.file
665                )),
666                outgoing_link: Some(OutgoingLink {
667                    target_path: split.file.to_string(),
668                    display_text: split.file.to_string(),
669                    link_type: LinkType::Embed,
670                }),
671                diagnostics,
672            }
673        }
674    }
675}
676
677/// Dispatch for `[[…]]` (plain wikilink). Mirrors `resolve_wikilink`'s body
678/// (the non-embed case).
679///
680/// DORMANT in the live build: the only production caller
681/// ([`crate::ast::dispatch_wikilink_embeds`]) always passes `is_embed: true`,
682/// so plain `[[…]]` text links never route here. They reach the typed AST as
683/// `Inline::Link { is_wikilink: true }` and are resolved by
684/// `crate::ast::resolve_urls` instead. This function remains as a tested
685/// helper (and for any future plugin/CLI caller that passes `is_embed:
686/// false`).
687fn dispatch_wikilink_form(
688    split: &SplitDestUrl<'_>,
689    pothole: PotholeContent,
690    graph: &ContentGraph,
691    from_path: &str,
692) -> WikilinkEmit {
693    let mut diagnostics = Vec::new();
694
695    // For plain wikilinks, only Alias-shaped potholes contribute to
696    // display text. Width tokens and params are meaningless on a
697    // non-embed wikilink — preserve Stage 1 behavior by ignoring them.
698    let alias_display = match &pothole {
699        PotholeContent::Alias(s) => Some(s.clone()),
700        PotholeContent::WidthToken { rest_alias, .. } if !rest_alias.is_empty() => {
701            Some(rest_alias.clone())
702        }
703        _ => None,
704    };
705
706    let display_text = if let Some(a) = alias_display {
707        a
708    } else if let Some(sec) = split.section {
709        if split.file.is_empty() {
710            sec.to_string()
711        } else {
712            format!("{} > {}", split.file, sec)
713        }
714    } else {
715        split.file.to_string()
716    };
717
718    let resolved = if split.file.is_empty() {
719        ResolvedRef::Found(from_path.to_string())
720    } else {
721        resolve_reference(split.file, graph, from_path)
722    };
723
724    match resolved {
725        ResolvedRef::Found(target_path) => {
726            let outgoing = OutgoingLink {
727                target_path: target_path.clone(),
728                display_text: display_text.clone(),
729                link_type: LinkType::Wikilink,
730            };
731
732            let anchor = build_anchor(split.section);
733            let link = if split.file.is_empty() {
734                format!("[{}]({})", display_text, anchor)
735            } else {
736                format!(
737                    "[{}](moss-resolved:{}{})",
738                    display_text, target_path, anchor
739                )
740            };
741
742            WikilinkEmit {
743                output: EmitKind::Link(link),
744                outgoing_link: Some(outgoing),
745                diagnostics,
746            }
747        }
748        ResolvedRef::Unresolved => {
749            diagnostics.push(Diagnostic {
750                message: format!("Unresolved wikilink: [[{}]]", split.file),
751                source_path: from_path.to_string(),
752                reference: split.file.to_string(),
753            });
754            WikilinkEmit {
755                output: EmitKind::Link(format!(
756                    "[{}](moss-unresolved:{})",
757                    display_text, split.file
758                )),
759                outgoing_link: Some(OutgoingLink {
760                    target_path: split.file.to_string(),
761                    display_text,
762                    link_type: LinkType::Wikilink,
763                }),
764                diagnostics,
765            }
766        }
767    }
768}
769
770/// Build [`MediaAttrs`] from a pothole's alias / params.
771///
772/// Two active sources of display vocabulary for image embeds:
773///
774/// - Alias form (`![[hero.jpg|cover left]]`) — whitespace-separated
775///   display keywords. The pothole arrives as
776///   [`PotholeContent::Alias`] or [`PotholeContent::WidthToken::rest_alias`]
777///   when a width token preceded the keywords. `parse_media_attrs` decodes
778///   them into typed `fit` / `position` / `align` fields.
779/// - Params form (`![[hero.jpg|fit=cover position=left]]`) — every token
780///   is `key=value`. The pothole arrives as
781///   [`PotholeContent::Params`] carrying a `TitleParams` bag; we look up
782///   `fit` / `position` / `align` by name and convert their values via the
783///   per-enum `from_keyword`. Unknown keys flow through as `extra_attrs`.
784///
785/// Pandoc attribute blocks (`![[hero.jpg|cover]]{.theme-rounded x="y"}`) are
786/// a third potential source, but [`ParsedEmbed::attrs`] is currently
787/// hard-coded to `None` at the dispatcher's image branch (see
788/// `dispatch_embed_form`). The `attrs` parameter is plumbed through for
789/// future wiring; today the function ignores it. Don't grow the merge
790/// logic here until a caller actually populates `parsed.attrs`.
791fn build_image_media_attrs(
792    pothole: &PotholeContent,
793    _attrs: Option<&crate::ast::attrs::AttrBlock>,
794) -> MediaAttrs {
795    let mut media = MediaAttrs::default();
796
797    // Source 1: alias form. Only fold when the entire alias is structural
798    // (every token is a display keyword) — non-structural aliases are
799    // caption text and don't contribute display params.
800    let alias_text = match pothole {
801        PotholeContent::Alias(s) => Some(s.as_str()),
802        PotholeContent::WidthToken { rest_alias, .. } if !rest_alias.is_empty() => {
803            Some(rest_alias.as_str())
804        }
805        _ => None,
806    };
807    if let Some(text) = alias_text {
808        // Width tokens (`wide`, `screen`, etc.) may appear adjacent to fit /
809        // position keywords in space-separated aliases like
810        // `![[hero|wide cover]]`. They ride on the figure wrapper via
811        // `embed.width`, not the inner `<img>`; strip them here so the
812        // remainder ("cover") parses cleanly through `parse_media_attrs`.
813        // Without this, `is_all_display_keywords("wide cover")` returns
814        // `false` (because "wide" isn't a display keyword) and we'd
815        // silently drop the fit/position — the same regression this branch
816        // exists to fix.
817        let cleaned: Vec<&str> = text
818            .split_whitespace()
819            .filter(|t| crate::media::match_width_token(t).is_none())
820            .collect();
821        let cleaned_str = cleaned.join(" ");
822        if !cleaned_str.is_empty() && crate::media::is_all_display_keywords(&cleaned_str) {
823            let parsed = parse_media_attrs(&cleaned_str);
824            media.fit = parsed.fit;
825            media.position = parsed.position;
826            media.align = parsed.align;
827            // `parse_media_attrs` doesn't populate `class_names` or
828            // `extra_attrs` today (those come from Pandoc blocks, which
829            // aren't wired). The extends here are forward-looking scaffolding
830            // — harmless no-ops on current `MediaAttrs` shape.
831            media.class_names.extend(parsed.class_names);
832            for (k, v) in parsed.extra_attrs {
833                media.extra_attrs.insert(k, v);
834            }
835        }
836    }
837
838    // Source 2: Params form (K=V pothole). Recognized keys override; the
839    // rest flow through as `extra_attrs`.
840    //
841    // `style` is filtered OUT here because `synthesize_image_with_media_attrs`
842    // builds the `style="…"` attribute from `MediaAttrs::to_inline_style()`;
843    // letting an author-typed `style=foo` ALSO flow into `extra_attrs` would
844    // emit two `style=` attributes on the same `<img>` and the browser would
845    // honor the last one, silently dropping moss's object-fit / object-position.
846    if let PotholeContent::Params(params) = pothole {
847        for (k, v) in &params.params {
848            match k.as_str() {
849                "fit" => {
850                    if let Some(fit) = Fit::from_keyword(v) {
851                        media.fit = Some(fit);
852                    }
853                }
854                "position" => {
855                    if let Some(pos) = Position::from_keyword(v) {
856                        media.position = Some(pos);
857                    }
858                }
859                "align" => {
860                    if let Some(side) = AlignSide::from_keyword(v) {
861                        media.align = Some(side);
862                    }
863                }
864                // `width` / `data-width` ride on the figure wrapper, not the
865                // inner `<img>` — handled upstream via `embed.width`.
866                "width" | "data-width" => {}
867                "classes" => {
868                    for c in v.split_whitespace() {
869                        if !media.class_names.iter().any(|x| x == c) {
870                            media.class_names.push(c.to_string());
871                        }
872                    }
873                }
874                // Drop `style=` to avoid duplicate-attribute emission;
875                // see function-level note above.
876                "style" => {}
877                _ => {
878                    media.extra_attrs.insert(k.clone(), v.clone());
879                }
880            }
881        }
882    }
883
884    media
885}
886
887/// Discriminant for the per-kind HTML synthesizer the dispatcher routes to
888/// directly (Phase 3 PR4.5). Non-image / non-deferred extensions skip the
889/// markdown round-trip and emit `EmitKind::Html` straight from the synth
890/// function — see the dispatcher branch in `dispatch_embed_form`.
891#[derive(Debug, Clone, Copy, PartialEq, Eq)]
892enum SynthKind {
893    Video,
894    Pdf,
895    Audio,
896    Iframe,
897    Model,
898}
899
900/// Classify a file extension into a [`SynthKind`] when the dispatcher should
901/// emit final HTML directly. Returns `None` for image (`png`/`jpg`/...) —
902/// which keeps its inline-markdown round-trip — and for deferred kinds
903/// (`md`/`ipynb`/`csv`/`tsv`) which still need src-tauri post-passes.
904///
905/// The extension table now lives in `ext_kind::reference_kind_for_ext` (the
906/// single source of truth). The `EmbedRenderer::extensions()` slices in
907/// `embed_renderer.rs` still exist and are still used by the renderer
908/// registry — do NOT delete them.
909fn synth_kind_for_ext(ext: &str) -> Option<SynthKind> {
910    use crate::resolve::ext_kind::{reference_kind_for_ext, ExtKind};
911    match reference_kind_for_ext(ext) {
912        ExtKind::Video => Some(SynthKind::Video),
913        ExtKind::Pdf => Some(SynthKind::Pdf),
914        ExtKind::Audio => Some(SynthKind::Audio),
915        ExtKind::Iframe => Some(SynthKind::Iframe),
916        ExtKind::Model => Some(SynthKind::Model),
917        ExtKind::Image | ExtKind::Transclusion | ExtKind::Notebook | ExtKind::Table | ExtKind::Other => None,
918    }
919}
920
921/// Build the [`TitleParams`] handed to a per-kind synthesizer.
922///
923/// Mirrors the `*_extra_params` helpers in `embed_renderer.rs` (which fed
924/// the legacy `moss:title` round-trip) — they are the canonical reference
925/// for which params each synth function reads. Notable shape:
926///
927/// - **`data-width`** carries the canonical wrapper width (`body | wide |
928///   page | screen`) when the pothole was an Obsidian width-token. Synth
929///   functions emit it as the `data-width=` attribute on the wrapping
930///   element.
931/// - **`width` / `height`** come from `|WxH` sizing aliases parsed via
932///   [`Sizing`]. Pixel/percent/vh values are CSS-formatted.
933/// - **`title`** (iframe only) carries non-sizing alias text as the
934///   iframe's accessible name (legacy behaviour: `[[widget.html|My Widget]]`).
935/// - **`query` / `fragment`** (iframe/pdf only) reconstruct the served URL
936///   from the split dest-url — pulldown-cmark percent-encodes `?` and `#`
937///   if they stay in the URL slot, so the dispatcher hands them out-of-band.
938/// - **Pothole `Params`** are folded last so author-typed `width=400` etc.
939///   override the alias-derived values (every-token-K=V rule wins).
940fn build_synth_params(
941    kind: SynthKind,
942    embed: &ParsedEmbed<'_>,
943    pothole: &PotholeContent,
944) -> TitleParams {
945    let mut params = TitleParams::default();
946    if let Some(w) = embed.width {
947        params.insert("data-width", w);
948    }
949
950    // iframe / pdf carry ?query and #fragment out-of-band on the synth side.
951    if matches!(kind, SynthKind::Iframe | SynthKind::Pdf) {
952        if let Some(q) = embed.query {
953            params.insert("query", q);
954        }
955        if let Some(f) = embed.section {
956            params.insert("fragment", f);
957        }
958    }
959
960    // Per-kind alias handling. `embed.alias` is the pothole's alias-shaped
961    // remainder (already excludes width tokens) — for non-image kinds it
962    // overwhelmingly looks like a `|WxH` sizing hint, but iframe also
963    // supports free-text titles.
964    if let Some(alias) = embed.alias {
965        match kind {
966            SynthKind::Video => {
967                // Video alias supports a bare `loop` keyword (case-insensitive)
968                // plus an optional `WxH` sizing hint in any order.
969                // `![[clip.mp4|loop]]` → params["loop"]="1", no sizing.
970                // `![[clip.mp4|640x360 loop]]` → sizing AND loop.
971                // `![[clip.mp4|640x360]]` → sizing only (backward-compat).
972                //
973                // Strategy: tokenise on whitespace, extract the `loop` token,
974                // then run Sizing::parse on the remaining tokens joined by a
975                // space so multi-token sizing like "640x360" keeps working.
976                let mut tokens: Vec<&str> = alias.split_whitespace().collect();
977                let loop_pos = tokens
978                    .iter()
979                    .position(|t| t.eq_ignore_ascii_case("loop"));
980                if let Some(pos) = loop_pos {
981                    tokens.remove(pos);
982                    params.insert("loop", "1");
983                }
984                let remainder = tokens.join(" ");
985                if !remainder.is_empty() {
986                    match Sizing::parse(&remainder) {
987                        Some(Sizing::Width(w)) => {
988                            params.insert("width", w.to_css());
989                        }
990                        Some(Sizing::Box(w, h)) => {
991                            params.insert("width", w.to_css());
992                            params.insert("height", h.to_css());
993                        }
994                        None => {}
995                    }
996                }
997            }
998            SynthKind::Pdf | SynthKind::Model => match Sizing::parse(alias) {
999                Some(Sizing::Width(w)) => {
1000                    params.insert("width", w.to_css());
1001                }
1002                Some(Sizing::Box(w, h)) => {
1003                    params.insert("width", w.to_css());
1004                    params.insert("height", h.to_css());
1005                }
1006                None => {}
1007            },
1008            SynthKind::Iframe => match Sizing::parse(alias) {
1009                Some(Sizing::Width(w)) => {
1010                    params.insert("width", w.to_css());
1011                }
1012                Some(Sizing::Box(w, h)) => {
1013                    params.insert("width", w.to_css());
1014                    params.insert("height", h.to_css());
1015                }
1016                None => {
1017                    // Non-sizing alias text → iframe accessible name.
1018                    params.insert("title", alias);
1019                }
1020            },
1021            SynthKind::Audio => {
1022                // Audio synthesizer reads no alias-derived params today
1023                // (controls / preload defaults are unconditional). Leave
1024                // params untouched.
1025            }
1026        }
1027    }
1028
1029    // Author-typed K=V params win over alias-derived values (every-token
1030    // rule already validated by `parse_pothole_params`).
1031    if let PotholeContent::Params(p) = pothole {
1032        for (k, v) in &p.params {
1033            params.insert(k.clone(), v.clone());
1034        }
1035    }
1036
1037    params
1038}
1039
1040// ---------------------------------------------------------------------------
1041// Tests
1042// ---------------------------------------------------------------------------
1043
1044#[cfg(test)]
1045mod tests {
1046    use super::*;
1047    use crate::content_graph::{ContentGraph, ContentGraphBuilder};
1048
1049    // --- parse_pothole_params edge cases ----------------------------------
1050
1051    #[test]
1052    fn pothole_empty_string_is_empty() {
1053        assert_eq!(parse_pothole_params(""), PotholeContent::Empty);
1054        assert_eq!(parse_pothole_params("   "), PotholeContent::Empty);
1055    }
1056
1057    #[test]
1058    fn pothole_pure_digit_is_alias_not_width_token() {
1059        // `[[img.jpg|400]]` — `400` is NOT a spec § P9 width keyword
1060        // (only `body|wide|page|screen|full` match). Pure-pixel widths
1061        // are handled downstream by the relevant renderer's `Sizing::parse`
1062        // on the alias. parse_pothole_params therefore classifies `400`
1063        // as a plain alias here; the image / video renderer's existing
1064        // alias-based sizing logic (carry through to ParsedEmbed.alias)
1065        // does the rest.
1066        match parse_pothole_params("400") {
1067            PotholeContent::Alias(s) => assert_eq!(s, "400"),
1068            other => panic!("expected Alias, got {:?}", other),
1069        }
1070    }
1071
1072    #[test]
1073    fn pothole_plain_alias() {
1074        // `[[file|My alias]]`
1075        match parse_pothole_params("My alias") {
1076            PotholeContent::Alias(s) => assert_eq!(s, "My alias"),
1077            other => panic!("expected Alias, got {:?}", other),
1078        }
1079    }
1080
1081    #[test]
1082    fn pothole_kv_pair_is_params() {
1083        // `[[file|width=400 align=left]]`
1084        match parse_pothole_params("width=400 align=left") {
1085            PotholeContent::Params(p) => {
1086                assert_eq!(p.get("width"), Some("400"));
1087                assert_eq!(p.get("align"), Some("left"));
1088            }
1089            other => panic!("expected Params, got {:?}", other),
1090        }
1091    }
1092
1093    #[test]
1094    fn pothole_single_kv_is_params() {
1095        // `[[file|width=400]]`
1096        match parse_pothole_params("width=400") {
1097            PotholeContent::Params(p) => {
1098                assert_eq!(p.get("width"), Some("400"));
1099            }
1100            other => panic!("expected Params, got {:?}", other),
1101        }
1102    }
1103
1104    #[test]
1105    fn pothole_bare_alt_blocks_kv_parse() {
1106        // CRITICAL: `[[file|alt text=cover]]` — `alt` is bare (no `=`),
1107        // so the whole thing must be classified as alias text, NOT as
1108        // a `text=cover` param.
1109        match parse_pothole_params("alt text=cover") {
1110            PotholeContent::Alias(s) => assert_eq!(s, "alt text=cover"),
1111            other => panic!("expected Alias, got {:?}", other),
1112        }
1113    }
1114
1115    #[test]
1116    fn pothole_uppercase_key_blocks_kv_parse() {
1117        // `[[file|My Notes=Important]]` — `My` doesn't start with
1118        // lowercase letter; whole thing falls through to alias.
1119        match parse_pothole_params("My Notes=Important") {
1120            PotholeContent::Alias(s) => assert_eq!(s, "My Notes=Important"),
1121            other => panic!("expected Alias, got {:?}", other),
1122        }
1123    }
1124
1125    #[test]
1126    fn pothole_no_equals_is_alias() {
1127        // `[[file|width 400]]` — no `=` on `width` token; alias.
1128        match parse_pothole_params("width 400") {
1129            PotholeContent::Alias(s) => assert_eq!(s, "width 400"),
1130            other => panic!("expected Alias, got {:?}", other),
1131        }
1132    }
1133
1134    #[test]
1135    fn pothole_partial_kv_falls_through_to_alias() {
1136        // `[[file|width=400 caption text]]` — first token is K=V but
1137        // `caption` and `text` aren't. Every-token rule fails → alias.
1138        match parse_pothole_params("width=400 caption text") {
1139            PotholeContent::Alias(s) => assert_eq!(s, "width=400 caption text"),
1140            other => panic!("expected Alias, got {:?}", other),
1141        }
1142    }
1143
1144    #[test]
1145    fn pothole_kv_with_hyphenated_key() {
1146        // Hyphen and underscore allowed in keys.
1147        match parse_pothole_params("aria-label=primary data_id=42") {
1148            PotholeContent::Params(p) => {
1149                assert_eq!(p.get("aria-label"), Some("primary"));
1150                assert_eq!(p.get("data_id"), Some("42"));
1151            }
1152            other => panic!("expected Params, got {:?}", other),
1153        }
1154    }
1155
1156    #[test]
1157    fn pothole_obsidian_width_keyword() {
1158        // `[[img.jpg|wide]]` — `wide` is a known width keyword.
1159        match parse_pothole_params("wide") {
1160            PotholeContent::WidthToken { width, rest_alias } => {
1161                assert_eq!(width, "wide");
1162                assert!(rest_alias.is_empty());
1163            }
1164            other => panic!("expected WidthToken, got {:?}", other),
1165        }
1166    }
1167
1168    // --- split_dest_url cases --------------------------------------------
1169
1170    #[test]
1171    fn split_dest_url_plain_file() {
1172        let s = split_dest_url("notes");
1173        assert_eq!(s.file, "notes");
1174        assert_eq!(s.section, None);
1175        assert_eq!(s.query, None);
1176    }
1177
1178    #[test]
1179    fn split_dest_url_with_anchor() {
1180        let s = split_dest_url("notes#section");
1181        assert_eq!(s.file, "notes");
1182        assert_eq!(s.section, Some("section"));
1183        assert_eq!(s.query, None);
1184    }
1185
1186    #[test]
1187    fn split_dest_url_with_query() {
1188        let s = split_dest_url("page.html?x=1");
1189        assert_eq!(s.file, "page.html");
1190        assert_eq!(s.query, Some("x=1"));
1191    }
1192
1193    #[test]
1194    fn split_dest_url_anchor_then_query() {
1195        let s = split_dest_url("page.html#frag?x=1");
1196        assert_eq!(s.file, "page.html");
1197        assert_eq!(s.section, Some("frag"));
1198        assert_eq!(s.query, Some("x=1"));
1199    }
1200
1201    #[test]
1202    fn split_dest_url_query_then_anchor() {
1203        // Both '?' and '#' present, '?' first — query owns its tail; '#' splits out.
1204        let s = split_dest_url("page.html?x=1#frag");
1205        assert_eq!(s.file, "page.html");
1206        // query is [q+1..h] => "x=1"
1207        assert_eq!(s.query, Some("x=1"));
1208        // section is [h+1..] => "frag"
1209        assert_eq!(s.section, Some("frag"));
1210    }
1211
1212    // --- dispatch_wikilink_embed integration -----------------------------
1213    //
1214    // Use a minimal ContentGraph that registers a few paths. We rely on
1215    // ContentGraph::resolve_path() to map bare names back to filesystem-
1216    // looking paths (the same surface Stage 1 uses).
1217
1218    fn build_graph(paths: &[&str]) -> ContentGraph {
1219        let mut b = ContentGraphBuilder::new();
1220        for p in paths {
1221            // Derive a simple slug from the filename stem; the slug is
1222            // only relevant for slug-based resolution which our tests
1223            // don't exercise (they use bare filenames matching `path`).
1224            let slug = std::path::Path::new(p)
1225                .file_stem()
1226                .and_then(|s| s.to_str())
1227                .unwrap_or(p);
1228            b.add_file(p, slug);
1229        }
1230        b.build()
1231    }
1232
1233    /// Helper: empty AssetSnapshot. Phase 3 PR4.5 (2026-05-27) added the
1234    /// `assets` parameter to dispatch_wikilink_embed so non-image embed
1235    /// kinds can route directly to their HTML synthesizers.
1236    fn empty_snapshot() -> AssetSnapshot {
1237        AssetSnapshot::new()
1238    }
1239
1240    #[test]
1241    fn dispatch_bare_wikilink_is_link() {
1242        let graph = build_graph(&["notes.md"]);
1243        let emit = dispatch_wikilink_embed(
1244            "notes",
1245            None,
1246            /* is_embed */ false,
1247            &graph,
1248            "index.md",
1249            &empty_snapshot(),
1250        );
1251        match emit.output {
1252            EmitKind::Link(link) => {
1253                assert!(link.contains("notes"));
1254                assert!(link.contains("moss-resolved:"));
1255            }
1256            other => panic!("expected Link, got {:?}", other),
1257        }
1258        assert!(emit.outgoing_link.is_some());
1259        assert!(emit.diagnostics.is_empty());
1260    }
1261
1262    #[test]
1263    fn dispatch_wikilink_with_alias_uses_alias_text() {
1264        let graph = build_graph(&["notes.md"]);
1265        let emit = dispatch_wikilink_embed(
1266            "notes",
1267            Some("My alias"),
1268            false,
1269            &graph,
1270            "index.md",
1271            &empty_snapshot(),
1272        );
1273        match emit.output {
1274            EmitKind::Link(link) => {
1275                assert!(link.starts_with("[My alias]"));
1276            }
1277            other => panic!("expected Link, got {:?}", other),
1278        }
1279    }
1280
1281    #[test]
1282    fn dispatch_unresolved_wikilink_emits_diagnostic() {
1283        let graph = build_graph(&[]);
1284        let emit = dispatch_wikilink_embed(
1285            "missing",
1286            None,
1287            false,
1288            &graph,
1289            "index.md",
1290            &empty_snapshot(),
1291        );
1292        assert_eq!(emit.diagnostics.len(), 1);
1293        match emit.output {
1294            EmitKind::Link(link) => assert!(link.contains("moss-unresolved:")),
1295            other => panic!("expected Link, got {:?}", other),
1296        }
1297    }
1298
1299    #[test]
1300    fn dispatch_anchor_wikilink_preserves_section_in_href() {
1301        let graph = build_graph(&["notes.md"]);
1302        let emit = dispatch_wikilink_embed(
1303            "notes#section",
1304            None,
1305            false,
1306            &graph,
1307            "index.md",
1308            &empty_snapshot(),
1309        );
1310        match emit.output {
1311            EmitKind::Link(link) => {
1312                assert!(link.contains("moss-resolved:"));
1313                // Anchor preserved (Obsidian-style heading-anchor slug).
1314                assert!(link.contains("#section"), "got: {}", link);
1315            }
1316            other => panic!("expected Link, got {:?}", other),
1317        }
1318    }
1319
1320    // --- build_anchor / dispatch_wikilink_form (`[[…]]` text-link) -------
1321    //
1322    // SCOPE WARNING — read before trusting these as link-path coverage.
1323    //
1324    // The three tests below drive `dispatch_wikilink_embed(..., is_embed:
1325    // false, ..)`, i.e. the `dispatch_wikilink_form` branch and its
1326    // `build_anchor` helper. That branch is the ONLY caller of `build_anchor`,
1327    // and in the LIVE build it is DORMANT: the sole production caller of this
1328    // dispatcher — the AST visitor `crate::ast::dispatch_wikilink_embeds`
1329    // (`ast/dispatch_wikilink_embeds.rs`) — hard-codes `is_embed: true`
1330    // (it only walks `![[…]]` image-embed `Inline::Image` nodes). Plain
1331    // `[[Page#Heading]]` TEXT links never reach this function in production;
1332    // they arrive as `Inline::Link { is_wikilink: true }` and are resolved
1333    // by `crate::ast::resolve_urls`, whose `slug_wikilink_suffix` performs
1334    // the user-facing `#Heading → #heading` slugging.
1335    //
1336    // ==> The REAL guards for `[[Page#Heading]]` text-link slugging live in
1337    //     `crates/moss-core/src/ast/resolve_urls.rs`:
1338    //       - wikilink_cross_page_fragment_is_slugged
1339    //       - wikilink_same_page_fragment_is_slugged
1340    //       - markdown_link_fragment_stays_raw_not_slugged
1341    //       - wikilink_block_ref_keeps_id_raw
1342    //       - wikilink_cjk_fragment_preserved
1343    //       - slug_wikilink_suffix_preserves_query
1344    //
1345    // These three tests are kept because `build_anchor` is real code worth
1346    // locking (it mirrors `slug_wikilink_suffix`, and a plugin/CLI caller
1347    // could pass `is_embed: false`), NOT because they cover the live link
1348    // path. Their names are deliberately `build_anchor_*` so a future reader
1349    // is not misled into thinking text-link resolution is guarded here.
1350
1351    #[test]
1352    fn build_anchor_slugs_section_fragment() {
1353        // Helper-path test (NOT the live `[[…]]` link path — see SCOPE
1354        // WARNING above; the live guard is
1355        // `resolve_urls::wikilink_cross_page_fragment_is_slugged`).
1356        //
1357        // `dispatch_wikilink_form("notes#My Heading")` slugs the section
1358        // fragment via `build_anchor` → `obsidian_heading_anchor` →
1359        // `#my-heading`.
1360        // Emitted output: `[notes > My Heading](moss-resolved:notes.md#my-heading)`.
1361        let graph = build_graph(&["notes.md"]);
1362        let emit = dispatch_wikilink_embed(
1363            "notes#My Heading",
1364            None,
1365            false,
1366            &graph,
1367            "index.md",
1368            &empty_snapshot(),
1369        );
1370        match emit.output {
1371            EmitKind::Link(link) => {
1372                assert!(link.contains("#my-heading"), "got: {}", link);
1373                assert!(link.contains("moss-resolved:"), "got: {}", link);
1374            }
1375            other => panic!("expected Link, got {:?}", other),
1376        }
1377    }
1378
1379    #[test]
1380    fn build_anchor_same_page_emits_bare_anchor() {
1381        // Helper-path test (NOT the live `[[…]]` link path — see SCOPE
1382        // WARNING above; the live guard is
1383        // `resolve_urls::wikilink_same_page_fragment_is_slugged`).
1384        //
1385        // `dispatch_wikilink_form("#My Heading")` (empty file part) resolves
1386        // to a bare slugged anchor with no `moss-resolved:` prefix.
1387        // Emitted output: `[My Heading](#my-heading)`.
1388        let graph = build_graph(&["notes.md"]);
1389        let emit = dispatch_wikilink_embed(
1390            "#My Heading",
1391            None,
1392            false,
1393            &graph,
1394            "notes.md",
1395            &empty_snapshot(),
1396        );
1397        match emit.output {
1398            EmitKind::Link(link) => {
1399                assert!(link.contains("(#my-heading)"), "got: {}", link);
1400                assert!(!link.contains("moss-resolved:"), "got: {}", link);
1401            }
1402            other => panic!("expected Link, got {:?}", other),
1403        }
1404    }
1405
1406    #[test]
1407    fn build_anchor_block_ref_is_not_slugged() {
1408        // Helper-path test (NOT the live `[[…]]` link path — see SCOPE
1409        // WARNING above; the live guard is
1410        // `resolve_urls::wikilink_block_ref_keeps_id_raw`).
1411        //
1412        // Block refs (^id) are emitted RAW — NOT run through
1413        // obsidian_heading_anchor. Use a block-id with a space + uppercase
1414        // so slugging (which would yield "#block-id") is observably
1415        // different from the raw form ("#Block Id"). This fails loudly if
1416        // the `^` short-circuit in build_anchor regresses.
1417        // Emitted output: `[notes > ^Block Id](moss-resolved:notes.md#Block Id)`.
1418        let graph = build_graph(&["notes.md"]);
1419        let emit = dispatch_wikilink_embed(
1420            "notes#^Block Id",
1421            None,
1422            false,
1423            &graph,
1424            "index.md",
1425            &empty_snapshot(),
1426        );
1427        match emit.output {
1428            EmitKind::Link(link) => {
1429                assert!(link.contains("#Block Id"), "expected raw block-ref, got: {}", link);
1430                assert!(!link.contains("#block-id"), "block-ref was slugged: {}", link);
1431            }
1432            other => panic!("expected Link, got {:?}", other),
1433        }
1434    }
1435
1436    #[test]
1437    fn dispatch_video_extension_routes_to_synth() {
1438        // Phase 3 PR4.5 (2026-05-27): non-image wikilinks now route
1439        // DIRECTLY to the per-kind synthesizer — the markdown round-trip
1440        // is gone (it was dropping the `moss:kind=…` title since PR2 and
1441        // entirely silent after PR4 deleted `parse_title`). The dispatcher
1442        // returns `EmitKind::Html` carrying the `<video>` byte shape; we
1443        // pin only the structural identity (element + src) so byte-shape
1444        // changes are owned by the synth tests in `render/video.rs`.
1445        let graph = build_graph(&["clip.mp4"]);
1446        let emit = dispatch_wikilink_embed(
1447            "clip.mp4",
1448            None,
1449            true,
1450            &graph,
1451            "index.md",
1452            &empty_snapshot(),
1453        );
1454        match emit.output {
1455            EmitKind::Html(s) => {
1456                assert!(s.contains("<video"), "expected <video>, got: {}", s);
1457                assert!(s.contains(r#"src="clip.mp4""#), "expected src=, got: {}", s);
1458                assert!(s.contains("moss-embed-video"), "expected class, got: {}", s);
1459            }
1460            other => panic!("expected Html, got: {:?}", other),
1461        }
1462    }
1463
1464    #[test]
1465    fn dispatch_pdf_extension_routes_to_synth() {
1466        // See `dispatch_video_extension_routes_to_synth` for the PR4.5
1467        // routing rationale. PdfRenderer emits an `<object type="application/pdf">`.
1468        let graph = build_graph(&["report.pdf"]);
1469        let emit = dispatch_wikilink_embed(
1470            "report.pdf",
1471            None,
1472            true,
1473            &graph,
1474            "index.md",
1475            &empty_snapshot(),
1476        );
1477        match emit.output {
1478            EmitKind::Html(s) => {
1479                assert!(s.contains("<object"), "expected <object>, got: {}", s);
1480                assert!(
1481                    s.contains(r#"data="report.pdf""#),
1482                    "expected data=, got: {}",
1483                    s
1484                );
1485                assert!(
1486                    s.contains(r#"type="application/pdf""#),
1487                    "expected type=, got: {}",
1488                    s
1489                );
1490            }
1491            other => panic!("expected Html, got: {:?}", other),
1492        }
1493    }
1494
1495    #[test]
1496    fn dispatch_audio_extension_routes_to_synth() {
1497        let graph = build_graph(&["song.mp3"]);
1498        let emit = dispatch_wikilink_embed(
1499            "song.mp3",
1500            None,
1501            true,
1502            &graph,
1503            "index.md",
1504            &empty_snapshot(),
1505        );
1506        match emit.output {
1507            EmitKind::Html(s) => {
1508                assert!(s.contains("<audio"), "expected <audio>, got: {}", s);
1509                assert!(s.contains(r#"src="song.mp3""#), "expected src=, got: {}", s);
1510                assert!(
1511                    s.contains(r#"type="audio/mpeg""#),
1512                    "expected MIME, got: {}",
1513                    s
1514                );
1515            }
1516            other => panic!("expected Html, got: {:?}", other),
1517        }
1518    }
1519
1520    #[test]
1521    fn dispatch_iframe_extension_routes_to_synth() {
1522        let graph = build_graph(&["widget.html"]);
1523        let emit = dispatch_wikilink_embed(
1524            "widget.html",
1525            None,
1526            true,
1527            &graph,
1528            "index.md",
1529            &empty_snapshot(),
1530        );
1531        match emit.output {
1532            EmitKind::Html(s) => {
1533                assert!(s.contains("<iframe"), "expected <iframe>, got: {}", s);
1534                assert!(
1535                    s.contains(r#"src="widget.html""#),
1536                    "expected src=, got: {}",
1537                    s
1538                );
1539            }
1540            other => panic!("expected Html, got: {:?}", other),
1541        }
1542    }
1543
1544    #[test]
1545    fn dispatch_model_extension_routes_to_synth() {
1546        let graph = build_graph(&["scene.glb"]);
1547        let emit = dispatch_wikilink_embed(
1548            "scene.glb",
1549            None,
1550            true,
1551            &graph,
1552            "index.md",
1553            &empty_snapshot(),
1554        );
1555        match emit.output {
1556            EmitKind::Html(s) => {
1557                assert!(
1558                    s.contains("<model-viewer"),
1559                    "expected <model-viewer>, got: {}",
1560                    s
1561                );
1562                assert!(
1563                    s.contains(r#"src="scene.glb""#),
1564                    "expected src=, got: {}",
1565                    s
1566                );
1567            }
1568            other => panic!("expected Html, got: {:?}", other),
1569        }
1570    }
1571
1572    #[test]
1573    fn dispatch_iframe_alias_carries_title() {
1574        // `![[widget.html|Embedded Widget]]` — non-sizing alias text
1575        // surfaces on the iframe as the `title=` accessible name. The
1576        // synth function reads `params.get("title")`; `build_synth_params`
1577        // routes the alias there for iframe-kind.
1578        let graph = build_graph(&["widget.html"]);
1579        let emit = dispatch_wikilink_embed(
1580            "widget.html",
1581            Some("Embedded Widget"),
1582            true,
1583            &graph,
1584            "index.md",
1585            &empty_snapshot(),
1586        );
1587        match emit.output {
1588            EmitKind::Html(s) => {
1589                assert!(s.contains(r#"title="Embedded Widget""#), "got: {}", s);
1590            }
1591            other => panic!("expected Html, got: {:?}", other),
1592        }
1593    }
1594
1595    #[test]
1596    fn dispatch_video_sizing_alias_propagates_dims() {
1597        // `![[clip.mp4|640x360]]` — sizing alias becomes width/height
1598        // CSS-formatted on the <video>.
1599        let graph = build_graph(&["clip.mp4"]);
1600        let emit = dispatch_wikilink_embed(
1601            "clip.mp4",
1602            Some("640x360"),
1603            true,
1604            &graph,
1605            "index.md",
1606            &empty_snapshot(),
1607        );
1608        match emit.output {
1609            EmitKind::Html(s) => {
1610                assert!(s.contains(r#"width="640px""#), "got: {}", s);
1611                assert!(s.contains(r#"height="360px""#), "got: {}", s);
1612            }
1613            other => panic!("expected Html, got: {:?}", other),
1614        }
1615    }
1616
1617    // --- Image display-attr dispatch (fit / position threading) ----------
1618    //
1619    // The polish-pass plan (docs/plans/2026-05-27-polish-passes-followups.md
1620    // Item B) flagged that `![[hero.jpg|cover]]` and
1621    // `![[hero.jpg|fit=cover position=left]]` were silently dropping
1622    // fit/position. `ImageRenderer::render_to_markdown` builds `TitleParams`
1623    // from the alias / pothole, then explicitly discards them with
1624    // `let _ = params;` — the emitted markdown is bare `![](url)`. The
1625    // dispatcher now intercepts these cases ahead of the renderer registry
1626    // and emits a final `<img>` with the appropriate `style=`.
1627
1628    #[test]
1629    fn dispatch_only_fires_for_wikilink_caller() {
1630        // This test documents the safety rule from v2 revision notes:
1631        // dispatch_wikilink_embed is the ONLY public entry point for
1632        // wikilink-form events. There is no parallel function for
1633        // LinkType::Inline. Plain `[link](file.pdf)` events stay as
1634        // markdown links via pulldown-cmark's default emission.
1635        //
1636        // We can't directly test what the caller does (that's in pipeline.rs),
1637        // but we can pin the invariant by asserting the API surface:
1638        // the public function takes `is_embed: bool` for `![[…]]` vs
1639        // `[[…]]`, not a `LinkType` enum that could be confused with Inline.
1640
1641        // No assertion needed — the type signature itself is the check.
1642    }
1643    // ---- Image embed dispatch (image-embed synth-collapse) ---------------
1644    //
1645    // ALL `![[photo.jpg]]` forms now emit `EmitKind::Block(Block::Figure)`
1646    // with full param threading (width / caption / fit / position / align).
1647    // The OLD tests asserted `EmitKind::Inline("![](url)")` round-trips and
1648    // a fit/position fast-path that DROPPED width — they encoded the bug
1649    // this change fixes and were removed.
1650
1651    fn figure_of(emit: &WikilinkEmit) -> &crate::ast::node::Block {
1652        match &emit.output {
1653            EmitKind::Block(b) => b.as_ref(),
1654            other => panic!("expected EmitKind::Block(Figure), got {other:?}"),
1655        }
1656    }
1657
1658    fn render_figure(emit: &WikilinkEmit) -> String {
1659        let block = match emit.output.clone() {
1660            EmitKind::Block(b) => *b,
1661            other => panic!("expected EmitKind::Block(Figure), got {other:?}"),
1662        };
1663        let doc = crate::ast::Document::from_blocks(vec![block]);
1664        crate::ast::render_document(&doc, &crate::ast::DefaultHooks::new())
1665    }
1666
1667    fn dispatch_img(alias: Option<&str>) -> WikilinkEmit {
1668        let graph = build_graph(&["photo.jpg", "hero.jpg"]);
1669        dispatch_wikilink_embed("photo.jpg", alias, true, &graph, "index.md", &empty_snapshot())
1670    }
1671
1672    #[test]
1673    fn dispatch_image_plain_emits_figure_block() {
1674        use crate::ast::node::{Block, Inline};
1675        let emit = dispatch_img(None);
1676        match figure_of(&emit) {
1677            Block::Figure { image, caption, width, align, class_names, img_style } => {
1678                assert!(caption.is_none(), "plain embed: no caption");
1679                assert!(width.is_none());
1680                assert!(align.is_none());
1681                assert!(class_names.is_empty());
1682                assert!(img_style.is_none());
1683                match image {
1684                    Inline::Image { src, alt, is_wikilink, .. } => {
1685                        assert!(src.is_resolved());
1686                        assert_eq!(alt, "");
1687                        assert!(*is_wikilink);
1688                    }
1689                    other => panic!("expected Image, got {other:?}"),
1690                }
1691            }
1692            other => panic!("expected Figure, got {other:?}"),
1693        }
1694    }
1695
1696    #[test]
1697    fn dispatch_image_caption_text_sets_alt_and_figcaption() {
1698        use crate::ast::node::{Block, Inline};
1699        let emit = dispatch_img(Some("My caption"));
1700        match figure_of(&emit) {
1701            Block::Figure { image, caption, .. } => {
1702                let cap = caption.as_ref().expect("caption present");
1703                assert_eq!(cap.len(), 1);
1704                match &cap[0] {
1705                    Inline::Text(t) => assert_eq!(t, "My caption"),
1706                    other => panic!("expected caption Text, got {other:?}"),
1707                }
1708                match image {
1709                    Inline::Image { alt, .. } => assert_eq!(alt, "My caption"),
1710                    other => panic!("expected Image, got {other:?}"),
1711                }
1712            }
1713            other => panic!("expected Figure, got {other:?}"),
1714        }
1715        let html = render_figure(&emit);
1716        assert!(html.contains(r#"alt="My caption""#), "got: {html}");
1717        assert!(html.contains("<figcaption>My caption</figcaption>"), "got: {html}");
1718    }
1719
1720    #[test]
1721    fn dispatch_image_width_token_preserved_as_data_width() {
1722        use crate::ast::node::Block;
1723        // FIX: width is no longer dropped — it lands as figure data-width=.
1724        let emit = dispatch_img(Some("wide"));
1725        match figure_of(&emit) {
1726            Block::Figure { width, caption, .. } => {
1727                assert_eq!(width.as_deref(), Some("wide"));
1728                assert!(caption.is_none(), "width token is not a caption");
1729            }
1730            other => panic!("expected Figure, got {other:?}"),
1731        }
1732        let html = render_figure(&emit);
1733        assert!(html.contains(r#"data-width="wide""#), "got: {html}");
1734    }
1735
1736    #[test]
1737    fn dispatch_image_cover_emits_object_fit_on_inner_img() {
1738        use crate::ast::node::Block;
1739        let emit = dispatch_img(Some("cover"));
1740        match figure_of(&emit) {
1741            Block::Figure { img_style, caption, .. } => {
1742                assert_eq!(img_style.as_deref(), Some("object-fit:cover"));
1743                assert!(caption.is_none(), "structural alias is not a caption");
1744            }
1745            other => panic!("expected Figure, got {other:?}"),
1746        }
1747        let html = render_figure(&emit);
1748        assert!(html.contains("object-fit:cover"), "got: {html}");
1749        assert!(html.contains(r#"<figure class="moss-image""#), "got: {html}");
1750    }
1751
1752    #[test]
1753    fn dispatch_image_cover_left_emits_fit_and_position() {
1754        use crate::ast::node::Block;
1755        let emit = dispatch_img(Some("cover left"));
1756        match figure_of(&emit) {
1757            Block::Figure { img_style, .. } => {
1758                let style = img_style.as_deref().expect("style present");
1759                assert!(style.contains("object-fit:cover"), "got: {style}");
1760                assert!(style.contains("object-position:left"), "got: {style}");
1761            }
1762            other => panic!("expected Figure, got {other:?}"),
1763        }
1764    }
1765
1766    #[test]
1767    fn dispatch_image_params_form_emits_object_fit() {
1768        use crate::ast::node::Block;
1769        let emit = dispatch_img(Some("fit=cover"));
1770        match figure_of(&emit) {
1771            Block::Figure { img_style, .. } => {
1772                assert_eq!(img_style.as_deref(), Some("object-fit:cover"));
1773            }
1774            other => panic!("expected Figure, got {other:?}"),
1775        }
1776    }
1777
1778    #[test]
1779    fn dispatch_image_two_word_position_combines() {
1780        use crate::ast::node::Block;
1781        let emit = dispatch_img(Some("cover top left"));
1782        match figure_of(&emit) {
1783            Block::Figure { img_style, .. } => {
1784                let style = img_style.as_deref().expect("style present");
1785                assert!(style.contains("object-fit:cover"), "got: {style}");
1786                assert!(style.contains("object-position:top left"), "got: {style}");
1787            }
1788            other => panic!("expected Figure, got {other:?}"),
1789        }
1790    }
1791
1792    #[test]
1793    fn dispatch_image_wide_cover_combines_width_and_fit() {
1794        use crate::ast::node::Block;
1795        // width → figure data-width; fit → inner <img> style. Both survive
1796        // (the pre-collapse fast-path DROPPED width when fit was present).
1797        let emit = dispatch_img(Some("wide cover"));
1798        match figure_of(&emit) {
1799            Block::Figure { width, img_style, .. } => {
1800                assert_eq!(width.as_deref(), Some("wide"));
1801                assert_eq!(img_style.as_deref(), Some("object-fit:cover"));
1802            }
1803            other => panic!("expected Figure, got {other:?}"),
1804        }
1805        let html = render_figure(&emit);
1806        assert!(html.contains(r#"data-width="wide""#), "got: {html}");
1807        assert!(html.contains("object-fit:cover"), "got: {html}");
1808    }
1809
1810    #[test]
1811    fn dispatch_image_inner_img_has_single_style_attr() {
1812        // Inner <img> carries exactly one style= (object-fit), no LQIP dup
1813        // (no snapshot here).
1814        let emit = dispatch_img(Some("fit=cover"));
1815        let html = render_figure(&emit);
1816        let n = html.matches("style=").count();
1817        assert_eq!(n, 1, "exactly one style= attr, got {n}: {html}");
1818    }
1819
1820    // Editor Image UX (2026-06-04): wikilink `|NN%` percent width carries
1821    // into Block::Figure.width instead of leaking into the caption.
1822    // -------------------------------------------------------------------
1823
1824    #[test]
1825    fn wikilink_image_percent_carries_width() {
1826        use crate::ast::node::Block;
1827        // ![[pic.jpg|55%]] → Figure { width: Some("55%") }, no bogus caption
1828        let emit = dispatch_img(Some("55%"));
1829        match figure_of(&emit) {
1830            Block::Figure { width, caption, .. } => {
1831                assert_eq!(width.as_deref(), Some("55%"));
1832                assert!(caption.is_none(), "percent must not become a caption");
1833            }
1834            other => panic!("expected Figure, got {other:?}"),
1835        }
1836    }
1837
1838    #[test]
1839    fn wikilink_image_percent_with_caption() {
1840        use crate::ast::node::{Block, Inline};
1841        // ![[pic.jpg|My cap|55%]] → width Some("55%"), caption "My cap"
1842        let emit = dispatch_img(Some("My cap|55%"));
1843        match figure_of(&emit) {
1844            Block::Figure { width, caption, .. } => {
1845                assert_eq!(width.as_deref(), Some("55%"));
1846                let cap = caption.as_ref().expect("caption present");
1847                assert!(
1848                    matches!(cap.as_slice(), [Inline::Text(t)] if t == "My cap"),
1849                    "caption should be the non-width segment, got {cap:?}"
1850                );
1851            }
1852            other => panic!("expected Figure, got {other:?}"),
1853        }
1854    }
1855
1856    // --- External URL dispatch ---
1857
1858    #[test]
1859    fn dispatch_external_url_youtube_emits_html() {
1860        let graph = build_graph(&[]);
1861        let emit = dispatch_wikilink_embed(
1862            "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
1863            None,
1864            true,
1865            &graph,
1866            "index.md",
1867            &empty_snapshot(),
1868        );
1869        match emit.output {
1870            EmitKind::Html(s) => {
1871                assert!(s.contains("<iframe"), "got: {s}");
1872                assert!(s.contains("youtube.com/embed"), "got: {s}");
1873                assert!(s.contains(r#"data-provider="youtube""#), "got: {s}");
1874            }
1875            other => panic!("expected Html, got: {other:?}"),
1876        }
1877        assert!(emit.outgoing_link.is_none(), "external URLs must not register in ContentGraph");
1878        assert!(emit.diagnostics.is_empty());
1879    }
1880
1881    #[test]
1882    fn dispatch_external_url_generic_emits_html() {
1883        let graph = build_graph(&[]);
1884        let emit = dispatch_wikilink_embed(
1885            "https://example.com/embed",
1886            None,
1887            true,
1888            &graph,
1889            "index.md",
1890            &empty_snapshot(),
1891        );
1892        match emit.output {
1893            EmitKind::Html(s) => {
1894                assert!(s.contains("<iframe"), "got: {s}");
1895                assert!(s.contains(r#"src="https://example.com/embed""#), "got: {s}");
1896                assert!(!s.contains("data-provider="), "generic must not have data-provider, got: {s}");
1897            }
1898            other => panic!("expected Html, got: {other:?}"),
1899        }
1900        assert!(emit.outgoing_link.is_none());
1901    }
1902
1903    #[test]
1904    fn dispatch_external_url_http_also_works() {
1905        let graph = build_graph(&[]);
1906        let emit = dispatch_wikilink_embed(
1907            "http://example.com/embed",
1908            None,
1909            true,
1910            &graph,
1911            "index.md",
1912            &empty_snapshot(),
1913        );
1914        match emit.output {
1915            EmitKind::Html(s) => assert!(s.contains("<iframe"), "got: {s}"),
1916            other => panic!("expected Html, got: {other:?}"),
1917        }
1918    }
1919
1920    #[test]
1921    fn dispatch_external_url_with_width_pothole() {
1922        let graph = build_graph(&[]);
1923        let emit = dispatch_wikilink_embed(
1924            "https://vimeo.com/123456789",
1925            Some("wide"),
1926            true,
1927            &graph,
1928            "index.md",
1929            &empty_snapshot(),
1930        );
1931        match emit.output {
1932            EmitKind::Html(s) => assert!(s.contains(r#"data-width="wide""#), "got: {s}"),
1933            other => panic!("expected Html, got: {other:?}"),
1934        }
1935    }
1936
1937    // --- |loop ambient-video parser arm (spec §3.3a) ----------------------
1938
1939    #[test]
1940    fn dispatch_video_loop_alias_emits_ambient_set() {
1941        // `![[clip.mp4|loop]]` — bare `loop` token sets the ambient playback set
1942        // and suppresses controls.
1943        let graph = build_graph(&["clip.mp4"]);
1944        let emit = dispatch_wikilink_embed(
1945            "clip.mp4",
1946            Some("loop"),
1947            true,
1948            &graph,
1949            "index.md",
1950            &empty_snapshot(),
1951        );
1952        match emit.output {
1953            EmitKind::Html(s) => {
1954                assert!(s.contains(" autoplay"), "missing autoplay, got: {}", s);
1955                assert!(s.contains(" muted"), "missing muted, got: {}", s);
1956                assert!(s.contains(" loop"), "missing loop, got: {}", s);
1957                assert!(s.contains(" playsinline"), "missing playsinline, got: {}", s);
1958                assert!(!s.contains(" controls"), "controls must be absent on loop branch, got: {}", s);
1959                assert!(s.contains(" data-loop"), "missing data-loop, got: {}", s);
1960            }
1961            other => panic!("expected Html, got: {:?}", other),
1962        }
1963    }
1964
1965    #[test]
1966    fn dispatch_video_loop_alias_case_insensitive() {
1967        // `![[clip.mp4|LOOP]]` — loop keyword must be case-insensitive.
1968        let graph = build_graph(&["clip.mp4"]);
1969        let emit = dispatch_wikilink_embed(
1970            "clip.mp4",
1971            Some("LOOP"),
1972            true,
1973            &graph,
1974            "index.md",
1975            &empty_snapshot(),
1976        );
1977        match emit.output {
1978            EmitKind::Html(s) => {
1979                assert!(s.contains(" autoplay"), "missing autoplay on LOOP alias, got: {}", s);
1980                assert!(!s.contains(" controls"), "controls must be absent on LOOP alias, got: {}", s);
1981            }
1982            other => panic!("expected Html, got: {:?}", other),
1983        }
1984    }
1985
1986    #[test]
1987    fn dispatch_video_size_and_loop_alias_propagates_all() {
1988        // `![[clip.mp4|640x360 loop]]` — sizing AND loop must both be set;
1989        // order within the alias is irrelevant to the output.
1990        let graph = build_graph(&["clip.mp4"]);
1991        let emit = dispatch_wikilink_embed(
1992            "clip.mp4",
1993            Some("640x360 loop"),
1994            true,
1995            &graph,
1996            "index.md",
1997            &empty_snapshot(),
1998        );
1999        match emit.output {
2000            EmitKind::Html(s) => {
2001                assert!(s.contains(r#"width="640px""#), "missing width, got: {}", s);
2002                assert!(s.contains(r#"height="360px""#), "missing height, got: {}", s);
2003                assert!(s.contains(" autoplay"), "missing autoplay, got: {}", s);
2004                assert!(s.contains(" data-loop"), "missing data-loop, got: {}", s);
2005                assert!(!s.contains(" controls"), "controls must be absent, got: {}", s);
2006            }
2007            other => panic!("expected Html, got: {:?}", other),
2008        }
2009    }
2010
2011    #[test]
2012    fn dispatch_video_loop_first_then_size() {
2013        // `![[clip.mp4|loop 640x360]]` — loop before size is also valid
2014        // (order-independent within the alias).
2015        let graph = build_graph(&["clip.mp4"]);
2016        let emit = dispatch_wikilink_embed(
2017            "clip.mp4",
2018            Some("loop 640x360"),
2019            true,
2020            &graph,
2021            "index.md",
2022            &empty_snapshot(),
2023        );
2024        match emit.output {
2025            EmitKind::Html(s) => {
2026                assert!(s.contains(r#"width="640px""#), "missing width, got: {}", s);
2027                assert!(s.contains(r#"height="360px""#), "missing height, got: {}", s);
2028                assert!(s.contains(" autoplay"), "missing autoplay, got: {}", s);
2029            }
2030            other => panic!("expected Html, got: {:?}", other),
2031        }
2032    }
2033
2034    #[test]
2035    fn dispatch_video_sizing_alias_still_works_without_loop() {
2036        // `![[clip.mp4|640x360]]` — sizing without loop must NOT emit autoplay
2037        // (backward-compat; non-loop path unchanged).
2038        let graph = build_graph(&["clip.mp4"]);
2039        let emit = dispatch_wikilink_embed(
2040            "clip.mp4",
2041            Some("640x360"),
2042            true,
2043            &graph,
2044            "index.md",
2045            &empty_snapshot(),
2046        );
2047        match emit.output {
2048            EmitKind::Html(s) => {
2049                assert!(s.contains(r#"width="640px""#), "missing width, got: {}", s);
2050                assert!(!s.contains(" autoplay"), "autoplay must NOT be emitted without loop, got: {}", s);
2051                assert!(s.contains(" controls"), "controls must be emitted on non-loop path, got: {}", s);
2052            }
2053            other => panic!("expected Html, got: {:?}", other),
2054        }
2055    }
2056
2057}