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::{resolve_reference, ResolvedRef};
53use super::title_params::TitleParams;
54use super::{Diagnostic, DiagnosticKind, 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 pinned_url = graph.pinned_url(&target_path);
486            let parsed = ParsedEmbed {
487                resolved_path: &target_path,
488                from_path,
489                pinned_url: &pinned_url,
490                query: split.query,
491                section: split.section,
492                alias: alias_owned.as_deref(),
493                width,
494                attrs: None,
495            };
496
497            // Non-image wikilink embeds (video / pdf / audio / iframe / 3D)
498            // route DIRECTLY to the typed-HTML synthesizer: derive
499            // `TitleParams` from the pothole content and the pinned URL, then
500            // hand them to the per-kind synthesizer. Routing them through
501            // `EmitKind::Inline(markdown_link)` instead rendered them as plain
502            // `<a href>` links, because the markdown round-trip drops the
503            // title the params travelled in. Image embeds keep inline-markdown
504            // emission so the `<picture>` / `<figure>` wrap stays on the path
505            // that already worked.
506            let ext = path_extension(&target_path);
507            // Page-independent and case-canonical: same href from the vault root
508            // and from a note nested three folders down (moss#903 bug 3).
509            let url = pinned_url.clone();
510            if let Some(synth_kind) = ext.as_deref().and_then(synth_kind_for_ext) {
511                let params = build_synth_params(synth_kind, &parsed, &pothole);
512                let html = match synth_kind {
513                    SynthKind::Video => {
514                        crate::render::video::synthesize_video_html(&params, &url, assets)
515                    }
516                    SynthKind::Pdf => {
517                        crate::render::pdf::synthesize_pdf_html(&params, &url, assets)
518                    }
519                    SynthKind::Audio => {
520                        crate::render::audio::synthesize_audio_html(&params, &url, assets)
521                    }
522                    SynthKind::Iframe => {
523                        crate::render::iframe::synthesize_iframe_html(&params, &url, assets)
524                    }
525                    SynthKind::Model => {
526                        crate::render::model::synthesize_model_html(&params, &url, assets)
527                    }
528                };
529                return WikilinkEmit {
530                    output: EmitKind::Html(html),
531                    outgoing_link: Some(outgoing),
532                    diagnostics,
533                };
534            }
535
536            // Image embeds — the unified arm (image-embed synth-collapse).
537            //
538            // ALL `![[photo.jpg]]` forms route through here to a typed
539            // `Block::Figure`, the SAME node the CommonMark `![](url)` path
540            // produces. This replaced the prior split (a fit/position
541            // "fast-path" that emitted bare `<picture>` + an
542            // `ImageRenderer::render_to_markdown` round-trip that dropped
543            // width via `let _ = params`). Six embed kinds already went
544            // dispatch → synth → Html; image now matches via
545            // dispatch → Block::Figure → render_document.
546            //
547            // Four sources of display params are assembled into the figure:
548            //   1. width  ← `embed.width` (canonical WidthToken) → figure `data-width=`
549            //   2. caption + alt ← `classify_image_alias` (structural → none,
550            //      caption-text → both, empty → none; never `Some("")`)
551            //   3. fit/position ← `build_image_media_attrs` → `to_inline_style()`
552            //      → inner `<img>` `style=` (NOT the figure)
553            //   4. align + class_names ← `build_image_media_attrs` → figure class list
554            //
555            // Emitting `EmitKind::Block` (not `EmitKind::Html`) keeps the
556            // 1:1 `apply_emit` substitution so the figure inherits the source
557            // paragraph's `block_meta` → `data-source-line` survives.
558            //
559            // `find_lone_wikilink_image` guarantees the dispatcher is only
560            // reached for a lone embed (within its container), so the figure
561            // shape is always correct here.
562            if matches!(ext.as_deref(), Some(e) if IMAGE_EXTENSIONS.iter().any(|x| *x == e)) {
563                let media = build_image_media_attrs(&pothole, parsed.attrs.as_ref());
564                // Recover a content-relative percent (`|55%`) from the alias.
565                // A percent isn't a named width token, so `parse_pothole_params`
566                // classifies it as `Alias` and it would otherwise leak into the
567                // caption. Split it here so the figure carries the width and the
568                // caption is the remaining (width-stripped) alias. Recovered here
569                // (not in `parse_pothole_params`) so the shared pothole classifier
570                // stays width-vocabulary-agnostic.
571                // Sync: the no-graph twin lives in ast/parser.rs::try_promote_to_figure
572                // (wikilink_pothole arm) — both split width via media::split_alt_width.
573                let (alias_no_width, pct_width): (Option<String>, Option<String>) =
574                    match parsed.alias {
575                        Some(a) => {
576                            let (rest, w) = crate::media::split_alt_width(a);
577                            (Some(rest), w)
578                        }
579                        None => (None, None),
580                    };
581                let alias_class =
582                    crate::media::classify_image_alias(alias_no_width.as_deref());
583                let alt = alias_class.caption.clone().unwrap_or_default();
584                let caption: Option<Vec<crate::ast::node::Inline>> = alias_class
585                    .caption
586                    .map(|c| vec![crate::ast::node::Inline::Text(c)]);
587                // `AlignSide::css_class()` returns the canonical
588                // `moss-align-left` / `moss-align-right` class verbatim —
589                // the same class the figure renderer appends.
590                let align = media.align.map(|side| side.css_class().to_string());
591                let img_style = media.to_inline_style();
592                // Width source, in priority order:
593                //  1. canonical pothole WidthToken (`|wide`) — `width`
594                //  2. a width token embedded in a structural alias (`|wide cover`)
595                //  3. a content-relative percent anywhere in the pothole (`|55%`)
596                let figure_width: Option<String> = width
597                    .map(|w| w.to_string())
598                    .or_else(|| {
599                        alias_class.display_keywords.as_deref().and_then(|kw| {
600                            kw.split_whitespace()
601                                .find_map(crate::media::match_width_token)
602                                .map(|w| w.to_string())
603                        })
604                    })
605                    .or(pct_width);
606                let figure = crate::ast::node::Block::Figure {
607                    image: crate::ast::node::Inline::Image {
608                        // `Asset` is the canonical kind for an `<img src>`
609                        // (matches resolve_urls' image-URL classification).
610                        src: crate::ast::url::Url::resolved(
611                            url.clone(),
612                            crate::ast::url::UrlKind::Asset,
613                        ),
614                        alt,
615                        title: None,
616                        is_wikilink: true,
617                        wikilink_pothole: None,
618                    },
619                    caption,
620                    // Named token OR `"NN%"` percent; the node stores
621                    // `Option<String>` (for Deserialize).
622                    width: figure_width,
623                    align,
624                    class_names: media.class_names,
625                    img_style,
626                };
627                return WikilinkEmit {
628                    output: EmitKind::Block(Box::new(figure)),
629                    outgoing_link: Some(outgoing),
630                    diagnostics,
631                };
632            }
633
634            let emit = match ext.as_deref().and_then(lookup) {
635                Some(r) => match r.render(&parsed) {
636                    RenderedEmbed::Inline(s) => EmitKind::Inline(s),
637                    RenderedEmbed::Html(s) => EmitKind::Html(s),
638                    RenderedEmbed::Deferred { marker } => EmitKind::Deferred(marker),
639                },
640                None => {
641                    // Fallback: plain file link (Obsidian parity for
642                    // unknown extensions).
643                    EmitKind::Inline(format!("[{}]({})", split.file, url))
644                }
645            };
646
647            WikilinkEmit {
648                output: emit,
649                outgoing_link: Some(outgoing),
650                diagnostics,
651            }
652        }
653        ResolvedRef::Unresolved => {
654            diagnostics.push(Diagnostic {
655                message: format!("Unresolved embed: ![[{}]]", split.file),
656                source_path: from_path.to_string(),
657                reference: split.file.to_string(),
658                kind: DiagnosticKind::Other,
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                kind: DiagnosticKind::Other,
754            });
755            WikilinkEmit {
756                output: EmitKind::Link(format!(
757                    "[{}](moss-unresolved:{})",
758                    display_text, split.file
759                )),
760                outgoing_link: Some(OutgoingLink {
761                    target_path: split.file.to_string(),
762                    display_text,
763                    link_type: LinkType::Wikilink,
764                }),
765                diagnostics,
766            }
767        }
768    }
769}
770
771/// Build [`MediaAttrs`] from a pothole's alias / params.
772///
773/// Two active sources of display vocabulary for image embeds:
774///
775/// - Alias form (`![[hero.jpg|cover left]]`) — whitespace-separated
776///   display keywords. The pothole arrives as
777///   [`PotholeContent::Alias`] or [`PotholeContent::WidthToken::rest_alias`]
778///   when a width token preceded the keywords. `parse_media_attrs` decodes
779///   them into typed `fit` / `position` / `align` fields.
780/// - Params form (`![[hero.jpg|fit=cover position=left]]`) — every token
781///   is `key=value`. The pothole arrives as
782///   [`PotholeContent::Params`] carrying a `TitleParams` bag; we look up
783///   `fit` / `position` / `align` by name and convert their values via the
784///   per-enum `from_keyword`. Unknown keys flow through as `extra_attrs`.
785///
786/// Pandoc attribute blocks (`![[hero.jpg|cover]]{.theme-rounded x="y"}`) are
787/// a third potential source, but [`ParsedEmbed::attrs`] is currently
788/// hard-coded to `None` at the dispatcher's image branch (see
789/// `dispatch_embed_form`). The `attrs` parameter is plumbed through for
790/// future wiring; today the function ignores it. Don't grow the merge
791/// logic here until a caller actually populates `parsed.attrs`.
792fn build_image_media_attrs(
793    pothole: &PotholeContent,
794    _attrs: Option<&crate::ast::attrs::AttrBlock>,
795) -> MediaAttrs {
796    let mut media = MediaAttrs::default();
797
798    // Source 1: alias form. Only fold when the entire alias is structural
799    // (every token is a display keyword) — non-structural aliases are
800    // caption text and don't contribute display params.
801    let alias_text = match pothole {
802        PotholeContent::Alias(s) => Some(s.as_str()),
803        PotholeContent::WidthToken { rest_alias, .. } if !rest_alias.is_empty() => {
804            Some(rest_alias.as_str())
805        }
806        _ => None,
807    };
808    if let Some(text) = alias_text {
809        // Width tokens (`wide`, `screen`, etc.) may appear adjacent to fit /
810        // position keywords in space-separated aliases like
811        // `![[hero|wide cover]]`. They ride on the figure wrapper via
812        // `embed.width`, not the inner `<img>`; strip them here so the
813        // remainder ("cover") parses cleanly through `parse_media_attrs`.
814        // Without this, `is_all_display_keywords("wide cover")` returns
815        // `false` (because "wide" isn't a display keyword) and we'd
816        // silently drop the fit/position — the same regression this branch
817        // exists to fix.
818        let cleaned: Vec<&str> = text
819            .split_whitespace()
820            .filter(|t| crate::media::match_width_token(t).is_none())
821            .collect();
822        let cleaned_str = cleaned.join(" ");
823        if !cleaned_str.is_empty() && crate::media::is_all_display_keywords(&cleaned_str) {
824            let parsed = parse_media_attrs(&cleaned_str);
825            media.fit = parsed.fit;
826            media.position = parsed.position;
827            media.align = parsed.align;
828            // `parse_media_attrs` doesn't populate `class_names` or
829            // `extra_attrs` today (those come from Pandoc blocks, which
830            // aren't wired). The extends here are forward-looking scaffolding
831            // — harmless no-ops on current `MediaAttrs` shape.
832            media.class_names.extend(parsed.class_names);
833            for (k, v) in parsed.extra_attrs {
834                media.extra_attrs.insert(k, v);
835            }
836        }
837    }
838
839    // Source 2: Params form (K=V pothole). Recognized keys override; the
840    // rest flow through as `extra_attrs`.
841    //
842    // `style` is filtered OUT here because `synthesize_image_with_media_attrs`
843    // builds the `style="…"` attribute from `MediaAttrs::to_inline_style()`;
844    // letting an author-typed `style=foo` ALSO flow into `extra_attrs` would
845    // emit two `style=` attributes on the same `<img>` and the browser would
846    // honor the last one, silently dropping moss's object-fit / object-position.
847    if let PotholeContent::Params(params) = pothole {
848        for (k, v) in &params.params {
849            match k.as_str() {
850                "fit" => {
851                    if let Some(fit) = Fit::from_keyword(v) {
852                        media.fit = Some(fit);
853                    }
854                }
855                "position" => {
856                    if let Some(pos) = Position::from_keyword(v) {
857                        media.position = Some(pos);
858                    }
859                }
860                "align" => {
861                    if let Some(side) = AlignSide::from_keyword(v) {
862                        media.align = Some(side);
863                    }
864                }
865                // `width` / `data-width` ride on the figure wrapper, not the
866                // inner `<img>` — handled upstream via `embed.width`.
867                "width" | "data-width" => {}
868                "classes" => {
869                    for c in v.split_whitespace() {
870                        if !media.class_names.iter().any(|x| x == c) {
871                            media.class_names.push(c.to_string());
872                        }
873                    }
874                }
875                // Drop `style=` to avoid duplicate-attribute emission;
876                // see function-level note above.
877                "style" => {}
878                _ => {
879                    media.extra_attrs.insert(k.clone(), v.clone());
880                }
881            }
882        }
883    }
884
885    media
886}
887
888/// Discriminant for the per-kind HTML synthesizer the dispatcher routes to
889/// directly (Phase 3 PR4.5). Non-image / non-deferred extensions skip the
890/// markdown round-trip and emit `EmitKind::Html` straight from the synth
891/// function — see the dispatcher branch in `dispatch_embed_form`.
892#[derive(Debug, Clone, Copy, PartialEq, Eq)]
893enum SynthKind {
894    Video,
895    Pdf,
896    Audio,
897    Iframe,
898    Model,
899}
900
901/// Classify a file extension into a [`SynthKind`] when the dispatcher should
902/// emit final HTML directly. Returns `None` for image (`png`/`jpg`/...) —
903/// which keeps its inline-markdown round-trip — and for deferred kinds
904/// (`md`/`ipynb`/`csv`/`tsv`) which still need src-tauri post-passes.
905///
906/// The extension table now lives in `ext_kind::reference_kind_for_ext` (the
907/// single source of truth). The `EmbedRenderer::extensions()` slices in
908/// `embed_renderer.rs` still exist and are still used by the renderer
909/// registry — do NOT delete them.
910fn synth_kind_for_ext(ext: &str) -> Option<SynthKind> {
911    use crate::resolve::ext_kind::{reference_kind_for_ext, ExtKind};
912    match reference_kind_for_ext(ext) {
913        ExtKind::Video => Some(SynthKind::Video),
914        ExtKind::Pdf => Some(SynthKind::Pdf),
915        ExtKind::Audio => Some(SynthKind::Audio),
916        ExtKind::Iframe => Some(SynthKind::Iframe),
917        ExtKind::Model => Some(SynthKind::Model),
918        ExtKind::Image | ExtKind::Transclusion | ExtKind::Notebook | ExtKind::Table | ExtKind::Other => None,
919    }
920}
921
922/// Build the [`TitleParams`] handed to a per-kind synthesizer.
923///
924/// Mirrors the `*_extra_params` helpers in `embed_renderer.rs` (which fed
925/// the legacy `moss:title` round-trip) — they are the canonical reference
926/// for which params each synth function reads. Notable shape:
927///
928/// - **`data-width`** carries the canonical wrapper width (`body | wide |
929///   page | screen`) when the pothole was an Obsidian width-token. Synth
930///   functions emit it as the `data-width=` attribute on the wrapping
931///   element.
932/// - **`width` / `height`** come from `|WxH` sizing aliases parsed via
933///   [`Sizing`]. Pixel/percent/vh values are CSS-formatted.
934/// - **`title`** (iframe only) carries non-sizing alias text as the
935///   iframe's accessible name (legacy behaviour: `[[widget.html|My Widget]]`).
936/// - **`query` / `fragment`** (iframe/pdf only) reconstruct the served URL
937///   from the split dest-url — pulldown-cmark percent-encodes `?` and `#`
938///   if they stay in the URL slot, so the dispatcher hands them out-of-band.
939/// - **Pothole `Params`** are folded last so author-typed `width=400` etc.
940///   override the alias-derived values (every-token-K=V rule wins).
941fn build_synth_params(
942    kind: SynthKind,
943    embed: &ParsedEmbed<'_>,
944    pothole: &PotholeContent,
945) -> TitleParams {
946    let mut params = TitleParams::default();
947    if let Some(w) = embed.width {
948        params.insert("data-width", w);
949    }
950
951    // iframe / pdf carry ?query and #fragment out-of-band on the synth side.
952    if matches!(kind, SynthKind::Iframe | SynthKind::Pdf) {
953        if let Some(q) = embed.query {
954            params.insert("query", q);
955        }
956        if let Some(f) = embed.section {
957            params.insert("fragment", f);
958        }
959    }
960
961    // Per-kind alias handling. `embed.alias` is the pothole's alias-shaped
962    // remainder (already excludes width tokens) — for non-image kinds it
963    // overwhelmingly looks like a `|WxH` sizing hint, but iframe also
964    // supports free-text titles.
965    if let Some(alias) = embed.alias {
966        match kind {
967            SynthKind::Video => {
968                // Video alias supports a bare `loop` keyword (case-insensitive)
969                // plus an optional `WxH` sizing hint in any order.
970                // `![[clip.mp4|loop]]` → params["loop"]="1", no sizing.
971                // `![[clip.mp4|640x360 loop]]` → sizing AND loop.
972                // `![[clip.mp4|640x360]]` → sizing only (backward-compat).
973                //
974                // Strategy: tokenise on whitespace, extract the `loop` token,
975                // then run Sizing::parse on the remaining tokens joined by a
976                // space so multi-token sizing like "640x360" keeps working.
977                let mut tokens: Vec<&str> = alias.split_whitespace().collect();
978                let loop_pos = tokens
979                    .iter()
980                    .position(|t| t.eq_ignore_ascii_case("loop"));
981                if let Some(pos) = loop_pos {
982                    tokens.remove(pos);
983                    params.insert("loop", "1");
984                }
985                let remainder = tokens.join(" ");
986                if !remainder.is_empty() {
987                    match Sizing::parse(&remainder) {
988                        Some(Sizing::Width(w)) => {
989                            params.insert("width", w.to_css());
990                        }
991                        Some(Sizing::Box(w, h)) => {
992                            params.insert("width", w.to_css());
993                            params.insert("height", h.to_css());
994                        }
995                        None => {}
996                    }
997                }
998            }
999            SynthKind::Pdf | SynthKind::Model => match Sizing::parse(alias) {
1000                Some(Sizing::Width(w)) => {
1001                    params.insert("width", w.to_css());
1002                }
1003                Some(Sizing::Box(w, h)) => {
1004                    params.insert("width", w.to_css());
1005                    params.insert("height", h.to_css());
1006                }
1007                None => {}
1008            },
1009            SynthKind::Iframe => match Sizing::parse(alias) {
1010                Some(Sizing::Width(w)) => {
1011                    params.insert("width", w.to_css());
1012                }
1013                Some(Sizing::Box(w, h)) => {
1014                    params.insert("width", w.to_css());
1015                    params.insert("height", h.to_css());
1016                }
1017                None => {
1018                    // Non-sizing alias text → iframe accessible name.
1019                    params.insert("title", alias);
1020                }
1021            },
1022            SynthKind::Audio => {
1023                // Audio synthesizer reads no alias-derived params today
1024                // (controls / preload defaults are unconditional). Leave
1025                // params untouched.
1026            }
1027        }
1028    }
1029
1030    // Author-typed K=V params win over alias-derived values (every-token
1031    // rule already validated by `parse_pothole_params`).
1032    if let PotholeContent::Params(p) = pothole {
1033        for (k, v) in &p.params {
1034            params.insert(k.clone(), v.clone());
1035        }
1036    }
1037
1038    params
1039}
1040
1041// ---------------------------------------------------------------------------
1042// Tests
1043// ---------------------------------------------------------------------------
1044
1045#[cfg(test)]
1046#[path = "wikilink_dispatch_tests.rs"]
1047mod tests;