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