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//! Phase 4 PR6 (2026-05-28): replaces the two Stage 1 passes
5//! (`markdown_refs::resolve_markdown_refs` for bare-filename image refs,
6//! `markdown_links::resolve_markdown_links` for standard `[text](url)`
7//! markdown links) with one typed visitor over the AST.
8//!
9//! Phase 4 PR7a (2026-05-28): `markdown_refs::resolve_markdown_refs` was
10//! deleted after its parity with this visitor was proven.
11//!
12//! Phase 4 PR7a-stage1b (2026-05-28): `markdown_links::resolve_markdown_links`
13//! was deleted in this PR. The visitor's `resolve_link_urls` now emits the
14//! same `moss-resolved:<path>` sentinel Stage 1 emitted, leaving the URL
15//! as `Url::Unresolved` so src-tauri's `classify_url_prod` decoder can
16//! apply `page_map` / `external_url_map` / wikilink-class-aware decoding
17//! unchanged. The sentinel IS the moss-core ↔ src-tauri layering seam:
18//! moss-core resolves filesystem paths, src-tauri owns the deployed URL
19//! space.
20//!
21//! ## Why one function, not two
22//!
23//! Stage 1 split bare-image refs and standard-link refs into separate
24//! line-level passes because each had its own source-rewriting needs
25//! (bare images got a relative path; standard links got a `moss-resolved:`
26//! prefix that downstream code decoded). The typed AST distinguishes
27//! `Inline::Image::src` (image refs, always asset URLs) from
28//! `Inline::Link::url` (standard links, may be markdown targets or assets)
29//! structurally — one walk classifies both correctly.
30//!
31//! ## Fence-awareness is automatic
32//!
33//! Stage 1 carried 100+ lines of fence-tracking regex per pass to skip
34//! code blocks (since both passes scanned raw markdown text). The typed
35//! AST handles this structurally — `Block::CodeBlock` and inline
36//! `Inline::Code` are not visited by [`visit_urls_mut`]. The visitor
37//! never sees a URL inside a code fence.
38//!
39//! ## OutgoingLink contract
40//!
41//! The returned `Vec<OutgoingLink>` carries the same load-bearing
42//! shape (target_path, link_type, document-order sequence) Stage 1's
43//! `markdown_refs::resolve_markdown_refs` + `markdown_links::
44//! resolve_markdown_links` produced before deletion. The visitor uses
45//! parsed inline text for `display_text`; Stage 1 used the raw source
46//! between `[` and `]`. Since `display_text` has no production
47//! consumer, this divergence is non-breaking — recorded as a known
48//! shape-spec deviation in `link_wrapping_image_target_path`.
49
50use super::document::Document;
51use super::node::{Block, Inline};
52use super::shortcode::Shortcode;
53use super::url::{ResolvedUrl, Url, UrlKind};
54use super::visit::visit_urls_mut;
55use crate::content_graph::ContentGraph;
56use crate::resolve::asset_class::{resolve_asset_ref, AssetIndex, AssetResolution};
57use crate::resolve::fuzzy_path::{relative_asset_path, resolve_reference, ResolvedRef};
58use crate::resolve::{LinkType, OutgoingLink};
59
60// ---------------------------------------------------------------------------
61// GraphAssetIndex: adapts ContentGraph to the AssetIndex trait so the pure
62// engine (resolve_asset_ref) can run against a real content graph.
63// ---------------------------------------------------------------------------
64
65/// Adapts [`ContentGraph`] to the [`AssetIndex`] trait.
66///
67/// Wraps a borrowed `ContentGraph` so that `resolve_asset_ref` (the pure
68/// shared engine in `moss_core::resolve::asset_class`) can be driven by the
69/// build-time in-memory index — identical to how `FsAssetIndex` in src-tauri
70/// drives it from the live filesystem. Exposed `pub` so integration tests and
71/// editor↔build parity tests can construct both adapters over the same file set.
72pub struct GraphAssetIndex<'a>(pub &'a ContentGraph);
73
74impl<'a> AssetIndex for GraphAssetIndex<'a> {
75    fn contains(&self, p: &str) -> bool {
76        self.0.asset_contains(p)
77    }
78    fn contains_ci(&self, p: &str) -> Option<String> {
79        self.0.asset_contains_ci(p)
80    }
81    fn find_by_suffix(&self, s: &str) -> Vec<String> {
82        self.0.asset_find_by_suffix(s)
83    }
84}
85
86/// Walk every URL in `doc` and classify it into [`Url::Resolved`].
87///
88/// Returns the list of [`OutgoingLink`] entries discovered during resolution
89/// — byte-equivalent to today's Stage 1 `markdown_refs` + `markdown_links`
90/// combined output (same shape, same sequence).
91///
92/// # Arguments
93///
94/// * `doc` — the typed document. Every [`Url::Unresolved`] is replaced in
95///   place with a [`Url::Resolved`]. URLs that are already [`Url::Resolved`]
96///   are left untouched (idempotent on a resolved document).
97/// * `graph` — the content graph for bare-filename / cross-page lookups.
98/// * `source_path` — the file containing the URLs, used by
99///   [`resolve_reference`] for relative-path disambiguation and by
100///   [`relative_asset_path`] for computing relative asset hrefs.
101pub fn resolve_urls(
102    doc: &mut Document,
103    graph: &ContentGraph,
104    source_path: &str,
105) -> Vec<OutgoingLink> {
106    // Phase 1: walk asset URLs (image refs) and accumulate their
107    // OutgoingLink entries. This pass replaces the deleted Stage 1
108    // `resolve::markdown_refs::resolve_markdown_refs` — it only touches
109    // asset URLs and produces OutgoingLink for resolved bare-filename
110    // images. The companion AST visitor lives at `resolve_image_urls`
111    // below.
112    let mut outgoing: Vec<OutgoingLink> = Vec::new();
113    resolve_image_urls(doc, graph, source_path, &mut outgoing);
114
115    // Phase 2: walk link URLs and accumulate their OutgoingLink entries.
116    // Replaces the deleted Stage 1
117    // `resolve::markdown_links::resolve_markdown_links` — only touches
118    // link URLs and produces OutgoingLink for resolved cross-page links.
119    // The AST visitor lives at `resolve_link_urls` below.
120    //
121    // Two-pass ordering matches Stage 1's resolve.rs sequence (refs first,
122    // then links). The image-URL display_text comes from alt; the link-URL
123    // display_text comes from the link text. Each phase appends to the
124    // shared `outgoing` Vec in document order.
125    resolve_link_urls(doc, graph, source_path, &mut outgoing);
126
127    // Phase 3 (NOT done by default): the renderer's invariant requires
128    // every URL be `Url::Resolved` at HTML emission time. For non-graph
129    // URLs the visitor left as `Url::Unresolved` (resolver-prefixed,
130    // anchors that fell through, edge cases), the caller is responsible
131    // for one more classification pass before rendering. Callers that
132    // need a complete classification can call
133    // [`classify_remaining_urls`] explicitly. The src-tauri host pipeline
134    // chains a second `visit_urls_mut` to apply its `classify_url_prod`
135    // for page_map-aware decoding of the three sentinel prefixes.
136
137    outgoing
138}
139
140// ---------------------------------------------------------------------------
141// Phase 1: image (Inline::Image::src) URL resolution
142// ---------------------------------------------------------------------------
143
144/// Walk every `Inline::Image::src` URL and resolve bare-filename references.
145///
146/// Replaces the deleted Stage 1 `resolve::markdown_refs::resolve_markdown_refs`
147/// (Phase 4 PR7a, 2026-05-28). Contract:
148/// - Only touches Inline::Image::src URLs (not Link URLs).
149/// - Bare filename + has-extension + no-path-separators → graph lookup.
150/// - On `Found`: rewrite to relative asset path; push OutgoingLink.
151/// - On `Unresolved`: leave URL as author-input (mark resolved-as-asset so
152///   the renderer accepts it).
153/// - Pipe-bearing URLs pass through unchanged (Phase 3 PR3 contract).
154/// - External / data / mailto / anchor / explicit-relative pass through.
155fn resolve_image_urls(
156    doc: &mut Document,
157    graph: &ContentGraph,
158    source_path: &str,
159    outgoing: &mut Vec<OutgoingLink>,
160) {
161    walk_inline_images_mut(doc, &mut |inline| {
162        let (src, alt) = match inline {
163            Inline::Image { src, alt, .. } => (src, alt.clone()),
164            _ => return,
165        };
166        resolve_asset_url(src, &alt, graph, source_path, outgoing);
167    });
168    // Hero/Gallery shortcodes carry image URLs as typed fields on the
169    // shortcode args (not as `Inline::Image`). Walk those structural
170    // URLs through the same bare-filename resolver so wikilink targets
171    // like `![[hero.jpg]]` resolve to `assets/hero.jpg` against the
172    // graph, mirroring the `Inline::Image` path. Regression fix for
173    // the chps-site home hero (2026-05-29): the previous skip left
174    // `args.image` as `Url::Unresolved("hero.jpg")` → the renderer
175    // emitted `<img src="hero.jpg">` instead of the depth-correct
176    // `assets/hero.jpg`.
177    for block in &mut doc.blocks {
178        resolve_shortcode_image_urls(block, graph, source_path, outgoing);
179    }
180}
181
182/// Resolve one image-kind `Url` field against the content graph.
183///
184/// Extracted from `resolve_image_urls`'s per-`Inline::Image` body so the
185/// same logic can apply to structural image URLs that live on shortcode
186/// args (Hero, Gallery). Behavior:
187/// - Already `Url::Resolved` → no-op.
188/// - Pipe-bearing → pass through verbatim (Phase 3 PR3 contract).
189/// - External, anchor, data URLs → pass through (engine returns NotFound for
190///   these, so they fall through to the verbatim passthrough arm).
191/// - Separator-bearing or bare filename → routed through [`resolve_asset_ref`]
192///   (the unified engine). On `Resolved`: rewrite to relative asset path and
193///   push an OutgoingLink. On `Ambiguous`: pick the shortest match, warn, push.
194///   On `NotFound`: leave as authored (matches Stage 1 behavior, no hard fail).
195/// - `/`-absolute: the engine resolves from root; re-emit as `/<root_rel>` so
196///   the absolute form is preserved in the rendered HTML (no pretty-URL nesting).
197fn resolve_asset_url(
198    url: &mut Url,
199    alt: &str,
200    graph: &ContentGraph,
201    source_path: &str,
202    outgoing: &mut Vec<OutgoingLink>,
203) {
204    let raw = match url {
205        Url::Unresolved(s) => s.clone(),
206        Url::Resolved(_) => return,
207    };
208
209    // Pipe-bearing URLs pass through unchanged (Phase 3 PR3): authors
210    // use `![[file.jpg|attrs]]` for typed params; pipe in standard
211    // markdown URL is literal and intentionally 404s.
212    if raw.contains('|') {
213        *url = Url::Resolved(ResolvedUrl::new(raw, UrlKind::Asset));
214        return;
215    }
216
217    // External, anchor, and data URLs are not asset filesystem references.
218    // Pass them through before invoking the engine (which only understands
219    // filesystem paths) so we don't misinterpret `https://...` as a path.
220    if raw.starts_with('#')
221        || raw.starts_with("http://")
222        || raw.starts_with("https://")
223        || raw.starts_with("//")
224        || raw.starts_with("data:")
225        || raw.starts_with("mailto:")
226    {
227        *url = Url::Resolved(ResolvedUrl::new(raw, UrlKind::Asset));
228        return;
229    }
230
231    // Route ALL remaining refs (bare filenames, separator paths, absolute
232    // `/…` paths) through the unified asset engine. This replaces BOTH the
233    // old `is_bare_filename` branch (which called `resolve_reference`) and
234    // the old passthrough branch (which emitted the verbatim separator path,
235    // causing 404s for cross-directory relative paths).
236    //
237    // NOTE: provenance (SeparatorFallback / CaseMismatch / Ambiguous) is
238    // intentionally NOT logged here — moss-core is the pure, side-effect-free
239    // kernel (no `log`/I/O). The advisory author-facing warning is surfaced by
240    // the editor adapter (`editor::asset_resolver`) via the `@codemirror/lint`
241    // hover tooltip. The build's job here is only to emit a correct URL; a
242    // build-time console warning is a deferred follow-up (would require
243    // surfacing provenance to the src-tauri build layer).
244    let is_absolute = raw.starts_with('/');
245    match resolve_asset_ref(&raw, source_path, &GraphAssetIndex(graph)) {
246        AssetResolution::Resolved { root_rel, provenance: _ } => {
247            if is_absolute {
248                // R3: absolute paths stay absolute — re-emit with leading `/`
249                // so the browser resolves from the site root, not from the
250                // pretty-URL directory. Never run through relative_asset_path.
251                *url = Url::Resolved(ResolvedUrl::new(format!("/{root_rel}"), UrlKind::Asset));
252                return;
253            }
254            let rel = relative_asset_path(source_path, &root_rel);
255            outgoing.push(OutgoingLink {
256                target_path: root_rel,
257                display_text: alt.to_string(),
258                link_type: LinkType::Standard,
259            });
260            *url = Url::Resolved(ResolvedUrl::new(rel, UrlKind::Asset));
261        }
262        AssetResolution::Ambiguous { chosen, candidates: _ } => {
263            let rel = relative_asset_path(source_path, &chosen);
264            outgoing.push(OutgoingLink {
265                target_path: chosen,
266                display_text: alt.to_string(),
267                link_type: LinkType::Standard,
268            });
269            *url = Url::Resolved(ResolvedUrl::new(rel, UrlKind::Asset));
270        }
271        AssetResolution::NotFound => {
272            // Unchanged: pass through verbatim. Matches Stage 1 behavior —
273            // the build never hard-fails on unresolved asset refs.
274            *url = Url::Resolved(ResolvedUrl::new(raw, UrlKind::Asset));
275        }
276    }
277}
278
279/// Recursively descend into shortcode-bearing blocks and resolve any
280/// structural image `Url` fields (HeroShortcode::image, GalleryItem::src).
281/// Container shortcodes (Grid, Hero overlay) may nest other shortcodes —
282/// recurse through their inner blocks. Skips Inline::Image-bearing
283/// blocks because the `walk_inline_images_mut` pass above already
284/// handled them.
285fn resolve_shortcode_image_urls(
286    block: &mut Block,
287    graph: &ContentGraph,
288    source_path: &str,
289    outgoing: &mut Vec<OutgoingLink>,
290) {
291    match block {
292        Block::Shortcode(sc) => match sc {
293            Shortcode::Hero(args) => {
294                if let Some(image_url) = args.image.as_mut() {
295                    resolve_asset_url(image_url, "", graph, source_path, outgoing);
296                }
297                // Overlay may itself contain shortcodes (e.g. `::::buttons`
298                // inside `:::hero`); recurse so any nested Hero/Gallery
299                // structural image URLs resolve too.
300                for nested in &mut args.overlay {
301                    resolve_shortcode_image_urls(nested, graph, source_path, outgoing);
302                }
303            }
304            Shortcode::Gallery(args) => {
305                for item in &mut args.items {
306                    let alt = item.alt.clone();
307                    resolve_asset_url(&mut item.src, &alt, graph, source_path, outgoing);
308                }
309            }
310            Shortcode::Grid(args) => {
311                for cell in &mut args.cells {
312                    for nested in cell {
313                        resolve_shortcode_image_urls(nested, graph, source_path, outgoing);
314                    }
315                }
316            }
317            Shortcode::Subscribe(_) | Shortcode::Buttons(_) | Shortcode::Recent(_) | Shortcode::Apply(_) => {}
318        },
319        // Container blocks: recurse so nested shortcodes (Hero inside a
320        // Callout, Grid inside a list, etc.) are reached.
321        Block::Callout { children, .. } | Block::BlockQuote(children) => {
322            for nested in children {
323                resolve_shortcode_image_urls(nested, graph, source_path, outgoing);
324            }
325        }
326        Block::List { items, .. } => {
327            for item_blocks in items {
328                for nested in item_blocks {
329                    resolve_shortcode_image_urls(nested, graph, source_path, outgoing);
330                }
331            }
332        }
333        Block::LinkCard { children, .. } => {
334            for nested in children {
335                resolve_shortcode_image_urls(nested, graph, source_path, outgoing);
336            }
337        }
338        // Leaf / inline-only blocks: nothing structural to resolve here.
339        Block::Heading { .. }
340        | Block::Paragraph(_)
341        | Block::Table { .. }
342        | Block::Figure { .. }
343        | Block::CodeBlock { .. }
344        | Block::ThematicBreak
345        | Block::Other(_) => {}
346    }
347}
348
349// ---------------------------------------------------------------------------
350// Phase 2: link (Inline::Link::url + Block::LinkCard::url) URL resolution
351// ---------------------------------------------------------------------------
352
353/// Walk every link URL (Inline::Link::url, Block::LinkCard::url) and
354/// resolve markdown / asset targets via the content graph.
355///
356/// Mirrors `markdown_links::resolve_markdown_links`:
357/// - Only touches Link URLs (image URLs were handled in phase 1).
358/// - Resolvable targets (not external / not anchor / not protocol /
359///   not absolute-path / not already-prefixed) → graph lookup.
360/// - On `Found`: classify into Internal (markdown) / Asset (binary) and
361///   push OutgoingLink with target_path = resolved path.
362/// - On `Unresolved`: leave URL author-input; Stage 1 emitted a diagnostic
363///   here, but PR6 mirrors the byte-equivalence contract (no diagnostic in
364///   the OutgoingLink Vec since Diagnostic is a separate stream).
365/// - Anchor / mailto / tel / external pass through with the matching
366///   UrlKind so the renderer attaches the right attributes.
367fn resolve_link_urls(
368    doc: &mut Document,
369    graph: &ContentGraph,
370    source_path: &str,
371    outgoing: &mut Vec<OutgoingLink>,
372) {
373    walk_links_mut(doc, &mut |link_url, display_text, is_wikilink| {
374        let raw = match link_url {
375            Url::Unresolved(s) => s.clone(),
376            Url::Resolved(_) => return,
377        };
378
379        // Author-facing short-circuits: classify and stop.
380        if let Some(rest) = raw.strip_prefix("mailto:") {
381            *link_url = Url::Resolved(ResolvedUrl::new(format!("mailto:{rest}"), UrlKind::Mailto));
382            return;
383        }
384        if let Some(rest) = raw.strip_prefix("tel:") {
385            *link_url = Url::Resolved(ResolvedUrl::new(format!("tel:{rest}"), UrlKind::Tel));
386            return;
387        }
388        if raw.starts_with('#') {
389            // Same-page anchor. For wikilinks (`[[#Heading]]`) slug the
390            // fragment so it matches the rendered heading id; markdown
391            // anchors (`[x](#frag)`) stay raw (literal author-supplied id).
392            let href = if is_wikilink {
393                slug_wikilink_suffix(&raw)
394            } else {
395                raw
396            };
397            *link_url = Url::Resolved(ResolvedUrl::new(href, UrlKind::Anchor));
398            return;
399        }
400        if raw.starts_with("http://")
401            || raw.starts_with("https://")
402            || raw.starts_with("//")
403            || raw.starts_with("data:")
404        {
405            *link_url = Url::Resolved(ResolvedUrl::new(raw, UrlKind::External));
406            return;
407        }
408
409        // Stage 1 carry-over: URLs already prefixed with a resolver
410        // sentinel (`moss-resolved:`, `moss-newtab:`, `wikilink:`) carry
411        // Stage 1 / upstream state the visitor cannot decode in isolation
412        // — the final pretty URL depends on the host's `page_map`, which
413        // lives in src-tauri's pipeline context. Leave these as
414        // `Url::Unresolved` so the host's per-URL classifier
415        // (`classify_url_prod` in src-tauri's pipeline) can apply the
416        // page_map-aware decoding. This preserves the byte-equivalence
417        // contract (no OutgoingLink emitted for already-resolved targets
418        // — Stage 1 already counted them) while letting the host close
419        // the prefix-decoding loop.
420        if raw.starts_with("moss-resolved:")
421            || raw.starts_with("moss-newtab:")
422            || raw.starts_with("wikilink:")
423        {
424            // Leave Unresolved; host pass classifies.
425            return;
426        }
427
428        // Absolute filesystem path — treat as opaque. Mirrors
429        // markdown_links: `if url.starts_with('/') { return false; }`.
430        if raw.starts_with('/') {
431            *link_url = Url::Resolved(ResolvedUrl::new(raw, UrlKind::Internal));
432            return;
433        }
434
435        // Resolvable: split query/fragment, look up the path against the
436        // content graph, push OutgoingLink, emit the `moss-resolved:`
437        // sentinel for the host classifier. Mirrors
438        // markdown_links::rewrite_line byte-for-byte: same sentinel shape
439        // (`moss-resolved:<path>[<suffix>]`), same suffix concatenation.
440        //
441        // Phase 4 PR7a-stage1b (2026-05-28): moss-core resolves the
442        // filesystem path; src-tauri's `classify_url_prod` decodes the
443        // sentinel into the final pretty / external / asset URL using
444        // `page_map`, `external_url_map`, and the wikilink-class signal.
445        // The sentinel IS the moss-core ↔ src-tauri layering seam — the
446        // visitor must NOT collapse it to a final `Url::Resolved` or
447        // page_map decoding silently breaks.
448        let (path_part, suffix) = split_path_suffix(&raw);
449        match resolve_reference(path_part, graph, source_path) {
450            ResolvedRef::Found(resolved) => {
451                outgoing.push(OutgoingLink {
452                    target_path: resolved.clone(),
453                    display_text: display_text.to_string(),
454                    link_type: LinkType::Standard,
455                });
456                // For wikilinks, slug the `#fragment` so the emitted href
457                // matches the rendered heading id. The `?query` portion (if
458                // any) is preserved by `slug_wikilink_suffix`. Markdown links
459                // keep their suffix raw — a literal author-supplied URL.
460                let sentinel = match suffix {
461                    Some(s) => {
462                        let s = if is_wikilink {
463                            slug_wikilink_suffix(s)
464                        } else {
465                            s.to_string()
466                        };
467                        format!("moss-resolved:{}{}", resolved, s)
468                    }
469                    None => format!("moss-resolved:{}", resolved),
470                };
471                *link_url = Url::Unresolved(sentinel);
472            }
473            ResolvedRef::Unresolved => {
474                // Mirrors Stage 1: leave the URL as-is in the rewritten
475                // source — no `moss-resolved:` prefix, no diagnostic in
476                // the OutgoingLink Vec. Mark Internal so the renderer's
477                // `Url::Resolved` invariant holds.
478                *link_url = Url::Resolved(ResolvedUrl::new(raw, UrlKind::Internal));
479            }
480        }
481    });
482}
483
484/// Split a URL into (path, suffix) where `suffix` is `?query` and/or
485/// `#fragment` in source order. Suffix is opaque — round-trip parity with
486/// `crate::build::markdown::pipeline::classify_url_prod` (the src-tauri
487/// decoder) is the contract; this function must not reorder, normalize,
488/// or escape the suffix bytes.
489///
490/// The parallel src-tauri implementation lives at
491/// `src-tauri/src/build/markdown/pipeline.rs::split_path_suffix` and must
492/// share this exact shape.
493fn split_path_suffix(url: &str) -> (&str, Option<&str>) {
494    let q = url.find('?');
495    let h = url.find('#');
496    let cut = match (q, h) {
497        (Some(a), Some(b)) => Some(a.min(b)),
498        (Some(a), None) => Some(a),
499        (None, Some(b)) => Some(b),
500        (None, None) => None,
501    };
502    match cut {
503        #[allow(clippy::string_slice)]
504        Some(pos) => (&url[..pos], Some(&url[pos..])),
505        None => (url, None),
506    }
507}
508
509/// Slug the `#fragment` of a wikilink `suffix` so the emitted href matches
510/// the rendered heading id (`obsidian_heading_anchor`). Only the fragment is
511/// transformed: any leading `?query` is preserved verbatim. Block refs
512/// (`#^id`) keep their id raw (minus the caret), mirroring
513/// [`crate::resolve::wikilink_dispatch`]'s `build_anchor`. This must ONLY be
514/// called for wikilinks (`is_wikilink: true`); regular markdown links keep
515/// their fragment raw (it is a literal URL — `#L42`, hand-authored ids, etc.).
516///
517/// `suffix` is the value returned by [`split_path_suffix`] — it begins with
518/// `?` or `#`. Shapes handled:
519/// - `#frag` → `#<slug>`
520/// - `?query` → `?query` (no fragment, untouched)
521/// - `?query#frag` → `?query#<slug>` (query verbatim, fragment slugged)
522fn slug_wikilink_suffix(suffix: &str) -> String {
523    use crate::heading::anchor::obsidian_heading_anchor;
524
525    // Find the fragment (`#…`); everything before it is a `?query` we leave
526    // untouched. There is at most one `#` in a well-formed suffix.
527    match suffix.find('#') {
528        None => suffix.to_string(), // query-only (or empty) — nothing to slug
529        Some(h) => {
530            #[allow(clippy::string_slice)]
531            let (head, frag_with_hash) = (&suffix[..h], &suffix[h + 1..]);
532            let slugged = if let Some(block_id) = frag_with_hash.strip_prefix('^') {
533                // Block ref: keep the id raw (caret stripped). Matches build_anchor.
534                block_id.to_string()
535            } else {
536                obsidian_heading_anchor(frag_with_hash)
537            };
538            format!("{head}#{slugged}")
539        }
540    }
541}
542
543// ---------------------------------------------------------------------------
544// Phase 3: ensure no Url::Unresolved survives
545// ---------------------------------------------------------------------------
546
547/// Classify any URL left as `Url::Unresolved` after phases 1 + 2 into a
548/// best-effort `Url::Resolved`. The renderer's invariant requires no
549/// `Url::Unresolved` reaches HTML emission; this is the safety net that
550/// catches URLs the per-kind phases didn't visit (e.g., a future
551/// `Inline::Link` variant added before its phase-2 arm is wired).
552///
553/// Callers that follow [`resolve_urls`] with their own per-URL classifier
554/// (e.g., src-tauri's pipeline calling `classify_url_prod` for
555/// resolver-prefix decoding) should NOT call this — let the secondary
556/// classifier handle the remaining URLs. Callers that have no secondary
557/// pass should call this to maintain the render invariant.
558pub fn classify_remaining_urls(doc: &mut Document) {
559    visit_urls_mut(doc, |url| {
560        if let Url::Unresolved(raw) = url {
561            // Conservative fallback: treat as External (opens in new tab,
562            // no graph lookup). Unknown URLs are external by nature;
563            // guessing Internal would be wrong and new-tab is safe.
564            let kind = classify_unresolved_kind(raw);
565            let raw_owned = std::mem::take(raw);
566            *url = Url::Resolved(ResolvedUrl::new(raw_owned, kind));
567        }
568    });
569}
570
571/// Best-effort kind classification for an Unresolved URL that escaped the
572/// per-kind phases. Mirrors the prefix-based detection in
573/// `pipeline::classify_url_prod` for consistency.
574fn classify_unresolved_kind(raw: &str) -> UrlKind {
575    if raw.starts_with("mailto:") {
576        UrlKind::Mailto
577    } else if raw.starts_with("tel:") {
578        UrlKind::Tel
579    } else if raw.starts_with('#') {
580        UrlKind::Anchor
581    } else if raw.starts_with("http://")
582        || raw.starts_with("https://")
583        || raw.starts_with("//")
584        || raw.starts_with("data:")
585    {
586        UrlKind::External
587    } else {
588        UrlKind::Internal
589    }
590}
591
592// ---------------------------------------------------------------------------
593// Per-kind walkers (image-only / link-only)
594// ---------------------------------------------------------------------------
595
596/// Walk every `Inline::Image` in the document and invoke `f` with a `&mut`
597/// reference to the inline. Used by phase 1 — separates image src
598/// classification from link URL classification.
599fn walk_inline_images_mut<F>(doc: &mut Document, f: &mut F)
600where
601    F: FnMut(&mut Inline),
602{
603    for block in &mut doc.blocks {
604        walk_images_in_block(block, f);
605    }
606}
607
608fn walk_images_in_block<F>(block: &mut Block, f: &mut F)
609where
610    F: FnMut(&mut Inline),
611{
612    match block {
613        Block::Heading { children, .. } | Block::Paragraph(children) => {
614            for inline in children {
615                walk_images_in_inline(inline, f);
616            }
617        }
618        Block::Callout { children, .. } | Block::BlockQuote(children) => {
619            for nested in children {
620                walk_images_in_block(nested, f);
621            }
622        }
623        Block::List { items, .. } => {
624            for item_blocks in items {
625                for nested in item_blocks {
626                    walk_images_in_block(nested, f);
627                }
628            }
629        }
630        Block::Table { header, rows, .. } => {
631            for cell in header {
632                for inline in cell {
633                    walk_images_in_inline(inline, f);
634                }
635            }
636            for row in rows {
637                for cell in row {
638                    for inline in cell {
639                        walk_images_in_inline(inline, f);
640                    }
641                }
642            }
643        }
644        Block::Shortcode(sc) => {
645            walk_images_in_shortcode(sc, f);
646        }
647        Block::Figure { image, caption, .. } => {
648            walk_images_in_inline(image, f);
649            if let Some(cap) = caption {
650                for inline in cap {
651                    walk_images_in_inline(inline, f);
652                }
653            }
654        }
655        Block::LinkCard { children, .. } => {
656            for nested in children {
657                walk_images_in_block(nested, f);
658            }
659        }
660        Block::CodeBlock { .. } | Block::ThematicBreak | Block::Other(_) => {}
661    }
662}
663
664fn walk_images_in_shortcode<F>(sc: &mut Shortcode, f: &mut F)
665where
666    F: FnMut(&mut Inline),
667{
668    match sc {
669        Shortcode::Subscribe(_) | Shortcode::Buttons(_) | Shortcode::Recent(_) | Shortcode::Apply(_) => {}
670        Shortcode::Gallery(args) => {
671            // Gallery items carry their src as a structural `Url` on
672            // GalleryItem, not as an `Inline::Image`. The Inline-image
673            // walker has nothing to do here; the structural URL is
674            // resolved by `resolve_shortcode_image_urls` instead.
675            let _ = args;
676        }
677        Shortcode::Hero(args) => {
678            // Hero's image is a structural `Url` field, not an
679            // `Inline::Image` — resolved by `resolve_shortcode_image_urls`.
680            // The overlay blocks may still contain `Inline::Image`s
681            // (e.g. inside markdown paragraphs); descend so those reach
682            // the inline walker.
683            for block in &mut args.overlay {
684                walk_images_in_block(block, f);
685            }
686        }
687        Shortcode::Grid(args) => {
688            for cell_blocks in &mut args.cells {
689                for block in cell_blocks {
690                    walk_images_in_block(block, f);
691                }
692            }
693        }
694    }
695}
696
697fn walk_images_in_inline<F>(inline: &mut Inline, f: &mut F)
698where
699    F: FnMut(&mut Inline),
700{
701    match inline {
702        Inline::Image { .. } => {
703            f(inline);
704        }
705        Inline::Link { children, .. } => {
706            for nested in children {
707                walk_images_in_inline(nested, f);
708            }
709        }
710        Inline::Emphasis(children) | Inline::Strong(children) => {
711            for nested in children {
712                walk_images_in_inline(nested, f);
713            }
714        }
715        Inline::Text(_) | Inline::Code(_) | Inline::LineBreak | Inline::Other(_) => {}
716    }
717}
718
719/// Walk every link URL in the document (Inline::Link::url +
720/// Block::LinkCard::url) and invoke `f` with `(&mut Url, display_text)`.
721///
722/// The `display_text` is the link text (concatenated from the Link's
723/// children) — needed for the OutgoingLink::display_text contract.
724fn walk_links_mut<F>(doc: &mut Document, f: &mut F)
725where
726    F: FnMut(&mut Url, &str, bool),
727{
728    for block in &mut doc.blocks {
729        walk_links_in_block(block, f);
730    }
731}
732
733fn walk_links_in_block<F>(block: &mut Block, f: &mut F)
734where
735    F: FnMut(&mut Url, &str, bool),
736{
737    match block {
738        Block::Heading { children, .. } | Block::Paragraph(children) => {
739            for inline in children {
740                walk_links_in_inline(inline, f);
741            }
742        }
743        Block::Callout { children, .. } | Block::BlockQuote(children) => {
744            for nested in children {
745                walk_links_in_block(nested, f);
746            }
747        }
748        Block::List { items, .. } => {
749            for item_blocks in items {
750                for nested in item_blocks {
751                    walk_links_in_block(nested, f);
752                }
753            }
754        }
755        Block::Table { header, rows, .. } => {
756            for cell in header {
757                for inline in cell {
758                    walk_links_in_inline(inline, f);
759                }
760            }
761            for row in rows {
762                for cell in row {
763                    for inline in cell {
764                        walk_links_in_inline(inline, f);
765                    }
766                }
767            }
768        }
769        Block::Shortcode(sc) => {
770            walk_links_in_shortcode(sc, f);
771        }
772        Block::Figure { caption, .. } => {
773            if let Some(cap) = caption {
774                for inline in cap {
775                    walk_links_in_inline(inline, f);
776                }
777            }
778        }
779        Block::LinkCard { url, children } => {
780            // Compound-link card: the wrapping href is a link URL. Use
781            // the inner text content as display_text by recursively
782            // gathering it from the children (best-effort — empty string
783            // if no text is found).
784            // LinkCard wrapping href is never a wikilink (it's a compound
785            // markdown link card), so pass is_wikilink=false.
786            let display = gather_text_blocks(children);
787            f(url, &display, false);
788            for nested in children {
789                walk_links_in_block(nested, f);
790            }
791        }
792        Block::CodeBlock { .. } | Block::ThematicBreak | Block::Other(_) => {}
793    }
794}
795
796fn walk_links_in_shortcode<F>(sc: &mut Shortcode, f: &mut F)
797where
798    F: FnMut(&mut Url, &str, bool),
799{
800    match sc {
801        Shortcode::Subscribe(_) | Shortcode::Recent(_) | Shortcode::Apply(_) => {}
802        Shortcode::Buttons(args) => {
803            for item in &mut args.items {
804                // ButtonItem display text comes from item.text per the
805                // shortcode shape (crates/moss-core/src/ast/shortcode.rs).
806                // Button URLs are authored markdown targets, not wikilinks.
807                let text = item.text.clone();
808                f(&mut item.url, &text, false);
809            }
810        }
811        Shortcode::Gallery(_) => {
812            // Gallery items use src URLs (image-kind), not link URLs.
813            // No link-walk action.
814        }
815        Shortcode::Hero(args) => {
816            for block in &mut args.overlay {
817                walk_links_in_block(block, f);
818            }
819        }
820        Shortcode::Grid(args) => {
821            for cell_blocks in &mut args.cells {
822                for block in cell_blocks {
823                    walk_links_in_block(block, f);
824                }
825            }
826        }
827    }
828}
829
830fn walk_links_in_inline<F>(inline: &mut Inline, f: &mut F)
831where
832    F: FnMut(&mut Url, &str, bool),
833{
834    match inline {
835        Inline::Link {
836            url,
837            children,
838            is_wikilink,
839            ..
840        } => {
841            // display_text = concatenated plain text of the children.
842            // Matches markdown_links::rewrite_line, which uses the raw
843            // text between `[` and `]` (no rendering, just the literal).
844            let display = gather_text_inlines(children);
845            f(url, &display, *is_wikilink);
846            // Descend so nested Links (rare in CommonMark but possible
847            // via parser quirks) get visited too.
848            for nested in children {
849                walk_links_in_inline(nested, f);
850            }
851        }
852        Inline::Image { .. } => {
853            // Image src is a Url but it's image-kind — handled by phase 1.
854        }
855        Inline::Emphasis(children) | Inline::Strong(children) => {
856            for nested in children {
857                walk_links_in_inline(nested, f);
858            }
859        }
860        Inline::Text(_) | Inline::Code(_) | Inline::LineBreak | Inline::Other(_) => {}
861    }
862}
863
864/// Concatenate the plain-text content of a list of inlines, mirroring
865/// pulldown-cmark's behavior of treating link text as a verbatim string.
866/// Used to populate `OutgoingLink::display_text`.
867fn gather_text_inlines(inlines: &[Inline]) -> String {
868    let mut s = String::new();
869    for inline in inlines {
870        gather_text_inline(inline, &mut s);
871    }
872    s
873}
874
875fn gather_text_inline(inline: &Inline, out: &mut String) {
876    match inline {
877        Inline::Text(t) => out.push_str(t),
878        Inline::Code(c) => out.push_str(c),
879        Inline::Emphasis(children) | Inline::Strong(children) => {
880            for nested in children {
881                gather_text_inline(nested, out);
882            }
883        }
884        Inline::Link { children, .. } => {
885            for nested in children {
886                gather_text_inline(nested, out);
887            }
888        }
889        Inline::Image { alt, .. } => out.push_str(alt),
890        Inline::LineBreak => out.push('\n'),
891        Inline::Other(_) => {}
892    }
893}
894
895/// Concatenate the plain-text content of a list of blocks. Used by
896/// Block::LinkCard arm to populate the OutgoingLink::display_text.
897fn gather_text_blocks(blocks: &[Block]) -> String {
898    let mut s = String::new();
899    for block in blocks {
900        gather_text_block(block, &mut s);
901    }
902    s
903}
904
905fn gather_text_block(block: &Block, out: &mut String) {
906    match block {
907        Block::Heading { children, .. } | Block::Paragraph(children) => {
908            for inline in children {
909                gather_text_inline(inline, out);
910            }
911        }
912        Block::Figure { image, caption, .. } => {
913            if let Inline::Image { alt, .. } = image {
914                out.push_str(alt);
915            }
916            if let Some(cap) = caption {
917                for inline in cap {
918                    gather_text_inline(inline, out);
919                }
920            }
921        }
922        _ => {}
923    }
924}
925
926// ---------------------------------------------------------------------------
927// Tests
928// ---------------------------------------------------------------------------
929
930#[cfg(test)]
931mod tests {
932    use super::*;
933    use crate::ast::parser::parse;
934    use crate::content_graph::ContentGraphBuilder;
935
936    fn graph_with(paths: &[&str]) -> crate::content_graph::ContentGraph {
937        let mut b = ContentGraphBuilder::new();
938        for p in paths {
939            b.add_file(p, p);
940        }
941        b.build()
942    }
943
944    // -----------------------------------------------------------------
945    // Single-shot resolve_urls behavior
946    // -----------------------------------------------------------------
947
948    #[test]
949    fn markdown_link_fragment_preserved_raw_not_slugged() {
950        // Unit test of `split_path_suffix` PURITY: it splits path from
951        // suffix but never slugs — the returned suffix is byte-identical to
952        // the source. (Slugging, when it happens, is layered on top by
953        // `slug_wikilink_suffix`, exercised separately.)
954        //
955        // Design split (corrected): a MARKDOWN link (`[t](page#Heading)`) is
956        // a literal URL — its `#fragment` stays RAW by design, so authored
957        // `#L42` / hand-authored ids / external anchors survive untouched.
958        // A WIKILINK (`[[page#Heading]]`) is NOT a literal URL: its fragment
959        // IS slugged to match the rendered heading id — `resolve_link_urls`
960        // routes wikilinks through `slug_wikilink_suffix` (see the
961        // end-to-end guard `markdown_link_fragment_stays_raw_not_slugged`
962        // and the `wikilink_*_fragment_is_slugged` tests below). The earlier
963        // claim that "authoring correctness comes from editor autocomplete"
964        // was the flawed premise behind the link-path bug; wikilink slugging
965        // now happens in resolve_urls itself.
966        let (path, suffix) = split_path_suffix("page#My Heading");
967        assert_eq!(path, "page");
968        assert_eq!(suffix, Some("#My Heading")); // raw, spaces + case intact
969    }
970
971    #[test]
972    fn resolves_standard_markdown_link_to_internal() {
973        let mut doc = parse("[文字](文字.md)");
974        let graph = graph_with(&["index.md", "文字/文字.md"]);
975        let outgoing = resolve_urls(&mut doc, &graph, "index.md");
976
977        assert_eq!(outgoing.len(), 1);
978        assert_eq!(outgoing[0].target_path, "文字/文字.md");
979        assert_eq!(outgoing[0].display_text, "文字");
980        assert_eq!(outgoing[0].link_type, LinkType::Standard);
981
982        // Phase 4 PR7a-stage1b (2026-05-28): the visitor emits a
983        // `moss-resolved:` sentinel for internal links (Url::Unresolved)
984        // so src-tauri's host classifier can decode it via page_map.
985        // The renderer doesn't see this state — the host's
986        // `classify_url_prod` pass replaces Unresolved before render.
987        match &doc.blocks[0] {
988            Block::Paragraph(children) => match &children[0] {
989                Inline::Link { url, .. } => {
990                    assert!(url.is_unresolved(), "expected sentinel, got: {url:?}");
991                    match url {
992                        Url::Unresolved(s) => assert_eq!(s, "moss-resolved:文字/文字.md"),
993                        Url::Resolved(_) => unreachable!(),
994                    }
995                }
996                _ => panic!("expected Link"),
997            },
998            _ => panic!("expected Paragraph"),
999        }
1000    }
1001
1002    #[test]
1003    fn passes_through_external_link() {
1004        let mut doc = parse("[ex](https://example.com)");
1005        let graph = graph_with(&["index.md"]);
1006        let outgoing = resolve_urls(&mut doc, &graph, "index.md");
1007
1008        assert!(outgoing.is_empty());
1009        match &doc.blocks[0] {
1010            Block::Paragraph(children) => match &children[0] {
1011                Inline::Link { url, .. } => {
1012                    let Url::Resolved(r) = url else {
1013                        panic!("expected Resolved, got {url:?}")
1014                    };
1015                    assert_eq!(r.kind, UrlKind::External);
1016                    assert_eq!(r.href, "https://example.com");
1017                }
1018                _ => panic!("expected Link"),
1019            },
1020            _ => panic!("expected Paragraph"),
1021        }
1022    }
1023
1024    #[test]
1025    fn classifies_anchor_link() {
1026        let mut doc = parse("[top](#top)");
1027        let graph = graph_with(&["index.md"]);
1028        let outgoing = resolve_urls(&mut doc, &graph, "index.md");
1029        assert!(outgoing.is_empty());
1030        match &doc.blocks[0] {
1031            Block::Paragraph(children) => match &children[0] {
1032                Inline::Link { url, .. } => {
1033                    let Url::Resolved(r) = url else {
1034                        panic!("expected Resolved, got {url:?}")
1035                    };
1036                    assert_eq!(r.kind, UrlKind::Anchor);
1037                    assert_eq!(r.href, "#top");
1038                }
1039                _ => panic!("expected Link"),
1040            },
1041            _ => panic!("expected Paragraph"),
1042        }
1043    }
1044
1045    #[test]
1046    fn classifies_mailto() {
1047        let mut doc = parse("[Mail](mailto:test@example.com)");
1048        let graph = graph_with(&["index.md"]);
1049        let _ = resolve_urls(&mut doc, &graph, "index.md");
1050        match &doc.blocks[0] {
1051            Block::Paragraph(children) => match &children[0] {
1052                Inline::Link { url, .. } => {
1053                    let Url::Resolved(r) = url else {
1054                        panic!("expected Resolved, got {url:?}")
1055                    };
1056                    assert_eq!(r.kind, UrlKind::Mailto);
1057                    assert_eq!(r.href, "mailto:test@example.com");
1058                }
1059                _ => panic!("expected Link"),
1060            },
1061            _ => panic!("expected Paragraph"),
1062        }
1063    }
1064
1065    #[test]
1066    fn resolves_bare_filename_image_against_graph() {
1067        let mut doc = parse("![My Photo](photo.jpg)");
1068        let mut b = ContentGraphBuilder::new();
1069        b.add_file("assets/photo.jpg", "photo");
1070        let graph = b.build();
1071        let outgoing = resolve_urls(&mut doc, &graph, "articles/post.md");
1072
1073        assert_eq!(outgoing.len(), 1);
1074        assert_eq!(outgoing[0].target_path, "assets/photo.jpg");
1075        assert_eq!(outgoing[0].display_text, "My Photo");
1076        assert_eq!(outgoing[0].link_type, LinkType::Standard);
1077
1078        // Image src rewritten to relative asset path.
1079        match &doc.blocks[0] {
1080            Block::Paragraph(children) => match &children[0] {
1081                Inline::Image { src, .. } => {
1082                    let Url::Resolved(r) = src else {
1083                        panic!("expected Resolved, got {src:?}")
1084                    };
1085                    assert_eq!(r.href, "../assets/photo.jpg");
1086                    assert_eq!(r.kind, UrlKind::Asset);
1087                }
1088                Inline::Link {
1089                    children: link_kids,
1090                    ..
1091                } => {
1092                    // pulldown-cmark may wrap an image-only paragraph in a
1093                    // figure or other structure depending on detection;
1094                    // accept either the direct image or one-level
1095                    // deeper.
1096                    if let Some(Inline::Image { src, .. }) = link_kids.first() {
1097                        let Url::Resolved(r) = src else {
1098                            panic!("expected Resolved, got {src:?}")
1099                        };
1100                        assert_eq!(r.href, "../assets/photo.jpg");
1101                    }
1102                }
1103                _ => panic!("expected Image, got {children:?}"),
1104            },
1105            Block::Figure { image, .. } => {
1106                // PR3's Block::Figure: image-only paragraph may parse as
1107                // Figure directly.
1108                if let Inline::Image { src, .. } = image {
1109                    let Url::Resolved(r) = src else {
1110                        panic!("expected Resolved, got {src:?}")
1111                    };
1112                    assert_eq!(r.href, "../assets/photo.jpg");
1113                }
1114            }
1115            _ => panic!("expected Paragraph or Figure, got {:?}", doc.blocks[0]),
1116        }
1117    }
1118
1119    #[test]
1120    fn unresolved_bare_filename_passes_through() {
1121        let mut doc = parse("![](nonexistent.jpg)");
1122        let graph = graph_with(&["index.md"]);
1123        let outgoing = resolve_urls(&mut doc, &graph, "articles/post.md");
1124
1125        assert!(outgoing.is_empty());
1126        // URL stays as raw "nonexistent.jpg" but becomes Resolved (Asset
1127        // kind) so the renderer's invariant holds.
1128        let mut found_image = false;
1129        for block in &doc.blocks {
1130            if let Block::Paragraph(children) = block {
1131                for inline in children {
1132                    if let Inline::Image { src, .. } = inline {
1133                        let Url::Resolved(r) = src else {
1134                            panic!("expected Resolved, got {src:?}")
1135                        };
1136                        assert_eq!(r.href, "nonexistent.jpg");
1137                        assert_eq!(r.kind, UrlKind::Asset);
1138                        found_image = true;
1139                    }
1140                }
1141            }
1142            if let Block::Figure { image, .. } = block {
1143                if let Inline::Image { src, .. } = image {
1144                    let Url::Resolved(r) = src else {
1145                        panic!("expected Resolved, got {src:?}")
1146                    };
1147                    assert_eq!(r.href, "nonexistent.jpg");
1148                    found_image = true;
1149                }
1150            }
1151        }
1152        assert!(found_image, "expected an Inline::Image in the parsed doc");
1153    }
1154
1155    #[test]
1156    fn does_not_resolve_url_inside_code_block() {
1157        // visit_urls_mut never descends into Block::CodeBlock, so the
1158        // visitor never sees URLs in code fences. This matches Stage 1's
1159        // fence-aware behavior structurally.
1160        let mut doc = parse("```\n[link](inside.md)\n```\n");
1161        let graph = graph_with(&["index.md", "inside.md"]);
1162        let outgoing = resolve_urls(&mut doc, &graph, "index.md");
1163        assert!(
1164            outgoing.is_empty(),
1165            "code block content must not produce OutgoingLink"
1166        );
1167    }
1168
1169    #[test]
1170    fn fragment_preserved_on_internal_link() {
1171        let mut doc = parse("[x](文字/文字.md#sec)");
1172        let graph = graph_with(&["index.md", "文字/文字.md"]);
1173        let outgoing = resolve_urls(&mut doc, &graph, "index.md");
1174
1175        assert_eq!(outgoing.len(), 1);
1176        assert_eq!(outgoing[0].target_path, "文字/文字.md");
1177
1178        // Sentinel emit: suffix concatenated verbatim after the resolved path.
1179        match &doc.blocks[0] {
1180            Block::Paragraph(children) => match &children[0] {
1181                Inline::Link { url, .. } => match url {
1182                    Url::Unresolved(s) => assert_eq!(s, "moss-resolved:文字/文字.md#sec"),
1183                    Url::Resolved(r) => panic!("expected sentinel, got Resolved({r:?})"),
1184                },
1185                _ => panic!("expected Link"),
1186            },
1187            _ => panic!("expected Paragraph"),
1188        }
1189    }
1190
1191    #[test]
1192    fn query_string_preserved_on_internal_link() {
1193        let mut b = ContentGraphBuilder::new();
1194        b.add_file("index.md", "x");
1195        b.add_file("assets/scale-compare.html", "h");
1196        let graph = b.build();
1197
1198        let mut doc = parse("[demo](scale-compare.html?a=major_pent&r=major_pent%3AD)");
1199        let outgoing = resolve_urls(&mut doc, &graph, "index.md");
1200
1201        assert_eq!(outgoing.len(), 1);
1202        assert_eq!(outgoing[0].target_path, "assets/scale-compare.html");
1203
1204        // Sentinel emit: suffix concatenated verbatim after the resolved path.
1205        match &doc.blocks[0] {
1206            Block::Paragraph(children) => match &children[0] {
1207                Inline::Link { url, .. } => match url {
1208                    Url::Unresolved(s) => assert_eq!(
1209                        s,
1210                        "moss-resolved:assets/scale-compare.html?a=major_pent&r=major_pent%3AD"
1211                    ),
1212                    Url::Resolved(r) => panic!("expected sentinel, got Resolved({r:?})"),
1213                },
1214                _ => panic!("expected Link"),
1215            },
1216            _ => panic!("expected Paragraph"),
1217        }
1218    }
1219
1220    // -----------------------------------------------------------------
1221    // OutgoingLink + sentinel-shape coverage
1222    // -----------------------------------------------------------------
1223    //
1224    // Phase 4 PR7a-stage1b (2026-05-28): the Stage 1 pass
1225    // `markdown_links::resolve_markdown_links` was deleted in this PR
1226    // alongside the matching `byte_equivalence_*` baseline helpers. The
1227    // visitor now emits the same `moss-resolved:<path>` sentinel Stage 1
1228    // emitted, byte-for-byte — proven by the per-test sentinel
1229    // assertions below. The companion Stage 1 pass
1230    // `markdown_refs::resolve_markdown_refs` was already deleted in the
1231    // prior PR; its parity is covered by
1232    // `resolves_bare_filename_image_against_graph` above.
1233
1234    #[test]
1235    fn standard_markdown_link_emits_sentinel() {
1236        let source = "index.md";
1237        let content = "[文字](文字.md)";
1238        let graph = graph_with(&["index.md", "文字/文字.md"]);
1239
1240        let mut doc = parse(content);
1241        let visitor = resolve_urls(&mut doc, &graph, source);
1242
1243        assert_eq!(visitor.len(), 1);
1244        assert_eq!(visitor[0].target_path, "文字/文字.md");
1245        assert_eq!(visitor[0].display_text, "文字");
1246        assert_eq!(visitor[0].link_type, LinkType::Standard);
1247        // The sentinel shape is what `classify_url_prod` in src-tauri
1248        // expects to decode via `page_map` / `external_url_map`.
1249        match &doc.blocks[0] {
1250            Block::Paragraph(children) => match &children[0] {
1251                Inline::Link {
1252                    url: Url::Unresolved(s),
1253                    ..
1254                } => {
1255                    assert_eq!(s, "moss-resolved:文字/文字.md");
1256                }
1257                _ => panic!("expected Url::Unresolved sentinel, got {:?}", children[0]),
1258            },
1259            _ => panic!("expected Paragraph"),
1260        }
1261    }
1262
1263    #[test]
1264    fn multiple_links_one_line_emit_sentinels() {
1265        let source = "index.md";
1266        let content = "[a](foo.md) and [b](bar.md)";
1267        let graph = graph_with(&["index.md", "foo.md", "bar.md"]);
1268
1269        let mut doc = parse(content);
1270        let visitor = resolve_urls(&mut doc, &graph, source);
1271
1272        assert_eq!(visitor.len(), 2);
1273        assert_eq!(visitor[0].target_path, "foo.md");
1274        assert_eq!(visitor[1].target_path, "bar.md");
1275    }
1276
1277    #[test]
1278    fn external_links_no_outgoing() {
1279        let source = "index.md";
1280        let content = "[ext](https://example.com) [anchor](#top) [mail](mailto:a@b)";
1281        let graph = graph_with(&["index.md"]);
1282
1283        let mut doc = parse(content);
1284        let visitor = resolve_urls(&mut doc, &graph, source);
1285
1286        assert!(visitor.is_empty());
1287    }
1288
1289    #[test]
1290    fn unresolved_link_no_outgoing() {
1291        let source = "index.md";
1292        let content = "[missing](missing.md)";
1293        let graph = graph_with(&["index.md"]);
1294
1295        let mut doc = parse(content);
1296        let visitor = resolve_urls(&mut doc, &graph, source);
1297
1298        assert!(visitor.is_empty());
1299        // The unresolved URL stays as-is (no sentinel) but is marked
1300        // Url::Resolved so the renderer's invariant holds.
1301        match &doc.blocks[0] {
1302            Block::Paragraph(children) => match &children[0] {
1303                Inline::Link { url, .. } => {
1304                    let Url::Resolved(r) = url else {
1305                        panic!("expected Resolved, got {url:?}")
1306                    };
1307                    assert_eq!(r.href, "missing.md");
1308                    assert_eq!(r.kind, UrlKind::Internal);
1309                }
1310                _ => panic!("expected Link"),
1311            },
1312            _ => panic!("expected Paragraph"),
1313        }
1314    }
1315
1316    #[test]
1317    fn code_block_urls_not_visited() {
1318        let source = "index.md";
1319        let content =
1320            "Before\n\n```\n[link](inside.md)\n![](photo.jpg)\n```\n\nAfter [link](inside.md).";
1321        let mut b = ContentGraphBuilder::new();
1322        b.add_file("index.md", "x");
1323        b.add_file("inside.md", "i");
1324        b.add_file("assets/photo.jpg", "p");
1325        let graph = b.build();
1326
1327        let mut doc = parse(content);
1328        let visitor = resolve_urls(&mut doc, &graph, source);
1329
1330        // Only the trailing `[link](inside.md)` (outside the fence) emits
1331        // an OutgoingLink. URLs inside `Block::CodeBlock` are not visited.
1332        assert_eq!(visitor.len(), 1);
1333        assert_eq!(visitor[0].target_path, "inside.md");
1334    }
1335
1336    #[test]
1337    fn query_and_fragment_sentinel_shape() {
1338        let source = "index.md";
1339        let content = "[d](app.html?x=1#sec)";
1340        let mut b = ContentGraphBuilder::new();
1341        b.add_file("index.md", "x");
1342        b.add_file("assets/app.html", "h");
1343        let graph = b.build();
1344
1345        let mut doc = parse(content);
1346        let visitor = resolve_urls(&mut doc, &graph, source);
1347
1348        assert_eq!(visitor.len(), 1);
1349        assert_eq!(visitor[0].target_path, "assets/app.html");
1350        match &doc.blocks[0] {
1351            Block::Paragraph(children) => match &children[0] {
1352                Inline::Link {
1353                    url: Url::Unresolved(s),
1354                    ..
1355                } => {
1356                    assert_eq!(s, "moss-resolved:assets/app.html?x=1#sec");
1357                }
1358                _ => panic!("expected sentinel, got {:?}", children[0]),
1359            },
1360            _ => panic!("expected Paragraph"),
1361        }
1362    }
1363
1364    #[test]
1365    fn link_wrapping_image_target_path() {
1366        // Shape produced by `[![[image.png]]](target.html?q)` after the
1367        // wikilinks pass rewrites the embed to `![alt](path)`.
1368        // Pre-PR7a-stage1b this test compared to a Stage 1 baseline that
1369        // used the raw markdown source between `[` and `]` for
1370        // display_text; the visitor uses parsed plain text (alt text).
1371        // That divergence was non-breaking (display_text has no
1372        // production consumer). With Stage 1 deleted we assert on the
1373        // visitor's behavior directly: load-bearing fields (target_path,
1374        // link_type) plus the documented display_text.
1375        //
1376        // Task 6 (asset-engine routing, 2026-06-03): the engine now also
1377        // resolves separator-path image references through the graph and
1378        // emits an OutgoingLink for the discovered dependency edge. So
1379        // this test now expects TWO OutgoingLink entries:
1380        //   [0] — image: assets/scale-compare.png (phase 1, image resolver)
1381        //   [1] — link: assets/scale-compare.html (phase 2, link resolver)
1382        // Previously [0] was absent because separator-path images were
1383        // passed through verbatim (the 404 bug). The href for the image
1384        // is unchanged ("assets/scale-compare.png" from index.md root).
1385        let source = "index.md";
1386        let content = "[![scale-compare](assets/scale-compare.png)](scale-compare.html?a=major_pent&r=major_pent%3AD)";
1387        let mut b = ContentGraphBuilder::new();
1388        b.add_file("index.md", "x");
1389        b.add_file("assets/scale-compare.html", "h");
1390        b.add_file("assets/scale-compare.png", "p");
1391        let graph = b.build();
1392
1393        let mut doc = parse(content);
1394        let visitor = resolve_urls(&mut doc, &graph, source);
1395
1396        // Phase 1 emits the image dependency edge; phase 2 emits the link.
1397        assert_eq!(visitor.len(), 2, "expected image + link OutgoingLinks, got: {visitor:?}");
1398        // Find the link entry by target (order: phase 1 image first, then phase 2 link).
1399        let link_entry = visitor
1400            .iter()
1401            .find(|o| o.target_path == "assets/scale-compare.html")
1402            .expect("OutgoingLink for scale-compare.html not found");
1403        assert_eq!(link_entry.link_type, LinkType::Standard);
1404        assert_eq!(link_entry.display_text, "scale-compare");
1405        // Image dependency edge also present.
1406        assert!(
1407            visitor.iter().any(|o| o.target_path == "assets/scale-compare.png"),
1408            "OutgoingLink for scale-compare.png not found"
1409        );
1410    }
1411
1412    // -----------------------------------------------------------------
1413    // Edge cases
1414    // -----------------------------------------------------------------
1415
1416    #[test]
1417    fn pipe_bearing_image_url_unchanged() {
1418        let mut doc = parse("![alt](photo.jpg|contain)");
1419        let mut b = ContentGraphBuilder::new();
1420        b.add_file("assets/photo.jpg", "p");
1421        let graph = b.build();
1422        let outgoing = resolve_urls(&mut doc, &graph, "articles/post.md");
1423
1424        // Phase 3 PR3 contract: pipe-bearing URLs pass through verbatim,
1425        // no OutgoingLink emitted.
1426        assert!(outgoing.is_empty());
1427    }
1428
1429    #[test]
1430    fn idempotent_on_already_resolved_url() {
1431        // If the document already carries Resolved URLs (e.g. a previous
1432        // pass ran), the visitor should not double-process. Single
1433        // invocation should produce the SAME state.
1434        let mut doc = parse("[文字](文字.md)");
1435        let graph = graph_with(&["index.md", "文字/文字.md"]);
1436        let outgoing1 = resolve_urls(&mut doc, &graph, "index.md");
1437
1438        let outgoing2 = resolve_urls(&mut doc, &graph, "index.md");
1439        // After the first pass everything is Resolved; the second pass
1440        // produces no new OutgoingLink entries.
1441        assert!(
1442            outgoing2.is_empty(),
1443            "idempotency violated: {:?}",
1444            outgoing2
1445        );
1446        assert_eq!(outgoing1.len(), 1);
1447    }
1448
1449    #[test]
1450    fn absolute_path_passes_through() {
1451        let mut doc = parse("[abs](/about.html)");
1452        let graph = graph_with(&["index.md", "about.html"]);
1453        let outgoing = resolve_urls(&mut doc, &graph, "index.md");
1454        // Absolute paths bypass the graph (mirrors markdown_links).
1455        assert!(outgoing.is_empty());
1456        match &doc.blocks[0] {
1457            Block::Paragraph(children) => match &children[0] {
1458                Inline::Link { url, .. } => {
1459                    let Url::Resolved(r) = url else {
1460                        panic!("expected Resolved, got {url:?}")
1461                    };
1462                    assert_eq!(r.href, "/about.html");
1463                }
1464                _ => panic!("expected Link"),
1465            },
1466            _ => panic!("expected Paragraph"),
1467        }
1468    }
1469
1470    // -----------------------------------------------------------------
1471    // Hero / Gallery shortcode image resolution
1472    // -----------------------------------------------------------------
1473    //
1474    // Regression coverage for the chps-site home hero regression
1475    // (2026-05-29): `:::hero` with a body-image fallback `![[hero.jpg]]`
1476    // (or `image=hero.jpg` attribute) stores the wikilink target as a
1477    // `Url::Unresolved("hero.jpg")` on `HeroShortcode::image`. Before the
1478    // fix, `walk_images_in_shortcode`'s Hero arm explicitly skipped that
1479    // field, deferring to `classify_remaining_urls` — but the fallback
1480    // classifier only assigns a `UrlKind`, never consulting the
1481    // ContentGraph. Result: the renderer emitted `<img src="hero.jpg">`
1482    // instead of the depth-correct `assets/hero.jpg`. The fix routes
1483    // `args.image` (Hero) and `item.src` (Gallery) through the same
1484    // bare-filename graph lookup that `Inline::Image` already uses.
1485
1486    fn extract_hero_image_href(doc: &Document) -> Option<String> {
1487        for block in &doc.blocks {
1488            if let Block::Shortcode(Shortcode::Hero(args)) = block {
1489                if let Some(Url::Resolved(r)) = &args.image {
1490                    return Some(r.href.clone());
1491                }
1492                return None;
1493            }
1494        }
1495        None
1496    }
1497
1498    #[test]
1499    fn hero_body_wikilink_resolves_against_graph_at_depth_0() {
1500        // chps-site home page shape: `:::hero` with `![[hero.jpg]]`
1501        // wikilink as the body-image fallback. The asset lives at
1502        // `assets/hero.jpg` on disk. From depth-0 (home), the emitted
1503        // href must be `assets/hero.jpg`, not the bare wikilink target.
1504        let mut doc = parse(":::hero\n![[hero.jpg]]\n# Welcome\n:::\n");
1505        let mut b = ContentGraphBuilder::new();
1506        b.add_file("index.md", "home");
1507        b.add_file("assets/hero.jpg", "hero");
1508        let graph = b.build();
1509        let outgoing = resolve_urls(&mut doc, &graph, "index.md");
1510
1511        let href = extract_hero_image_href(&doc).expect("hero image must be Resolved");
1512        assert_eq!(
1513            href, "assets/hero.jpg",
1514            "hero body-wikilink must resolve to depth-0 asset path, got {href:?}"
1515        );
1516        // OutgoingLink registers the discovered dependency edge.
1517        assert!(
1518            outgoing.iter().any(|o| o.target_path == "assets/hero.jpg"),
1519            "expected OutgoingLink to assets/hero.jpg, got {outgoing:?}"
1520        );
1521    }
1522
1523    #[test]
1524    fn hero_body_wikilink_resolves_with_relative_prefix_at_depth_1() {
1525        // Source one directory deep (e.g. `articles/post.md`) must emit
1526        // a `../assets/hero.jpg` href so it resolves from the deployed
1527        // pretty-URL `/articles/post/index.html`. Mirrors the
1528        // `resolves_bare_filename_image_against_graph` test's relative-
1529        // path assertion for `Inline::Image`.
1530        let mut doc = parse(":::hero\n![[hero.jpg]]\n:::\n");
1531        let mut b = ContentGraphBuilder::new();
1532        b.add_file("articles/post.md", "post");
1533        b.add_file("assets/hero.jpg", "hero");
1534        let graph = b.build();
1535        let _ = resolve_urls(&mut doc, &graph, "articles/post.md");
1536
1537        let href = extract_hero_image_href(&doc).expect("hero image must be Resolved");
1538        assert_eq!(
1539            href, "../assets/hero.jpg",
1540            "hero body-wikilink at source-depth 1 must resolve with `../` prefix, got {href:?}"
1541        );
1542    }
1543
1544    #[test]
1545    fn hero_unresolved_wikilink_passes_through() {
1546        // If the wikilink target isn't in the graph, leave the URL as
1547        // the author wrote it (Resolved Asset kind, so the renderer
1548        // invariant holds). Mirrors `unresolved_bare_filename_passes_through`.
1549        let mut doc = parse(":::hero\n![[missing.jpg]]\n:::\n");
1550        let graph = graph_with(&["index.md"]);
1551        let _ = resolve_urls(&mut doc, &graph, "index.md");
1552
1553        let href = extract_hero_image_href(&doc).expect("hero image must be Resolved");
1554        assert_eq!(href, "missing.jpg");
1555    }
1556
1557    // -----------------------------------------------------------------
1558    // Wikilink #fragment slugging (keystone bug fix)
1559    //
1560    // Authored `[[Page#Heading]]` wikilinks must resolve to a SLUGGED
1561    // fragment so the emitted href matches the rendered heading id
1562    // (`<h2 id="getting-started">`). Regular markdown links `[x](page#frag)`
1563    // stay RAW (a markdown link is a literal URL). The discriminator is
1564    // `Inline::Link::is_wikilink`, which `parse()` sets for `[[…]]` syntax
1565    // (ENABLE_WIKILINKS). Block refs (`#^id`) keep the id raw minus the
1566    // caret, mirroring `wikilink_dispatch::build_anchor`.
1567    // -----------------------------------------------------------------
1568
1569    /// Pull the resolved Url string out of the first Link inline in the
1570    /// first paragraph. Works for both `Url::Unresolved` (sentinel) and
1571    /// `Url::Resolved` (anchor / external) variants.
1572    fn first_link_href(doc: &Document) -> String {
1573        match &doc.blocks[0] {
1574            Block::Paragraph(children) => {
1575                let link = children
1576                    .iter()
1577                    .find(|i| matches!(i, Inline::Link { .. }))
1578                    .expect("expected an Inline::Link");
1579                match link {
1580                    Inline::Link { url, .. } => match url {
1581                        Url::Unresolved(s) => s.clone(),
1582                        Url::Resolved(r) => r.href.clone(),
1583                    },
1584                    _ => unreachable!(),
1585                }
1586            }
1587            other => panic!("expected Paragraph, got {other:?}"),
1588        }
1589    }
1590
1591    #[test]
1592    fn wikilink_cross_page_fragment_is_slugged() {
1593        // `[[other#Getting Started]]` → sentinel `moss-resolved:other.md#getting-started`.
1594        let mut doc = parse("[[other#Getting Started]]");
1595        let graph = graph_with(&["index.md", "other.md"]);
1596        let _ = resolve_urls(&mut doc, &graph, "index.md");
1597        assert_eq!(first_link_href(&doc), "moss-resolved:other.md#getting-started");
1598    }
1599
1600    #[test]
1601    fn wikilink_same_page_fragment_is_slugged() {
1602        // Same-page `[[#Local Section]]` → bare anchor `#local-section`,
1603        // no `moss-resolved:` prefix (the path part is empty).
1604        let mut doc = parse("[[#Local Section]]");
1605        let graph = graph_with(&["index.md"]);
1606        let _ = resolve_urls(&mut doc, &graph, "index.md");
1607        assert_eq!(first_link_href(&doc), "#local-section");
1608    }
1609
1610    #[test]
1611    fn markdown_link_fragment_stays_raw_not_slugged() {
1612        // Regression guard for the design decision: a NON-wikilink markdown
1613        // link keeps its fragment RAW (case intact, no slugging). This MUST
1614        // still hold after the wikilink fix. (CommonMark forbids spaces in a
1615        // bare link destination, so we use a case-bearing fragment to make
1616        // the raw-vs-slug distinction observable: raw `#GettingStarted`
1617        // would slug to `#gettingstarted`.)
1618        let mut doc = parse("[x](other#GettingStarted)");
1619        let graph = graph_with(&["index.md", "other.md"]);
1620        let _ = resolve_urls(&mut doc, &graph, "index.md");
1621        assert_eq!(first_link_href(&doc), "moss-resolved:other.md#GettingStarted");
1622    }
1623
1624    #[test]
1625    fn wikilink_block_ref_keeps_id_raw() {
1626        // Block refs (`#^id`) strip the caret but keep the id RAW (no slug),
1627        // mirroring `wikilink_dispatch::build_anchor`. Use space + uppercase
1628        // so slugging would be observably different.
1629        let mut doc = parse("[[other#^Block Id]]");
1630        let graph = graph_with(&["index.md", "other.md"]);
1631        let _ = resolve_urls(&mut doc, &graph, "index.md");
1632        let href = first_link_href(&doc);
1633        assert!(href.contains("#Block Id"), "expected raw block-ref, got: {href}");
1634        assert!(!href.contains("#block-id"), "block-ref was slugged: {href}");
1635    }
1636
1637    #[test]
1638    fn wikilink_cjk_fragment_preserved() {
1639        // CJK characters are preserved by obsidian_heading_anchor.
1640        let mut doc = parse("[[other#中文标题]]");
1641        let graph = graph_with(&["index.md", "other.md"]);
1642        let _ = resolve_urls(&mut doc, &graph, "index.md");
1643        assert_eq!(first_link_href(&doc), "moss-resolved:other.md#中文标题");
1644    }
1645
1646    #[test]
1647    fn slug_wikilink_suffix_preserves_query() {
1648        // A `?query#frag` suffix: only the `#frag` is slugged; the query
1649        // passes through untouched.
1650        assert_eq!(slug_wikilink_suffix("?a=1#My Heading"), "?a=1#my-heading");
1651        // Query-only suffix is untouched.
1652        assert_eq!(slug_wikilink_suffix("?a=1"), "?a=1");
1653        // Fragment-only suffix is slugged.
1654        assert_eq!(slug_wikilink_suffix("#My Heading"), "#my-heading");
1655        // Block ref keeps id raw (caret stripped).
1656        assert_eq!(slug_wikilink_suffix("#^Block Id"), "#Block Id");
1657    }
1658
1659    // -----------------------------------------------------------------
1660    // Task 6: engine routing tests for resolve_asset_url
1661    //
1662    // These tests exercise the unified asset engine (resolve_asset_ref)
1663    // through the resolve_asset_url path. They cover:
1664    //   - Separator-bearing paths that the old code passed through verbatim
1665    //     (the 404 bug), now rebased via SeparatorFallback.
1666    //   - Absolute `/`-prefixed paths that must stay absolute (R3).
1667    //   - Case-mismatched paths that the engine canonicalises.
1668    //   - Bare filenames that must behave identically to the old
1669    //     resolve_reference path (the `image_bare_unchanged_from_today` gate).
1670    // -----------------------------------------------------------------
1671
1672    /// Test seam: build a `Url::Unresolved(raw)`, run it through `resolve_asset_url`,
1673    /// and return the resolved `href` string. The `graph` is built with
1674    /// `ContentGraph::from_paths`.
1675    fn resolve_image_src(raw: &str, source_path: &str, graph: &crate::content_graph::ContentGraph) -> String {
1676        let mut url = Url::Unresolved(raw.to_string());
1677        let mut outgoing = Vec::new();
1678        resolve_asset_url(&mut url, "", graph, source_path, &mut outgoing);
1679        match url {
1680            Url::Resolved(r) => r.href,
1681            Url::Unresolved(s) => s,
1682        }
1683    }
1684
1685    #[test]
1686    fn image_separator_fallback_rebases_to_root() {
1687        // The 404 bug: `./assets/AGU2025.jpg` authored in `News/post.md` is
1688        // not adjacent (no `News/assets/` dir). Old code passed it verbatim →
1689        // 404. New engine: SeparatorFallback → root `assets/AGU2025.jpg` →
1690        // `relative_asset_path("News/post.md", "assets/AGU2025.jpg")` = "../assets/AGU2025.jpg".
1691        // (The downstream +1 ../ for pretty-URL nesting is added by
1692        // adjust_relative_paths_for_pretty_urls in src-tauri, not here.)
1693        let graph = graph_with(&["assets/AGU2025.jpg", "News/post.md"]);
1694        assert_eq!(
1695            resolve_image_src("./assets/AGU2025.jpg", "News/post.md", &graph),
1696            "../assets/AGU2025.jpg"
1697        );
1698    }
1699
1700    #[test]
1701    fn image_absolute_stays_absolute() {
1702        // R3: an absolute `/`-prefixed asset reference must be emitted with
1703        // its leading `/` intact, never run through relative_asset_path.
1704        let graph = graph_with(&["assets/x.jpg"]);
1705        assert_eq!(
1706            resolve_image_src("/assets/x.jpg", "News/post.md", &graph),
1707            "/assets/x.jpg"
1708        );
1709    }
1710
1711    #[test]
1712    fn image_case_mismatch_emits_canonical() {
1713        // `./assets/Hoon.jpg` authored in `Team.md` (root); disk is `Hoon.JPG`.
1714        // Engine: CaseMismatch → root_rel = "assets/Hoon.JPG".
1715        // relative_asset_path("Team.md", "assets/Hoon.JPG"):
1716        //   from_dir = "" (Team.md is at root) → ups = 0 → "assets/Hoon.JPG"
1717        //   (no leading `../` because the source is at the project root).
1718        let graph = graph_with(&["assets/Hoon.JPG"]);
1719        assert_eq!(
1720            resolve_image_src("./assets/Hoon.jpg", "Team.md", &graph),
1721            "assets/Hoon.JPG"
1722        );
1723    }
1724
1725    #[test]
1726    fn image_bare_unchanged_from_today() {
1727        // Gate: bare-filename resolution must produce the SAME result via the
1728        // engine as the old resolve_reference path did. `photo.jpg` from
1729        // `post.md` (root) → BareFuzzy → root_rel = "assets/photo.jpg" →
1730        // relative_asset_path("post.md", "assets/photo.jpg") = "assets/photo.jpg".
1731        let graph = graph_with(&["assets/photo.jpg", "post.md"]);
1732        assert_eq!(
1733            resolve_image_src("photo.jpg", "post.md", &graph),
1734            "assets/photo.jpg"
1735        );
1736    }
1737}