Skip to main content

moss_core/ast/
resolve_urls.rs

1//! Typed URL resolution: walk a [`Document`] and classify every
2//! [`Url::Unresolved`] into a [`Url::Resolved`] with the right [`UrlKind`].
3//!
4//! One typed visitor replaces the two line-level "Stage 1" regex passes it was
5//! migrated from (`markdown_refs` for bare-filename image refs,
6//! `markdown_links` for standard `[text](url)` links), both deleted after
7//! parity was proven — history in `docs/archive/2026-05-28-*`. Two properties
8//! come from the AST rather than from code here: `Inline::Image::src` (always
9//! an asset URL) is structurally distinct from `Inline::Link::url` (may be a
10//! markdown target), and code is never visited by [`visit_urls_mut`], so no
11//! fence tracking is needed.
12//!
13//! `resolve_link_urls` emits a `moss-resolved:<path>` sentinel and leaves the
14//! URL `Url::Unresolved` so src-tauri's `classify_url_prod` can apply
15//! `page_map` / `external_url_map` / wikilink-class-aware decoding. That
16//! sentinel IS the moss-core ↔ src-tauri layering seam: moss-core resolves
17//! filesystem paths, src-tauri owns the deployed URL space.
18//!
19//! ## OutgoingLink contract
20//!
21//! [`UrlResolution::outgoing`] carries the same load-bearing shape (target_path,
22//! link_type, document-order sequence) the deleted passes produced. It uses
23//! parsed inline text for `display_text` where they used the raw source between
24//! `[` and `]`; `display_text` has no production consumer, so the divergence is
25//! non-breaking (see `link_wrapping_image_target_path`).
26
27use super::document::Document;
28use super::node::{Block, Inline};
29use super::shortcode::Shortcode;
30use super::url::{ResolvedUrl, Url, UrlKind};
31use super::visit::visit_urls_mut;
32use crate::content_graph::ContentGraph;
33use crate::resolve::asset_class::{resolve_asset_ref, AssetIndex, AssetResolution};
34use crate::resolve::fuzzy_path::{resolve_reference, ResolvedRef};
35use crate::resolve::{Diagnostic, DiagnosticKind, LinkType, OutgoingLink};
36
37// ---------------------------------------------------------------------------
38// GraphAssetIndex: adapts ContentGraph to the AssetIndex trait so the pure
39// engine (resolve_asset_ref) can run against a real content graph.
40// ---------------------------------------------------------------------------
41
42/// Adapts [`ContentGraph`] to the [`AssetIndex`] trait.
43///
44/// Wraps a borrowed `ContentGraph` so that `resolve_asset_ref` (the pure
45/// shared engine in `moss_core::resolve::asset_class`) can be driven by the
46/// build-time in-memory index — identical to how `FsAssetIndex` in src-tauri
47/// drives it from the live filesystem. Exposed `pub` so integration tests and
48/// editor↔build parity tests can construct both adapters over the same file set.
49pub struct GraphAssetIndex<'a>(pub &'a ContentGraph);
50
51impl<'a> AssetIndex for GraphAssetIndex<'a> {
52    fn contains(&self, p: &str) -> bool {
53        self.0.asset_contains(p)
54    }
55    fn contains_ci(&self, p: &str) -> Option<String> {
56        self.0.asset_contains_ci(p)
57    }
58    fn find_by_suffix(&self, s: &str) -> Vec<String> {
59        self.0.asset_find_by_suffix(s)
60    }
61}
62
63/// What one `resolve_urls` walk learned: the dependency edges the build needs,
64/// and the references it could not resolve. A miss produces a diagnostic and
65/// keeps the author's bytes — never a guessed URL (moss#903 bug 3). The host
66/// surfaces diagnostics; moss-core does no I/O.
67#[derive(Debug, Default)]
68pub struct UrlResolution {
69    pub outgoing: Vec<OutgoingLink>,
70    pub diagnostics: Vec<Diagnostic>,
71}
72
73/// Walk every URL in `doc` and classify it into [`Url::Resolved`].
74///
75/// Returns the [`UrlResolution`] for the walk: the dependency edges discovered
76/// plus a diagnostic per unresolvable reference.
77///
78/// # Arguments
79///
80/// * `doc` — the typed document. Every [`Url::Unresolved`] is replaced in
81///   place with a [`Url::Resolved`]. URLs that are already [`Url::Resolved`]
82///   are left untouched (idempotent on a resolved document).
83/// * `graph` — the content graph for bare-filename / cross-page lookups.
84/// * `source_path` — the file containing the URLs, used by
85///   [`resolve_reference`] for relative-path disambiguation. It does NOT enter
86///   the emitted URL (see [`ContentGraph::pinned_url`]).
87pub fn resolve_urls(
88    doc: &mut Document,
89    graph: &ContentGraph,
90    source_path: &str,
91) -> UrlResolution {
92    // Phase 1: walk asset URLs (image refs) and accumulate their
93    // OutgoingLink entries. This pass replaces the deleted Stage 1
94    // `resolve::markdown_refs::resolve_markdown_refs` — it only touches
95    // asset URLs and produces OutgoingLink for resolved bare-filename
96    // images. The companion AST visitor lives at `resolve_image_urls`
97    // below.
98    let mut found = UrlResolution::default();
99    resolve_image_urls(doc, graph, source_path, &mut found);
100
101    // Phase 2: walk link URLs and accumulate their OutgoingLink entries.
102    // Replaces the deleted Stage 1
103    // `resolve::markdown_links::resolve_markdown_links` — only touches
104    // link URLs and produces OutgoingLink for resolved cross-page links.
105    // The AST visitor lives at `resolve_link_urls` below.
106    //
107    // Two-pass ordering matches Stage 1's resolve.rs sequence (refs first,
108    // then links). The image-URL display_text comes from alt; the link-URL
109    // display_text comes from the link text. Each phase appends to the
110    // shared `outgoing` Vec in document order.
111    resolve_link_urls(doc, graph, source_path, &mut found);
112
113    // Phase 3 (NOT done by default): the renderer's invariant requires
114    // every URL be `Url::Resolved` at HTML emission time. For non-graph
115    // URLs the visitor left as `Url::Unresolved` (resolver-prefixed,
116    // anchors that fell through, edge cases), the caller is responsible
117    // for one more classification pass before rendering. Callers that
118    // need a complete classification can call
119    // [`classify_remaining_urls`] explicitly. The src-tauri host pipeline
120    // chains a second `visit_urls_mut` to apply its `classify_url_prod`
121    // for page_map-aware decoding of the three sentinel prefixes.
122
123    found
124}
125
126// ---------------------------------------------------------------------------
127// Phase 1: image (Inline::Image::src) URL resolution
128// ---------------------------------------------------------------------------
129
130/// Walk every `Inline::Image::src` URL and resolve bare-filename references.
131///
132/// Replaces the deleted Stage 1 `resolve::markdown_refs::resolve_markdown_refs`
133/// (Phase 4 PR7a, 2026-05-28). Contract:
134/// - Only touches Inline::Image::src URLs (not Link URLs).
135/// - Bare filename + has-extension + no-path-separators → graph lookup.
136/// - On `Found`: rewrite to relative asset path; push OutgoingLink.
137/// - On `Unresolved`: leave URL as author-input (mark resolved-as-asset so
138///   the renderer accepts it).
139/// - Pipe-bearing URLs pass through unchanged (Phase 3 PR3 contract).
140/// - External / data / mailto / anchor / explicit-relative pass through.
141fn resolve_image_urls(
142    doc: &mut Document,
143    graph: &ContentGraph,
144    source_path: &str,
145    found: &mut UrlResolution,
146) {
147    walk_inline_images_mut(doc, &mut |inline| {
148        let (src, alt) = match inline {
149            Inline::Image { src, alt, .. } => (src, alt.clone()),
150            _ => return,
151        };
152        resolve_asset_url(src, &alt, graph, source_path, found);
153    });
154    // Hero/Gallery shortcodes carry image URLs as typed fields on the
155    // shortcode args (not as `Inline::Image`). Walk those structural
156    // URLs through the same bare-filename resolver so wikilink targets
157    // like `![[hero.jpg]]` resolve to `assets/hero.jpg` against the
158    // graph, mirroring the `Inline::Image` path. Regression fix for
159    // the chps-site home hero (2026-05-29): the previous skip left
160    // `args.image` as `Url::Unresolved("hero.jpg")` → the renderer
161    // emitted `<img src="hero.jpg">` instead of the depth-correct
162    // `assets/hero.jpg`.
163    for block in &mut doc.blocks {
164        resolve_shortcode_image_urls(block, graph, source_path, found);
165    }
166}
167
168/// Resolve one image-kind `Url` field against the content graph.
169///
170/// Extracted from `resolve_image_urls`'s per-`Inline::Image` body so the
171/// same logic can apply to structural image URLs that live on shortcode
172/// args (Hero, Gallery). Behavior:
173/// - Already `Url::Resolved` → no-op.
174/// - Pipe-bearing → pass through verbatim (Phase 3 PR3 contract).
175/// - External, anchor, data URLs → pass through (engine returns NotFound for
176///   these, so they fall through to the verbatim passthrough arm).
177/// - Separator-bearing, bare filename, or `/`-absolute → routed through
178///   [`resolve_asset_ref`] (the unified engine). On `Resolved` / `Ambiguous`:
179///   emit the target's pinned URL and push an OutgoingLink. On `NotFound`: keep
180///   the author's bytes and record a diagnostic — the build never hard-fails on
181///   an unresolved asset ref, and never invents a path either.
182///
183/// Every resolved href is [`ContentGraph::pinned_url`], so the referencing
184/// page's depth and the target folder's case are structurally out of the answer
185/// (moss#903 bug 3: the same embed emitted a working URL from the vault root and
186/// a broken one from a note two folders down, because the href was computed
187/// relative to the referencing file).
188fn resolve_asset_url(
189    url: &mut Url,
190    alt: &str,
191    graph: &ContentGraph,
192    source_path: &str,
193    found: &mut UrlResolution,
194) {
195    let raw = match url {
196        Url::Unresolved(s) => s.clone(),
197        Url::Resolved(_) => return,
198    };
199
200    // Pipe-bearing URLs pass through unchanged (Phase 3 PR3): authors
201    // use `![[file.jpg|attrs]]` for typed params; pipe in standard
202    // markdown URL is literal and intentionally 404s.
203    if raw.contains('|') {
204        *url = Url::Resolved(ResolvedUrl::new(raw, UrlKind::Asset));
205        return;
206    }
207
208    // External, anchor, and data URLs are not asset filesystem references.
209    // Pass them through before invoking the engine (which only understands
210    // filesystem paths) so we don't misinterpret `https://...` as a path.
211    if raw.starts_with('#')
212        || raw.starts_with("http://")
213        || raw.starts_with("https://")
214        || raw.starts_with("//")
215        || raw.starts_with("data:")
216        || raw.starts_with("mailto:")
217    {
218        *url = Url::Resolved(ResolvedUrl::new(raw, UrlKind::Asset));
219        return;
220    }
221
222    // Route ALL remaining refs (bare filenames, separator paths, absolute
223    // `/…` paths) through the unified asset engine. This replaces BOTH the
224    // old `is_bare_filename` branch (which called `resolve_reference`) and
225    // the old passthrough branch (which emitted the verbatim separator path,
226    // causing 404s for cross-directory relative paths).
227    //
228    // NOTE: provenance (SeparatorFallback / CaseMismatch / Ambiguous) is
229    // intentionally NOT logged here — moss-core is the pure, side-effect-free
230    // kernel (no `log`/I/O). The advisory author-facing warning is surfaced by
231    // the editor adapter (`editor::asset_resolver`) via the `@codemirror/lint`
232    // hover tooltip. The build's job here is only to emit a correct URL; a
233    // build-time console warning is a deferred follow-up (would require
234    // surfacing provenance to the src-tauri build layer).
235    match resolve_asset_ref(&raw, source_path, &GraphAssetIndex(graph)) {
236        AssetResolution::Resolved { root_rel, provenance: _ } => {
237            pin_asset_url(url, root_rel, alt, graph, found);
238        }
239        AssetResolution::Ambiguous { chosen, candidates: _ } => {
240            pin_asset_url(url, chosen, alt, graph, found);
241        }
242        AssetResolution::NotFound => {
243            // Keep the author's bytes (an unresolved asset ref never fails the
244            // build) and say so. Synthesizing a plausible-looking path is what
245            // shipped a 404 that looked like a working link.
246            found.diagnostics.push(Diagnostic {
247                message: format!("Unresolved asset reference: {raw}"),
248                source_path: source_path.to_string(),
249                reference: raw.clone(),
250                // The one blocking kind. `raw` is the author's literal
251                // spelling, which is the string they will search for.
252                kind: DiagnosticKind::MissingAsset,
253            });
254            *url = Url::Resolved(ResolvedUrl::new(raw, UrlKind::Asset));
255        }
256    }
257}
258
259/// Emit `root_rel`'s pinned URL and record the dependency edge — the one place a
260/// resolved asset reference becomes an href. Neither the referencing page nor
261/// the authored spelling of the reference reaches the emitted URL.
262fn pin_asset_url(
263    url: &mut Url,
264    root_rel: String,
265    alt: &str,
266    graph: &ContentGraph,
267    found: &mut UrlResolution,
268) {
269    let pinned = graph.pinned_url(&root_rel);
270    found.outgoing.push(OutgoingLink {
271        target_path: root_rel,
272        display_text: alt.to_string(),
273        link_type: LinkType::Standard,
274    });
275    *url = Url::Resolved(ResolvedUrl::new(pinned, UrlKind::Asset));
276}
277
278/// Recursively descend into shortcode-bearing blocks and resolve any
279/// structural image `Url` fields (HeroShortcode::image, GalleryItem::src).
280/// Container shortcodes (Grid, Hero overlay) may nest other shortcodes —
281/// recurse through their inner blocks. Skips Inline::Image-bearing
282/// blocks because the `walk_inline_images_mut` pass above already
283/// handled them.
284fn resolve_shortcode_image_urls(
285    block: &mut Block,
286    graph: &ContentGraph,
287    source_path: &str,
288    found: &mut UrlResolution,
289) {
290    match block {
291        Block::Shortcode(sc) => match sc {
292            Shortcode::Hero(args) => {
293                if let Some(image_url) = args.image.as_mut() {
294                    resolve_asset_url(image_url, "", graph, source_path, found);
295                }
296                // Multi-image hero: every extra slide resolves exactly like
297                // the primary — skipping this re-creates the 2026-05-29
298                // chps-site regression (raw filenames at depth) per slide.
299                for image_url in &mut args.extra_images {
300                    resolve_asset_url(image_url, "", graph, source_path, found);
301                }
302                // Overlay may itself contain shortcodes (e.g. `::::buttons`
303                // inside `:::hero`); recurse so any nested Hero/Gallery
304                // structural image URLs resolve too.
305                for nested in &mut args.overlay {
306                    resolve_shortcode_image_urls(nested, graph, source_path, found);
307                }
308            }
309            Shortcode::Gallery(args) => {
310                for item in &mut args.items {
311                    let alt = item.alt.clone();
312                    resolve_asset_url(&mut item.src, &alt, graph, source_path, found);
313                }
314            }
315            Shortcode::Grid(args) => {
316                for cell in &mut args.cells {
317                    for nested in cell {
318                        resolve_shortcode_image_urls(nested, graph, source_path, found);
319                    }
320                }
321            }
322            Shortcode::Subscribe(_) | Shortcode::Buttons(_) | Shortcode::Recent(_) | Shortcode::Apply(_) => {}
323        },
324        // Container blocks: recurse so nested shortcodes (Hero inside a
325        // Callout, Grid inside a list, etc.) are reached.
326        Block::Callout { children, .. }
327        | Block::BlockQuote(children)
328        | Block::FootnoteDefinition { children, .. } => {
329            for nested in children {
330                resolve_shortcode_image_urls(nested, graph, source_path, found);
331            }
332        }
333        Block::List { items, .. } => {
334            for item_blocks in items {
335                for nested in item_blocks {
336                    resolve_shortcode_image_urls(nested, graph, source_path, found);
337                }
338            }
339        }
340        Block::LinkCard { children, .. } => {
341            for nested in children {
342                resolve_shortcode_image_urls(nested, graph, source_path, found);
343            }
344        }
345        // Leaf / inline-only blocks: nothing structural to resolve here.
346        Block::Heading { .. }
347        | Block::Paragraph(_)
348        | Block::Table { .. }
349        | Block::Figure { .. }
350        | Block::CodeBlock { .. }
351        | Block::ThematicBreak
352        | Block::Other(_) => {}
353    }
354}
355
356// ---------------------------------------------------------------------------
357// Phase 2: link (Inline::Link::url + Block::LinkCard::url) URL resolution
358// ---------------------------------------------------------------------------
359
360/// Walk every link URL (Inline::Link::url, Block::LinkCard::url) and
361/// resolve markdown / asset targets via the content graph.
362///
363/// Mirrors `markdown_links::resolve_markdown_links`:
364/// - Only touches Link URLs (image URLs were handled in phase 1).
365/// - Resolvable targets (not external / not anchor / not protocol /
366///   not absolute-path / not already-prefixed) → graph lookup.
367/// - On `Found`: classify into Internal (markdown) / Asset (binary) and
368///   push OutgoingLink with target_path = resolved path.
369/// - On `Unresolved`: leave URL author-input; Stage 1 emitted a diagnostic
370///   here, but PR6 mirrors the byte-equivalence contract (no diagnostic in
371///   the OutgoingLink Vec since Diagnostic is a separate stream).
372/// - Anchor / mailto / tel / external pass through with the matching
373///   UrlKind so the renderer attaches the right attributes.
374fn resolve_link_urls(
375    doc: &mut Document,
376    graph: &ContentGraph,
377    source_path: &str,
378    found: &mut UrlResolution,
379) {
380    walk_links_mut(doc, &mut |link_url, display_text, is_wikilink| {
381        let raw = match link_url {
382            Url::Unresolved(s) => s.clone(),
383            Url::Resolved(_) => return,
384        };
385
386        // Author-facing short-circuits: classify and stop.
387        if let Some(rest) = raw.strip_prefix("mailto:") {
388            *link_url = Url::Resolved(ResolvedUrl::new(format!("mailto:{rest}"), UrlKind::Mailto));
389            return;
390        }
391        if let Some(rest) = raw.strip_prefix("tel:") {
392            *link_url = Url::Resolved(ResolvedUrl::new(format!("tel:{rest}"), UrlKind::Tel));
393            return;
394        }
395        if raw.starts_with('#') {
396            // Same-page anchor. For wikilinks (`[[#Heading]]`) slug the
397            // fragment so it matches the rendered heading id; markdown
398            // anchors (`[x](#frag)`) stay raw (literal author-supplied id).
399            let href = if is_wikilink {
400                slug_wikilink_suffix(&raw)
401            } else {
402                raw
403            };
404            *link_url = Url::Resolved(ResolvedUrl::new(href, UrlKind::Anchor));
405            return;
406        }
407        if raw.starts_with("http://")
408            || raw.starts_with("https://")
409            || raw.starts_with("//")
410            || raw.starts_with("data:")
411        {
412            *link_url = Url::Resolved(ResolvedUrl::new(raw, UrlKind::External));
413            return;
414        }
415
416        // Stage 1 carry-over: URLs already prefixed with a resolver
417        // sentinel (`moss-resolved:`, `moss-newtab:`, `wikilink:`) carry
418        // Stage 1 / upstream state the visitor cannot decode in isolation
419        // — the final pretty URL depends on the host's `page_map`, which
420        // lives in src-tauri's pipeline context. Leave these as
421        // `Url::Unresolved` so the host's per-URL classifier
422        // (`classify_url_prod` in src-tauri's pipeline) can apply the
423        // page_map-aware decoding. This preserves the byte-equivalence
424        // contract (no OutgoingLink emitted for already-resolved targets
425        // — Stage 1 already counted them) while letting the host close
426        // the prefix-decoding loop.
427        if raw.starts_with("moss-resolved:")
428            || raw.starts_with("moss-newtab:")
429            || raw.starts_with("wikilink:")
430        {
431            // Leave Unresolved; host pass classifies.
432            return;
433        }
434
435        // Absolute filesystem path — treat as opaque. Mirrors
436        // markdown_links: `if url.starts_with('/') { return false; }`.
437        if raw.starts_with('/') {
438            *link_url = Url::Resolved(ResolvedUrl::new(raw, UrlKind::Internal));
439            return;
440        }
441
442        // Resolvable: split query/fragment, look up the path against the
443        // content graph, push OutgoingLink, emit the `moss-resolved:`
444        // sentinel for the host classifier. Mirrors
445        // markdown_links::rewrite_line byte-for-byte: same sentinel shape
446        // (`moss-resolved:<path>[<suffix>]`), same suffix concatenation.
447        //
448        // Phase 4 PR7a-stage1b (2026-05-28): moss-core resolves the
449        // filesystem path; src-tauri's `classify_url_prod` decodes the
450        // sentinel into the final pretty / external / asset URL using
451        // `page_map`, `external_url_map`, and the wikilink-class signal.
452        // The sentinel IS the moss-core ↔ src-tauri layering seam — the
453        // visitor must NOT collapse it to a final `Url::Resolved` or
454        // page_map decoding silently breaks.
455        let (path_part, suffix) = split_path_suffix(&raw);
456        match resolve_reference(path_part, graph, source_path) {
457            ResolvedRef::Found(resolved) => {
458                found.outgoing.push(OutgoingLink {
459                    target_path: resolved.clone(),
460                    display_text: display_text.to_string(),
461                    link_type: LinkType::Standard,
462                });
463                // For wikilinks, slug the `#fragment` so the emitted href
464                // matches the rendered heading id. The `?query` portion (if
465                // any) is preserved by `slug_wikilink_suffix`. Markdown links
466                // keep their suffix raw — a literal author-supplied URL.
467                let sentinel = match suffix {
468                    Some(s) => {
469                        let s = if is_wikilink {
470                            slug_wikilink_suffix(s)
471                        } else {
472                            s.to_string()
473                        };
474                        format!("moss-resolved:{}{}", resolved, s)
475                    }
476                    None => format!("moss-resolved:{}", resolved),
477                };
478                *link_url = Url::Unresolved(sentinel);
479            }
480            ResolvedRef::Unresolved => {
481                // Mirrors Stage 1: leave the URL as authored — no
482                // `moss-resolved:` prefix, no synthesized target. Internal so
483                // the renderer's `Url::Resolved` invariant holds.
484                found.diagnostics.push(Diagnostic {
485                    message: format!("Unresolved link target: {raw}"),
486                    source_path: source_path.to_string(),
487                    reference: raw.clone(),
488                    kind: DiagnosticKind::Other,
489                });
490                *link_url = Url::Resolved(ResolvedUrl::new(raw, UrlKind::Internal));
491            }
492        }
493    });
494}
495
496/// Split a URL into (path, suffix) where `suffix` is `?query` and/or
497/// `#fragment` in source order. Suffix is opaque — round-trip parity with
498/// `crate::build::markdown::pipeline::classify_url_prod` (the src-tauri
499/// decoder) is the contract; this function must not reorder, normalize,
500/// or escape the suffix bytes.
501///
502/// The parallel src-tauri implementation lives at
503/// `src-tauri/src/build/markdown/pipeline.rs::split_path_suffix` and must
504/// share this exact shape.
505fn split_path_suffix(url: &str) -> (&str, Option<&str>) {
506    let q = url.find('?');
507    let h = url.find('#');
508    let cut = match (q, h) {
509        (Some(a), Some(b)) => Some(a.min(b)),
510        (Some(a), None) => Some(a),
511        (None, Some(b)) => Some(b),
512        (None, None) => None,
513    };
514    match cut {
515        #[allow(clippy::string_slice)]
516        Some(pos) => (&url[..pos], Some(&url[pos..])),
517        None => (url, None),
518    }
519}
520
521/// Slug the `#fragment` of a wikilink `suffix` so the emitted href matches
522/// the rendered heading id (`obsidian_heading_anchor`). Only the fragment is
523/// transformed: any leading `?query` is preserved verbatim. Block refs
524/// (`#^id`) keep their id raw (minus the caret), mirroring
525/// [`crate::resolve::wikilink_dispatch`]'s `build_anchor`. This must ONLY be
526/// called for wikilinks (`is_wikilink: true`); regular markdown links keep
527/// their fragment raw (it is a literal URL — `#L42`, hand-authored ids, etc.).
528///
529/// `suffix` is the value returned by [`split_path_suffix`] — it begins with
530/// `?` or `#`. Shapes handled:
531/// - `#frag` → `#<slug>`
532/// - `?query` → `?query` (no fragment, untouched)
533/// - `?query#frag` → `?query#<slug>` (query verbatim, fragment slugged)
534///
535/// `pub` (ADR-036 stage 2): `src-tauri`'s `newsletter.rs` has no content
536/// graph to resolve wikilinks through — it just needs this same fragment
537/// half — so it calls this directly instead of carrying its own copy of the
538/// block-ref-vs-heading-anchor branch.
539pub fn slug_wikilink_suffix(suffix: &str) -> String {
540    use crate::heading::anchor::obsidian_heading_anchor;
541
542    // Find the fragment (`#…`); everything before it is a `?query` we leave
543    // untouched. There is at most one `#` in a well-formed suffix.
544    match suffix.find('#') {
545        None => suffix.to_string(), // query-only (or empty) — nothing to slug
546        Some(h) => {
547            #[allow(clippy::string_slice)]
548            let (head, frag_with_hash) = (&suffix[..h], &suffix[h + 1..]);
549            let slugged = if let Some(block_id) = frag_with_hash.strip_prefix('^') {
550                // Block ref: keep the id raw (caret stripped). Matches build_anchor.
551                block_id.to_string()
552            } else {
553                obsidian_heading_anchor(frag_with_hash)
554            };
555            format!("{head}#{slugged}")
556        }
557    }
558}
559
560// ---------------------------------------------------------------------------
561// Phase 3: ensure no Url::Unresolved survives
562// ---------------------------------------------------------------------------
563
564/// Classify any URL left as `Url::Unresolved` after phases 1 + 2 into a
565/// best-effort `Url::Resolved`. The renderer's invariant requires no
566/// `Url::Unresolved` reaches HTML emission; this is the safety net that
567/// catches URLs the per-kind phases didn't visit (e.g., a future
568/// `Inline::Link` variant added before its phase-2 arm is wired).
569///
570/// Callers that follow [`resolve_urls`] with their own per-URL classifier
571/// (e.g., src-tauri's pipeline calling `classify_url_prod` for
572/// resolver-prefix decoding) should NOT call this — let the secondary
573/// classifier handle the remaining URLs. Callers that have no secondary
574/// pass should call this to maintain the render invariant.
575pub fn classify_remaining_urls(doc: &mut Document) {
576    visit_urls_mut(doc, |url| {
577        if let Url::Unresolved(raw) = url {
578            // Conservative fallback: treat as External (opens in new tab,
579            // no graph lookup). Unknown URLs are external by nature;
580            // guessing Internal would be wrong and new-tab is safe.
581            let kind = classify_unresolved_kind(raw);
582            let raw_owned = std::mem::take(raw);
583            *url = Url::Resolved(ResolvedUrl::new(raw_owned, kind));
584        }
585    });
586}
587
588/// Best-effort kind classification for an Unresolved URL that escaped the
589/// per-kind phases. Mirrors the prefix-based detection in
590/// `pipeline::classify_url_prod` for consistency.
591fn classify_unresolved_kind(raw: &str) -> UrlKind {
592    if raw.starts_with("mailto:") {
593        UrlKind::Mailto
594    } else if raw.starts_with("tel:") {
595        UrlKind::Tel
596    } else if raw.starts_with('#') {
597        UrlKind::Anchor
598    } else if raw.starts_with("http://")
599        || raw.starts_with("https://")
600        || raw.starts_with("//")
601        || raw.starts_with("data:")
602    {
603        UrlKind::External
604    } else {
605        UrlKind::Internal
606    }
607}
608
609// ---------------------------------------------------------------------------
610// Per-kind walkers (image-only / link-only)
611// ---------------------------------------------------------------------------
612
613/// Walk every `Inline::Image` in the document and invoke `f` with a `&mut`
614/// reference to the inline. Used by phase 1 — separates image src
615/// classification from link URL classification.
616fn walk_inline_images_mut<F>(doc: &mut Document, f: &mut F)
617where
618    F: FnMut(&mut Inline),
619{
620    for block in &mut doc.blocks {
621        walk_images_in_block(block, f);
622    }
623}
624
625fn walk_images_in_block<F>(block: &mut Block, f: &mut F)
626where
627    F: FnMut(&mut Inline),
628{
629    match block {
630        Block::Heading { children, .. } | Block::Paragraph(children) => {
631            for inline in children {
632                walk_images_in_inline(inline, f);
633            }
634        }
635        Block::Callout { children, .. }
636        | Block::BlockQuote(children)
637        | Block::FootnoteDefinition { children, .. } => {
638            for nested in children {
639                walk_images_in_block(nested, f);
640            }
641        }
642        Block::List { items, .. } => {
643            for item_blocks in items {
644                for nested in item_blocks {
645                    walk_images_in_block(nested, f);
646                }
647            }
648        }
649        Block::Table { header, rows, .. } => {
650            for cell in header {
651                for inline in cell {
652                    walk_images_in_inline(inline, f);
653                }
654            }
655            for row in rows {
656                for cell in row {
657                    for inline in cell {
658                        walk_images_in_inline(inline, f);
659                    }
660                }
661            }
662        }
663        Block::Shortcode(sc) => {
664            walk_images_in_shortcode(sc, f);
665        }
666        Block::Figure { image, caption, .. } => {
667            walk_images_in_inline(image, f);
668            if let Some(cap) = caption {
669                for inline in cap {
670                    walk_images_in_inline(inline, f);
671                }
672            }
673        }
674        Block::LinkCard { children, .. } => {
675            for nested in children {
676                walk_images_in_block(nested, f);
677            }
678        }
679        Block::CodeBlock { .. } | Block::ThematicBreak | Block::Other(_) => {}
680    }
681}
682
683fn walk_images_in_shortcode<F>(sc: &mut Shortcode, f: &mut F)
684where
685    F: FnMut(&mut Inline),
686{
687    match sc {
688        Shortcode::Subscribe(_) | Shortcode::Buttons(_) | Shortcode::Recent(_) | Shortcode::Apply(_) => {}
689        Shortcode::Gallery(args) => {
690            // Gallery items carry their src as a structural `Url` on
691            // GalleryItem, not as an `Inline::Image`. The Inline-image
692            // walker has nothing to do here; the structural URL is
693            // resolved by `resolve_shortcode_image_urls` instead.
694            let _ = args;
695        }
696        Shortcode::Hero(args) => {
697            // Hero's image is a structural `Url` field, not an
698            // `Inline::Image` — resolved by `resolve_shortcode_image_urls`.
699            // The overlay blocks may still contain `Inline::Image`s
700            // (e.g. inside markdown paragraphs); descend so those reach
701            // the inline walker.
702            for block in &mut args.overlay {
703                walk_images_in_block(block, f);
704            }
705        }
706        Shortcode::Grid(args) => {
707            for cell_blocks in &mut args.cells {
708                for block in cell_blocks {
709                    walk_images_in_block(block, f);
710                }
711            }
712        }
713    }
714}
715
716fn walk_images_in_inline<F>(inline: &mut Inline, f: &mut F)
717where
718    F: FnMut(&mut Inline),
719{
720    match inline {
721        Inline::Image { .. } => {
722            f(inline);
723        }
724        Inline::Link { children, .. } => {
725            for nested in children {
726                walk_images_in_inline(nested, f);
727            }
728        }
729        Inline::Emphasis(children) | Inline::Strong(children) | Inline::Strikethrough(children) => {
730            for nested in children {
731                walk_images_in_inline(nested, f);
732            }
733        }
734        Inline::Text(_)
735        | Inline::Code(_)
736        | Inline::LineBreak
737        | Inline::FootnoteRef(_)
738        | Inline::TaskMarker(_)
739        | Inline::Other(_) => {}
740    }
741}
742
743/// Walk every link URL in the document (Inline::Link::url +
744/// Block::LinkCard::url) and invoke `f` with `(&mut Url, display_text)`.
745///
746/// The `display_text` is the link text (concatenated from the Link's
747/// children) — needed for the OutgoingLink::display_text contract.
748fn walk_links_mut<F>(doc: &mut Document, f: &mut F)
749where
750    F: FnMut(&mut Url, &str, bool),
751{
752    for block in &mut doc.blocks {
753        walk_links_in_block(block, f);
754    }
755}
756
757fn walk_links_in_block<F>(block: &mut Block, f: &mut F)
758where
759    F: FnMut(&mut Url, &str, bool),
760{
761    match block {
762        Block::Heading { children, .. } | Block::Paragraph(children) => {
763            for inline in children {
764                walk_links_in_inline(inline, f);
765            }
766        }
767        Block::Callout { children, .. }
768        | Block::BlockQuote(children)
769        | Block::FootnoteDefinition { children, .. } => {
770            for nested in children {
771                walk_links_in_block(nested, f);
772            }
773        }
774        Block::List { items, .. } => {
775            for item_blocks in items {
776                for nested in item_blocks {
777                    walk_links_in_block(nested, f);
778                }
779            }
780        }
781        Block::Table { header, rows, .. } => {
782            for cell in header {
783                for inline in cell {
784                    walk_links_in_inline(inline, f);
785                }
786            }
787            for row in rows {
788                for cell in row {
789                    for inline in cell {
790                        walk_links_in_inline(inline, f);
791                    }
792                }
793            }
794        }
795        Block::Shortcode(sc) => {
796            walk_links_in_shortcode(sc, f);
797        }
798        Block::Figure { caption, .. } => {
799            if let Some(cap) = caption {
800                for inline in cap {
801                    walk_links_in_inline(inline, f);
802                }
803            }
804        }
805        Block::LinkCard { url, children } => {
806            // Compound-link card: the wrapping href is a link URL. Use
807            // the inner text content as display_text by recursively
808            // gathering it from the children (best-effort — empty string
809            // if no text is found).
810            // LinkCard wrapping href is never a wikilink (it's a compound
811            // markdown link card), so pass is_wikilink=false.
812            let display = gather_text_blocks(children);
813            f(url, &display, false);
814            for nested in children {
815                walk_links_in_block(nested, f);
816            }
817        }
818        Block::CodeBlock { .. } | Block::ThematicBreak | Block::Other(_) => {}
819    }
820}
821
822fn walk_links_in_shortcode<F>(sc: &mut Shortcode, f: &mut F)
823where
824    F: FnMut(&mut Url, &str, bool),
825{
826    match sc {
827        Shortcode::Subscribe(_) | Shortcode::Recent(_) | Shortcode::Apply(_) => {}
828        Shortcode::Buttons(args) => {
829            for item in &mut args.items {
830                // ButtonItem display text comes from item.text per the
831                // shortcode shape (crates/moss-core/src/ast/shortcode.rs).
832                // Button URLs are authored markdown targets, not wikilinks.
833                let text = item.text.clone();
834                f(&mut item.url, &text, false);
835            }
836        }
837        Shortcode::Gallery(_) => {
838            // Gallery items use src URLs (image-kind), not link URLs.
839            // No link-walk action.
840        }
841        Shortcode::Hero(args) => {
842            for block in &mut args.overlay {
843                walk_links_in_block(block, f);
844            }
845        }
846        Shortcode::Grid(args) => {
847            for cell_blocks in &mut args.cells {
848                for block in cell_blocks {
849                    walk_links_in_block(block, f);
850                }
851            }
852        }
853    }
854}
855
856fn walk_links_in_inline<F>(inline: &mut Inline, f: &mut F)
857where
858    F: FnMut(&mut Url, &str, bool),
859{
860    match inline {
861        Inline::Link {
862            url,
863            children,
864            is_wikilink,
865            ..
866        } => {
867            // display_text = concatenated plain text of the children.
868            // Matches markdown_links::rewrite_line, which uses the raw
869            // text between `[` and `]` (no rendering, just the literal).
870            let display = gather_text_inlines(children);
871            f(url, &display, *is_wikilink);
872            // Descend so nested Links (rare in CommonMark but possible
873            // via parser quirks) get visited too.
874            for nested in children {
875                walk_links_in_inline(nested, f);
876            }
877        }
878        Inline::Image { .. } => {
879            // Image src is a Url but it's image-kind — handled by phase 1.
880        }
881        Inline::Emphasis(children) | Inline::Strong(children) | Inline::Strikethrough(children) => {
882            for nested in children {
883                walk_links_in_inline(nested, f);
884            }
885        }
886        Inline::Text(_)
887        | Inline::Code(_)
888        | Inline::LineBreak
889        | Inline::FootnoteRef(_)
890        | Inline::TaskMarker(_)
891        | Inline::Other(_) => {}
892    }
893}
894
895/// Concatenate the plain-text content of a list of inlines, mirroring
896/// pulldown-cmark's behavior of treating link text as a verbatim string.
897/// Used to populate `OutgoingLink::display_text`.
898fn gather_text_inlines(inlines: &[Inline]) -> String {
899    let mut s = String::new();
900    for inline in inlines {
901        gather_text_inline(inline, &mut s);
902    }
903    s
904}
905
906fn gather_text_inline(inline: &Inline, out: &mut String) {
907    match inline {
908        Inline::Text(t) => out.push_str(t),
909        Inline::Code(c) => out.push_str(c),
910        Inline::Emphasis(children) | Inline::Strong(children) | Inline::Strikethrough(children) => {
911            for nested in children {
912                gather_text_inline(nested, out);
913            }
914        }
915        Inline::Link { children, .. } => {
916            for nested in children {
917                gather_text_inline(nested, out);
918            }
919        }
920        Inline::Image { alt, .. } => out.push_str(alt),
921        Inline::LineBreak => out.push('\n'),
922        // A marker is a pointer, not prose: it must not leak into a
923        // description, a slug, or a numeric-column probe. A task checkbox is
924        // the same — `- [x] Ship it` should summarize as "Ship it".
925        Inline::FootnoteRef(_) | Inline::TaskMarker(_) | Inline::Other(_) => {}
926    }
927}
928
929/// Concatenate the plain-text content of a list of blocks. Used by
930/// Block::LinkCard arm to populate the OutgoingLink::display_text.
931fn gather_text_blocks(blocks: &[Block]) -> String {
932    let mut s = String::new();
933    for block in blocks {
934        gather_text_block(block, &mut s);
935    }
936    s
937}
938
939fn gather_text_block(block: &Block, out: &mut String) {
940    match block {
941        Block::Heading { children, .. } | Block::Paragraph(children) => {
942            for inline in children {
943                gather_text_inline(inline, out);
944            }
945        }
946        Block::Figure { image, caption, .. } => {
947            if let Inline::Image { alt, .. } = image {
948                out.push_str(alt);
949            }
950            if let Some(cap) = caption {
951                for inline in cap {
952                    gather_text_inline(inline, out);
953                }
954            }
955        }
956        _ => {}
957    }
958}
959
960// ---------------------------------------------------------------------------
961// Tests
962// ---------------------------------------------------------------------------
963
964#[cfg(test)]
965#[path = "resolve_urls_tests.rs"]
966mod tests;