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("");
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("");
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\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 ``.
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.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("");
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}