moss_core/resolve.rs
1//! Centralized link resolution — ALL wikilink handling (body AND frontmatter) happens here.
2//!
3//! This module provides shared types for the resolve phase of the
4//! build pipeline, a fuzzy path resolver that wraps
5//! [`ContentGraph::resolve_path`](crate::content_graph::ContentGraph::resolve_path),
6//! and the top-level [`resolve_content`] function that ties all phases together.
7//!
8//! **Architectural boundary:** Downstream code (markdown.rs, render.rs) receives
9//! already-resolved paths. Do NOT add wikilink parsing or resolution elsewhere.
10
11use crate::asset_snapshot::AssetSnapshot;
12use crate::content_graph::ContentGraph;
13
14pub mod asset_class;
15pub mod asset_registry;
16pub mod block_refs;
17pub mod embed_renderer;
18pub mod embeds;
19pub mod ext_kind;
20pub mod folder_class;
21pub mod reference;
22pub mod fuzzy_path;
23pub mod link_class;
24pub mod output_url;
25pub mod registry;
26pub mod title_params;
27pub mod wikilink_dispatch;
28
29/// A link going out from a document.
30#[derive(Debug, Clone)]
31pub struct OutgoingLink {
32 pub target_path: String,
33 pub display_text: String,
34 pub link_type: LinkType,
35}
36
37/// The kind of link syntax used.
38#[derive(Debug, Clone, PartialEq)]
39pub enum LinkType {
40 /// `[[target]]` or `[[target|display]]`
41 Wikilink,
42 /// `![[target]]` — an embedded/transcluded reference
43 Embed,
44 /// Standard markdown `[text](url)`
45 Standard,
46}
47
48/// A diagnostic message from the resolve phase.
49#[derive(Debug, Clone)]
50pub struct Diagnostic {
51 pub message: String,
52 pub source_path: String,
53 pub reference: String,
54}
55
56/// Result of resolving all Obsidian syntax in a markdown file.
57#[derive(Debug)]
58pub struct ResolveResult {
59 /// Clean markdown with all Obsidian syntax resolved.
60 pub content_markdown: String,
61 /// All outgoing links from this document.
62 pub outgoing_links: Vec<OutgoingLink>,
63 /// Warnings and errors encountered during resolution.
64 pub diagnostics: Vec<Diagnostic>,
65 /// Block IDs extracted from this document.
66 pub block_ids: Vec<String>,
67 /// (target_path, source_path) pairs for embed dependency tracking.
68 pub embed_deps: Vec<(String, String)>,
69}
70
71/// Resolve all Obsidian syntax in a markdown file, producing clean standard markdown.
72///
73/// Pipeline order:
74/// 1. Separate frontmatter from body
75/// 2. Resolve wikilinks (first pass) -- standard `[[…]]` and `![[…]]` to markdown links / embed markers
76/// 3. Resolve embed placeholders -- inline `<!-- moss-embed:… -->` markers with file content
77/// 4. Resolve wikilinks (second pass) -- catch wikilinks introduced by embedded content
78/// 5. Transform block references -- `^id` markers to HTML anchors
79/// 6. Rejoin frontmatter + resolved body
80///
81/// Phase 4 PR7a (2026-05-28) deleted Stage 1 callout transformation,
82/// bare-filename image resolution, AND standard markdown link
83/// resolution (`[text](target.md)`); all three are now part of the
84/// typed AST (`crates/moss-core/src/ast/`). For standard markdown
85/// links, the AST visitor (`ast/resolve_urls::resolve_link_urls`)
86/// emits the same `moss-resolved:` sentinel Stage 1 used to emit, so
87/// src-tauri's `classify_url_prod` decoder still drives page_map /
88/// external_url_map / wikilink-class decoding unchanged.
89pub fn resolve_content(
90 source_path: &str,
91 raw_markdown: &str,
92 graph: &ContentGraph,
93 file_reader: &dyn Fn(&str) -> Option<String>,
94) -> ResolveResult {
95 let handlers = embeds::MarkerHandlers::new();
96 let registry = registry::RendererRegistry::builtin().build();
97 resolve_content_with_handlers(
98 source_path,
99 raw_markdown,
100 graph,
101 file_reader,
102 ®istry,
103 &handlers,
104 )
105}
106
107/// Variant of [`resolve_content`] that threads a custom [`registry::RendererRegistry`]
108/// (plugin-aware renderer dispatch) and [`embeds::MarkerHandlers`] (resolvers for
109/// Deferred markers: notebook, table, plugin renderers) through the pipeline.
110///
111/// Built-in-only pipelines should call [`resolve_content`]. Pipelines that load
112/// plugins at init time build a registry + handlers once and call this variant.
113///
114/// The handler registry fires in a **new step 4.25** that runs after embed
115/// resolution and before the second wikilink pass. This ordering lets
116/// Deferred handlers splice content that may itself contain wikilinks.
117pub fn resolve_content_with_handlers(
118 source_path: &str,
119 raw_markdown: &str,
120 graph: &ContentGraph,
121 file_reader: &dyn Fn(&str) -> Option<String>,
122 registry: ®istry::RendererRegistry,
123 handlers: &embeds::MarkerHandlers<'_>,
124) -> ResolveResult {
125 // Default-empty snapshot for callers that don't yet thread asset data.
126 // Phase 0 Task F1: the snapshot-aware variant exists below and is the
127 // entry point production code should migrate to as Phase 1 lights up
128 // consumption.
129 let empty_snapshot = AssetSnapshot::new();
130 resolve_content_with_handlers_and_snapshot(
131 source_path,
132 raw_markdown,
133 graph,
134 file_reader,
135 registry,
136 handlers,
137 &empty_snapshot,
138 )
139}
140
141/// Variant of [`resolve_content_with_handlers`] that additionally threads
142/// an [`AssetSnapshot`] through the resolve pipeline.
143///
144/// **Phase 0**: the snapshot is threaded but **not yet consumed** by any
145/// resolver — Stage 1 still emits markdown without reading variants/dims.
146/// Phase 1 wires the consumption side in moss-core's synthesizer. The
147/// signature exists now so src-tauri's build pipeline can populate the
148/// snapshot (from `MediaDimensionLookup` + `AssetRegistry`) and prove the
149/// threading path before consumers depend on it.
150///
151/// See `docs/plans/2026-05-25-phase0-asset-snapshot-and-translator.md`
152/// § Phase F for the thread-first / consume-later rationale.
153pub fn resolve_content_with_handlers_and_snapshot(
154 source_path: &str,
155 raw_markdown: &str,
156 graph: &ContentGraph,
157 file_reader: &dyn Fn(&str) -> Option<String>,
158 registry: ®istry::RendererRegistry,
159 handlers: &embeds::MarkerHandlers<'_>,
160 // Phase 0: threaded but not yet consumed. Phase 1 wires up reads.
161 _assets: &AssetSnapshot,
162) -> ResolveResult {
163 // Step 1: Separate frontmatter from body.
164 let (frontmatter, body) = split_frontmatter(raw_markdown);
165
166 // Phase 3 PR2: Stage 1's wikilink rewriter + stage1_sweep retire.
167 // pulldown-cmark now parses `[[…]]` / `![[…]]` natively via
168 // `Options::ENABLE_WIKILINKS` (flipped in PR2 at every Parser::new_ext
169 // site), and `transform_events::dispatch_wikilink_at` routes each event
170 // through the EmbedRenderer registry. The `stage1_sweep`
171 // (`` → `moss:kind=pdf` title rewrite) is retired per
172 // plan Option A: authors who want non-image embeds use the wikilink
173 // form `![[report.pdf]]`. See plan v2 § PR2.
174 let outgoing_links: Vec<OutgoingLink> = Vec::new();
175 let diagnostics: Vec<Diagnostic> = Vec::new();
176 let _ = registry; // Phase 3 PR2: registry flows directly to src-tauri's
177 // `transform_events` via `process_markdown_file`; this
178 // crate-side path no longer dispatches embeds in Stage 1.
179
180 // Phase 3 PR2: pre-pass that lowers block-level wikilinks into
181 // marker comments BEFORE pulldown-cmark sees them. Two classes of
182 // wikilink need this treatment because their output is block-level
183 // HTML, and pulldown-cmark wraps single-image paragraphs in `<p>`
184 // unconditionally:
185 // - **markdown transclusions** (`![[note]]`, `![[note.md]]`,
186 // `![[note#section]]`) → `<!-- moss-embed:TARGET -->` for
187 // `embeds::resolve_embeds` to inline the body.
188 // - **folder-list embeds** (`![[/dir/|limit:N]]`) →
189 // `<!-- MOSS_MARKER_FOLDER_LIST:… -->` for src-tauri's marker
190 // handlers to expand into card grids.
191 // Both cases used to be emitted by Stage 1's wikilink resolver; with
192 // that resolver retired, pulldown-cmark's Stage 2 dispatcher would
193 // emit the markers inside `<p>` (paragraph context), and
194 // `resolve_embeds` would never see them (it scans markdown lines,
195 // not rendered HTML). Pre-converting both shapes here mirrors the
196 // pre-Phase-3 layering.
197 let body = lower_transclusion_and_folder_wikilinks(body, graph, source_path);
198
199 // Step 3: Resolve markdown transclusion embeds. The inlined body of
200 // each embedded `.md` file is appended verbatim — its wikilinks (if
201 // any) survive into the markdown handed back to src-tauri, where
202 // pulldown-cmark + Stage 2 dispatcher resolves them along with the
203 // host page's own wikilinks.
204 let embed_result = embeds::resolve_embeds(&body, source_path, file_reader);
205 let mut diagnostics = diagnostics;
206 diagnostics.extend(embed_result.diagnostics);
207 let embed_deps = embed_result.embed_deps;
208
209 // Step 3.5: Resolve Deferred markers (notebook, table, plugins). All
210 // built-in handlers emit pure HTML (`<iframe>`, `<table>`); plugin
211 // handlers must do the same (no raw `[[…]]` in handler output).
212 // Skipped cheaply if handlers is empty.
213 let deferred_result = embeds::resolve_deferred_markers(&embed_result.content, handlers);
214 diagnostics.extend(deferred_result.diagnostics);
215
216 // Step 4.6 (DELETED, Phase 4 PR7a-stage1b 2026-05-28):
217 // `markdown_links::resolve_markdown_links` is gone. The typed AST
218 // visitor (`crates/moss-core/src/ast/resolve_urls.rs::resolve_link_urls`)
219 // now produces byte-equivalent results — including the
220 // `moss-resolved:<path>` sentinel that src-tauri's `classify_url_prod`
221 // decoder consumes for `page_map` / `external_url_map` / wikilink-class
222 // decoding. `outgoing_links` remains empty at this layer; the AST
223 // visitor's OutgoingLink Vec is consumed downstream in
224 // `process_markdown_file`.
225
226 // Step 5: Transform block references.
227 //
228 // Phase 4 PR7a (2026-05-28) deleted the Stage 1 `transform_callouts`
229 // pass that ran here. Obsidian-callout syntax is now handled by the
230 // typed AST parser (`crates/moss-core/src/ast/parser.rs`'s
231 // `Tag::BlockQuote` arm); the AST renderer emits the same canonical
232 // callout HTML (and additively handles foldable +/- suffixes and
233 // Obsidian aliases). See investigation notes referenced in the
234 // PR7a commit message for the byte-shape parity proof.
235 let (block_result, block_ids) = block_refs::transform_block_refs(&deferred_result.content);
236
237 // Step 6: Resolve frontmatter wikilinks + rejoin with resolved body.
238 let content_markdown = match frontmatter {
239 Some(fm) => {
240 let resolved_fm = resolve_frontmatter_wikilinks(fm, graph, source_path);
241 diagnostics.extend(resolved_fm.diagnostics);
242 format!("{}{}", resolved_fm.content, block_result)
243 }
244 None => block_result,
245 };
246
247 ResolveResult {
248 content_markdown,
249 outgoing_links,
250 diagnostics,
251 block_ids,
252 embed_deps,
253 }
254}
255
256/// Phase 3 PR2: lower wikilink-form markdown transclusions
257/// (`![[note]]` / `![[note.md]]` / `![[note#section]]`) into the
258/// `<!-- moss-embed:TARGET -->` marker shape that
259/// [`embeds::resolve_embeds`] consumes. Pure text rewrite — no I/O.
260///
261/// Why this pre-pass exists: pre-Phase-3, Stage 1's wikilink resolver
262/// did this conversion. Phase 3 retires that resolver and routes most
263/// wikilink handling through pulldown-cmark's Stage 2 dispatcher in
264/// `src-tauri/src/build/markdown/pipeline.rs::transform_events`. But
265/// `embeds::resolve_embeds` runs BEFORE pulldown-cmark, so the
266/// dispatcher cannot emit the marker in time. We pre-convert the
267/// transclusion wikilinks here.
268///
269/// Only `.md`-extension wikilinks (and extension-less wikilinks
270/// resolving to `.md` files) are rewritten. Image / pdf / iframe /
271/// video / audio / 3d / notebook / table embeds still flow through the
272/// Stage 2 dispatcher untouched.
273///
274/// The conversion is line-based and respects fenced code blocks
275/// (`{```/~~~}` blocks pass through unchanged). It does not honor
276/// inline-code spans on a line — wikilinks inside `` `like this` ``
277/// would also be rewritten — which mirrors the pre-Phase-3 wikilink
278/// resolver's coarse line-level scan.
279fn lower_transclusion_and_folder_wikilinks(
280 body: &str,
281 graph: &ContentGraph,
282 source_path: &str,
283) -> String {
284 let mut output_lines: Vec<String> = Vec::with_capacity(body.lines().count() + 1);
285 let mut fence_char: Option<char> = None;
286 for line in body.lines() {
287 // Fenced code block tracking — same logic as markdown_refs.
288 if let Some(fc) = fence_char {
289 let trimmed = line.trim_start();
290 let closes = trimmed.starts_with(fc)
291 && trimmed.chars().take(3).all(|c| c == fc)
292 && trimmed.trim_matches(fc).trim().is_empty();
293 if closes {
294 fence_char = None;
295 }
296 output_lines.push(line.to_string());
297 continue;
298 }
299 let trimmed = line.trim_start();
300 let fence_rest = trimmed
301 .strip_prefix("```")
302 .map(|r| ('`', r))
303 .or_else(|| trimmed.strip_prefix("~~~").map(|r| ('~', r)));
304 if let Some((candidate_char, rest)) = fence_rest {
305 if !rest.contains(candidate_char) {
306 fence_char = Some(candidate_char);
307 output_lines.push(line.to_string());
308 continue;
309 }
310 }
311
312 // Rewrite `![[…]]` wikilinks where the resolved target is a
313 // markdown file. Single-occurrence per line is the common case;
314 // a loop handles multi-occurrence safely.
315 let mut rewritten = String::with_capacity(line.len());
316 let mut rest = line;
317 while let Some(start) = rest.find("![[") {
318 rewritten.push_str(&rest[..start]);
319 let after = &rest[start + 3..];
320 let Some(end) = after.find("]]") else {
321 rewritten.push_str(&rest[start..]);
322 rest = "";
323 break;
324 };
325 let inner = &after[..end];
326 // Pothole-aware: pre-Phase-3 dropped pothole text for the
327 // marker (params live in the marker's heading-anchor /
328 // query suffix). Today the marker only cares about the
329 // `file#section` shape.
330 let inner_no_pothole = match inner.split_once('|') {
331 Some((f, _)) => f,
332 None => inner,
333 };
334 let (file_part, anchor) = match inner_no_pothole.find('#') {
335 Some(p) => (&inner_no_pothole[..p], Some(&inner_no_pothole[p + 1..])),
336 None => (inner_no_pothole, None),
337 };
338
339 // Skip empty target (`![[]]` is meaningless).
340 if file_part.is_empty() {
341 rewritten.push_str(&rest[start..start + 3 + end + 2]);
342 rest = &rest[start + 3 + end + 2..];
343 continue;
344 }
345
346 // Folder-list embed: trailing slash dispatches to the
347 // `MOSS_MARKER_FOLDER_LIST` marker that src-tauri's marker
348 // handler resolves into a card grid. The pothole carries
349 // params (limit:N, more, sort:axis) in pipe-encoded form.
350 if file_part.ends_with('/') {
351 let pothole_raw = match inner.split_once('|') {
352 Some((_, params)) => params,
353 None => "",
354 };
355 let params = embed_renderer::folder_list::parse_params(pothole_raw);
356 let marker =
357 embed_renderer::folder_list::emit_marker(file_part, source_path, ¶ms);
358 rewritten.push_str(&marker);
359 rest = &rest[start + 3 + end + 2..];
360 continue;
361 }
362
363 // Resolve via ContentGraph. Bail to no-rewrite if the
364 // reference doesn't resolve — Stage 2's dispatcher will
365 // emit the `[unresolved](moss-unresolved:…)` link form.
366 let resolved = fuzzy_path::resolve_reference(file_part, graph, source_path);
367 let target_path = match resolved {
368 fuzzy_path::ResolvedRef::Found(p) => p,
369 fuzzy_path::ResolvedRef::Unresolved => {
370 rewritten.push_str(&rest[start..start + 3 + end + 2]);
371 rest = &rest[start + 3 + end + 2..];
372 continue;
373 }
374 };
375 let ext = target_path
376 .rsplit('.')
377 .next()
378 .unwrap_or("")
379 .to_ascii_lowercase();
380 // Markdown transclusion: `![[note.md]]` →
381 // `<!-- moss-embed:note.md[#anchor] -->`.
382 if ext == "md" || ext == "markdown" {
383 let target_with_anchor = match anchor {
384 Some(a) => format!("{}#{}", target_path, a),
385 None => target_path,
386 };
387 rewritten.push_str("<!-- moss-embed:");
388 rewritten.push_str(&target_with_anchor);
389 rewritten.push_str(" -->");
390 rest = &rest[start + 3 + end + 2..];
391 continue;
392 }
393 // Deferred-handler embeds: `.ipynb` → notebook marker,
394 // `.csv` / `.tsv` → table marker. These extensions route to
395 // src-tauri marker handlers; the Stage 2 dispatcher would
396 // also produce these markers, but it runs AFTER
397 // `resolve_deferred_markers`, so pre-converting here keeps
398 // the existing marker-handler pipeline working.
399 let marker_prefix = match ext.as_str() {
400 "ipynb" => Some("moss-embed-ipynb"),
401 "csv" | "tsv" => Some("moss-embed-table"),
402 _ => None,
403 };
404 if let Some(prefix) = marker_prefix {
405 rewritten.push_str("<!-- ");
406 rewritten.push_str(prefix);
407 rewritten.push(':');
408 rewritten.push_str(&target_path);
409 rewritten.push_str(" -->");
410 rest = &rest[start + 3 + end + 2..];
411 continue;
412 }
413 // Other extensions (.pdf / .mp4 / .png / etc.) flow through
414 // the Stage 2 dispatcher untouched — those renderers
415 // produce HTML inline, not deferred markers.
416 rewritten.push_str(&rest[start..start + 3 + end + 2]);
417 rest = &rest[start + 3 + end + 2..];
418 }
419 rewritten.push_str(rest);
420 output_lines.push(rewritten);
421 }
422 let mut out = output_lines.join("\n");
423 if body.ends_with('\n') {
424 out.push('\n');
425 }
426 out
427}
428
429pub struct FrontmatterResolveResult {
430 /// The frontmatter text with `[[wikilinks]]` replaced by resolved paths.
431 pub content: String,
432 /// Diagnostics for unresolved references.
433 pub diagnostics: Vec<Diagnostic>,
434}
435
436/// Resolve `[[wikilink]]` patterns in frontmatter text to content graph paths.
437///
438/// Unlike body wikilink resolution (which produces markdown links like
439/// `[text](url)`), this function replaces `[[ref]]` with just the resolved
440/// path string. Surrounding quotes are preserved.
441///
442/// # Examples
443///
444/// - `sidebar: "[[news]]"` → `sidebar: "news.md"` (or resolved path)
445/// - `sidebar: [[news]]` → `sidebar: news.md`
446/// - `cover: "[[photo.jpg]]"` → `cover: "assets/photo.jpg"`
447/// - Unresolved: `[[missing]]` → `missing` (brackets stripped, diagnostic emitted)
448///
449/// The input `frontmatter` should include the delimiter(s) (e.g. `---`).
450/// Wikilinks in delimiter lines are not expected but won't cause issues.
451pub fn resolve_frontmatter_wikilinks(
452 frontmatter: &str,
453 graph: &ContentGraph,
454 source_path: &str,
455) -> FrontmatterResolveResult {
456 let mut diagnostics = Vec::new();
457 let mut result = String::with_capacity(frontmatter.len());
458 let bytes = frontmatter.as_bytes();
459 let len = bytes.len();
460 let mut i = 0;
461
462 while i < len {
463 // Look for `![[` (embed wikilink) or `[[` (regular wikilink)
464 // Embed prefix `!` is consumed — both resolve to the same path.
465 // For embeds `![[path|attrs]]`, pipe content = display params (preserved).
466 // For links `[[path|alias]]`, pipe content = alias text (discarded per Obsidian convention).
467 let is_embed =
468 i + 2 < len && bytes[i] == b'!' && bytes[i + 1] == b'[' && bytes[i + 2] == b'[';
469 let is_wikilink = !is_embed && i + 1 < len && bytes[i] == b'[' && bytes[i + 1] == b'[';
470 if is_embed || is_wikilink {
471 let bracket_start = if is_embed { i + 3 } else { i + 2 };
472 // Find closing `]]`
473 if let Some(close_pos) = find_closing_brackets(bytes, bracket_start) {
474 // Char-aligned: `bracket_start = i + 2` or `i + 3` where `i` is the
475 // byte-cursor invariant of the outer loop (see else-branch comment),
476 // and the offsets cross only ASCII bytes (`[`, `!`). `close_pos` is
477 // returned by `find_closing_brackets` which scans for the ASCII pair
478 // `]]`, so it lands on a char boundary.
479 #[allow(clippy::string_slice)]
480 let inner = &frontmatter[bracket_start..close_pos];
481
482 // Split on | to separate path from pipe content
483 let (ref_part, attrs_part) = crate::media::split_pipe(inner);
484
485 // Resolve only the path part via the content graph
486 let resolved_path = match graph.resolve_path(ref_part, source_path) {
487 Some(mut path) => {
488 // Only preserve pipe attrs for embed syntax (![[...|attrs]])
489 // For regular wikilinks ([[...|alias]]), discard the alias
490 if is_embed && !attrs_part.is_empty() {
491 path.push('|');
492 path.push_str(attrs_part);
493 }
494 path
495 }
496 None => {
497 diagnostics.push(Diagnostic {
498 message: format!("Unresolved frontmatter wikilink: [[{}]]", ref_part),
499 source_path: source_path.to_string(),
500 reference: ref_part.to_string(),
501 });
502 // Strip brackets, use the path text as-is
503 let mut fallback = ref_part.to_string();
504 // Only preserve attrs for embed syntax
505 if is_embed && !attrs_part.is_empty() {
506 fallback.push('|');
507 fallback.push_str(attrs_part);
508 }
509 fallback
510 }
511 };
512
513 result.push_str(&resolved_path);
514 i = close_pos + 2; // skip past `]]`
515 } else {
516 // No closing `]]` found — emit the opening chars as-is
517 if is_embed {
518 result.push_str("![[");
519 i += 3;
520 } else {
521 result.push('[');
522 i += 1;
523 }
524 }
525 } else {
526 // Byte-cursor invariant: `i` is always at a UTF-8 char boundary.
527 // * Initial value `i = 0` is a boundary.
528 // * In the wikilink branch above, `i` is reassigned to either
529 // `close_pos + 2` (close_pos is the byte index of the first `]`
530 // in the ASCII pair `]]`, so +2 also lands on an ASCII byte) or
531 // advanced by `+= 3` / `+= 1` past ASCII chars (`!`, `[`).
532 // * In this else branch, we read one full char from the boundary
533 // and advance by exactly its UTF-8 length, preserving the boundary.
534 // Therefore slicing `frontmatter[i..]` here is safe, and the
535 // `let-else { break }` is a defensive fallback: the loop guard
536 // `i < len` already ensures at least one byte is available, but
537 // bailing cleanly is cheaper than a panic if the invariant ever
538 // breaks.
539 #[allow(clippy::string_slice)]
540 let Some(ch) = frontmatter[i..].chars().next() else {
541 break;
542 };
543 result.push(ch);
544 i += ch.len_utf8();
545 }
546 }
547
548 FrontmatterResolveResult {
549 content: result,
550 diagnostics,
551 }
552}
553
554/// Find the position of the first `]]` in `bytes` starting from `start`.
555/// Returns the byte index of the first `]` in the `]]` pair, or `None`.
556fn find_closing_brackets(bytes: &[u8], start: usize) -> Option<usize> {
557 let mut j = start;
558 while j + 1 < bytes.len() {
559 if bytes[j] == b']' && bytes[j + 1] == b']' {
560 return Some(j);
561 }
562 // Wikilinks in frontmatter values are expected to be on a single line.
563 // We allow multi-line scanning for robustness.
564 j += 1;
565 }
566 None
567}
568
569/// Scan `content` starting from byte offset `scan_start` for the first
570/// standalone `---` line. Returns the byte position just past the
571/// delimiter (including its trailing newline, if present).
572fn find_delimiter(content: &str, scan_start: usize) -> Option<usize> {
573 // Char-aligned: callers pass either 0 or `pos + 1` where `pos = content.find('\n')`
574 // (an ASCII byte). Both values land on a UTF-8 char boundary.
575 #[allow(clippy::string_slice)]
576 let rest = &content[scan_start..];
577 let mut offset = 0;
578 for line in rest.lines() {
579 if line.trim() == "---" {
580 let close_abs = scan_start + offset + line.len();
581 return if close_abs < content.len() && content.as_bytes()[close_abs] == b'\n' {
582 Some(close_abs + 1)
583 } else {
584 Some(close_abs)
585 };
586 }
587 offset += line.len() + 1; // +1 for '\n'
588 }
589 None
590}
591
592/// Split content into (frontmatter_including_delimiters, body).
593///
594/// Supports two frontmatter formats:
595///
596/// **Standard YAML** — content starts with `---\n`:
597/// ```text
598/// ---
599/// title: Hello
600/// ---
601/// Body here.
602/// ```
603///
604/// **Simplified** — content does NOT start with `---`, but contains a
605/// standalone `---` line that separates frontmatter from body:
606/// ```text
607/// children: false
608/// sidebar: "[[news]]"
609/// ---
610///
611/// # Page Title
612/// ```
613///
614/// In both cases the frontmatter portion includes the delimiter(s) and
615/// any trailing newline after the closing `---`. Returns
616/// `(None, full_content)` when no frontmatter is detected.
617fn split_frontmatter(content: &str) -> (Option<&str>, &str) {
618 if content.starts_with("---") {
619 // --- Standard YAML frontmatter ---
620
621 // Find end of the opening `---` line.
622 let after_opening = match content.find('\n') {
623 Some(pos) => pos + 1,
624 None => return (None, content),
625 };
626
627 // Search for a closing `---` line in the remainder.
628 // Char-aligned: `split_pos` is computed by `find_delimiter` from
629 // `scan_start + line.len() + (line.len() + 1)*N + (0 or 1)`. All
630 // components are either char-aligned (`scan_start`, slices from `lines()`)
631 // or single ASCII bytes (`'\n'`), so `split_pos` is on a char boundary.
632 #[allow(clippy::string_slice)]
633 match find_delimiter(content, after_opening) {
634 Some(split_pos) => (Some(&content[..split_pos]), &content[split_pos..]),
635 None => (None, content), // No closing delimiter — treat entire content as body.
636 }
637 } else {
638 // --- Simplified frontmatter ---
639 // Look for the first standalone `---` line. Everything up to and
640 // including that line (plus its trailing newline) is frontmatter;
641 // everything after is body.
642 // Same char-alignment rationale as above.
643 #[allow(clippy::string_slice)]
644 match find_delimiter(content, 0) {
645 Some(split_pos) => (Some(&content[..split_pos]), &content[split_pos..]),
646 None => (None, content), // No `---` found at all — no frontmatter.
647 }
648 }
649}
650
651/// Extract the parent directory from a `/`-separated path.
652///
653/// `"posts/hello.md"` -> `"posts"`, `"hello.md"` -> `""`.
654pub(crate) fn parent_dir(path: &str) -> &str {
655 match path.rfind('/') {
656 // Char-aligned: '/' is an ASCII byte, so `pos` is a char boundary.
657 #[allow(clippy::string_slice)]
658 Some(pos) => &path[..pos],
659 None => "",
660 }
661}
662
663// ---------------------------------------------------------------------------
664// Tests
665// ---------------------------------------------------------------------------
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670 use crate::content_graph::ContentGraphBuilder;
671 use std::collections::HashMap;
672
673 fn test_graph() -> ContentGraph {
674 let mut b = ContentGraphBuilder::new();
675 b.add_file("guide.md", "guide");
676 b.add_file("note.md", "note");
677 b.add_file("disclaimer.md", "disclaimer");
678 b.add_file("assets/photo.jpg", "photo");
679 b.add_headings("guide.md", vec![("Setup".into(), "setup".into())]);
680 b.add_blocks("guide.md", vec!["key-point".into()]);
681 b.build()
682 }
683
684 fn test_files() -> HashMap<String, String> {
685 let mut files = HashMap::new();
686 files.insert(
687 "disclaimer.md".into(),
688 "---\ntitle: Disclaimer\n---\nThis is the disclaimer.\n\nSee [[guide]] for details."
689 .into(),
690 );
691 files
692 }
693
694 fn mock_reader(files: &HashMap<String, String>) -> impl Fn(&str) -> Option<String> + '_ {
695 move |path: &str| files.get(path).cloned()
696 }
697
698 // ----- split_frontmatter unit tests -----
699
700 #[test]
701 fn test_split_fm_present() {
702 let input = "---\ntitle: Hello\n---\nBody here.";
703 let (fm, body) = split_frontmatter(input);
704 assert_eq!(fm, Some("---\ntitle: Hello\n---\n"));
705 assert_eq!(body, "Body here.");
706 }
707
708 #[test]
709 fn test_split_fm_absent() {
710 let input = "Just body content.";
711 let (fm, body) = split_frontmatter(input);
712 assert!(fm.is_none());
713 assert_eq!(body, input);
714 }
715
716 #[test]
717 fn test_split_fm_no_closing() {
718 let input = "---\ntitle: Hello\nno closing delimiter";
719 let (fm, body) = split_frontmatter(input);
720 assert!(fm.is_none());
721 assert_eq!(body, input);
722 }
723
724 // ----- split_frontmatter: simplified frontmatter tests -----
725
726 #[test]
727 fn test_split_simplified_frontmatter() {
728 // Simplified format: no opening `---`, frontmatter lines before a `---` delimiter.
729 let input = "sidebar: [[news]]\n---\n\n# Hello";
730 let (fm, body) = split_frontmatter(input);
731 assert_eq!(fm, Some("sidebar: [[news]]\n---\n"));
732 assert_eq!(body, "\n# Hello");
733 }
734
735 #[test]
736 fn test_split_simplified_preserves_body() {
737 let input = "children: false\nuid: a48746ca\n---\n\n# Page Title\n\nBody content here\n";
738 let (fm, body) = split_frontmatter(input);
739 assert_eq!(fm, Some("children: false\nuid: a48746ca\n---\n"));
740 assert_eq!(body, "\n# Page Title\n\nBody content here\n");
741 }
742
743 #[test]
744 fn test_split_no_delimiter() {
745 // No `---` at all — everything is body, no frontmatter.
746 let input = "Just some content\nwith multiple lines\nbut no delimiter";
747 let (fm, body) = split_frontmatter(input);
748 assert!(fm.is_none());
749 assert_eq!(body, input);
750 }
751
752 #[test]
753 fn test_split_simplified_with_quoted_wikilink() {
754 let input = "sidebar: \"[[news]]\"\n---\nBody text";
755 let (fm, body) = split_frontmatter(input);
756 assert_eq!(fm, Some("sidebar: \"[[news]]\"\n---\n"));
757 assert_eq!(body, "Body text");
758 }
759
760 #[test]
761 fn test_split_simplified_empty_body() {
762 // Simplified frontmatter with nothing after the delimiter.
763 let input = "title: Test\n---\n";
764 let (fm, body) = split_frontmatter(input);
765 assert_eq!(fm, Some("title: Test\n---\n"));
766 assert_eq!(body, "");
767 }
768
769 #[test]
770 fn test_split_simplified_delimiter_at_eof_no_newline() {
771 // Simplified frontmatter where `---` is the last line with no trailing newline.
772 let input = "title: Test\n---";
773 let (fm, body) = split_frontmatter(input);
774 assert_eq!(fm, Some("title: Test\n---"));
775 assert_eq!(body, "");
776 }
777
778 #[test]
779 fn test_split_simplified_multiple_dashes_in_body() {
780 // Only the FIRST `---` should be treated as the delimiter.
781 let input = "title: Test\n---\n\nSome body\n---\nMore body";
782 let (fm, body) = split_frontmatter(input);
783 assert_eq!(fm, Some("title: Test\n---\n"));
784 assert_eq!(body, "\nSome body\n---\nMore body");
785 }
786
787 // ----- Integration tests for resolve_content -----
788
789 #[test]
790 fn test_full_resolve_pipeline() {
791 let graph = test_graph();
792 let files = test_files();
793
794 let input = "---\ntitle: Test\n---\nSee [[guide#Setup]] for help.\n\nImportant point. ^my-block\n\n> [!warning] Watch Out\n> Be careful here.";
795
796 let result = resolve_content("note.md", input, &graph, &mock_reader(&files));
797
798 // Frontmatter preserved
799 assert!(result
800 .content_markdown
801 .starts_with("---\ntitle: Test\n---\n"));
802
803 // Phase 3 PR2: `resolve_content` no longer resolves body wikilinks
804 // — that's the Stage 2 dispatcher's job in
805 // `src-tauri/src/build/markdown/pipeline.rs::transform_events`.
806 // The `[[guide#Setup]]` wikilink passes through unchanged here.
807 assert!(result.content_markdown.contains("[[guide#Setup]]"));
808
809 // Block ref transformed
810 assert!(result
811 .content_markdown
812 .contains("<span id=\"my-block\"></span>"));
813 assert_eq!(result.block_ids, vec!["my-block"]);
814
815 // Phase 4 PR7a (2026-05-28): Stage 1 `transform_callouts` is
816 // deleted. Callout transformation now lives in the typed AST
817 // parser (`ast/parser.rs`'s Tag::BlockQuote arm) and renderer.
818 // `resolve_content` returns raw markdown here — the `> [!warning]`
819 // syntax passes through verbatim for downstream parsing.
820 assert!(
821 result.content_markdown.contains("> [!warning] Watch Out"),
822 "Expected callout markdown to pass through verbatim post-PR7a, got: {}",
823 result.content_markdown
824 );
825 }
826
827 #[test]
828 fn test_frontmatter_preserved() {
829 let graph = test_graph();
830 let files = HashMap::new();
831
832 let input = "---\ntitle: My Page\ntags:\n - rust\n - wasm\n---\nPlain body.";
833 let result = resolve_content("note.md", input, &graph, &mock_reader(&files));
834
835 assert!(result
836 .content_markdown
837 .starts_with("---\ntitle: My Page\ntags:\n - rust\n - wasm\n---\n"));
838 assert!(result.content_markdown.ends_with("Plain body."));
839 }
840
841 #[test]
842 fn test_no_obsidian_syntax() {
843 let graph = test_graph();
844 let files = HashMap::new();
845
846 let input = "---\ntitle: Plain\n---\nJust a plain paragraph.\n\nAnother paragraph.";
847 let result = resolve_content("note.md", input, &graph, &mock_reader(&files));
848
849 assert_eq!(result.content_markdown, input);
850 assert!(result.outgoing_links.is_empty());
851 assert!(result.diagnostics.is_empty());
852 assert!(result.block_ids.is_empty());
853 assert!(result.embed_deps.is_empty());
854 }
855
856 #[test]
857 fn test_embedded_wikilinks_resolved() {
858 let graph = test_graph();
859 let files = test_files();
860
861 // disclaimer.md body contains `See [[guide]] for details.`
862 // Phase 3 PR2: the embedded body's wikilink is no longer
863 // resolved by `resolve_content`; the Stage 2 dispatcher in
864 // src-tauri handles it. `resolve_content` lowers
865 // `![[disclaimer]]` into the `<!-- moss-embed:disclaimer.md -->`
866 // marker, then `resolve_embeds` inlines the disclaimer body
867 // verbatim — wikilinks inside survive into the markdown
868 // returned here.
869 let input = "![[disclaimer]]";
870 let result = resolve_content("note.md", input, &graph, &mock_reader(&files));
871
872 // The embedded body's wikilink survives as raw `[[guide]]`
873 // (handed off to Stage 2 downstream).
874 assert!(
875 result.content_markdown.contains("[[guide]]"),
876 "Expected raw wikilink from embedded content, got: {}",
877 result.content_markdown
878 );
879 // The disclaimer body text should be present
880 assert!(result.content_markdown.contains("This is the disclaimer."));
881 }
882
883 #[test]
884 fn test_diagnostics_merged() {
885 let graph = test_graph();
886 let files = HashMap::new();
887
888 // Phase 3 PR2: wikilink unresolved diagnostics now surface from
889 // the Stage 2 dispatcher in src-tauri. `resolve_content` only
890 // surfaces diagnostics from passes it still runs (transclusion
891 // / deferred markers / block refs). `![[missing]]` with no
892 // extension resolves to Unresolved in the lowering pass — but
893 // the lowering pass leaves the raw `![[missing]]` for Stage 2
894 // to handle and does NOT emit a diagnostic itself. So this
895 // test asserts the new contract: zero diagnostics for body
896 // wikilinks at this layer.
897 let input = "[[nonexistent]] and ![[missing]]";
898 let result = resolve_content("note.md", input, &graph, &mock_reader(&files));
899
900 // Body wikilinks pass through; no diagnostics from this layer.
901 assert!(
902 result.diagnostics.is_empty(),
903 "Expected zero diagnostics post-PR2 (body wikilinks deferred), got: {:?}",
904 result.diagnostics
905 );
906 // Raw wikilinks pass through to the markdown handed back.
907 assert!(result.content_markdown.contains("[[nonexistent]]"));
908 assert!(result.content_markdown.contains("![[missing]]"));
909 }
910
911 #[test]
912 fn test_outgoing_links_tracked() {
913 let graph = test_graph();
914 let files = test_files();
915
916 // Phase 3 PR2: body wikilink outgoing-links are populated by
917 // the Stage 2 dispatcher in src-tauri (not by `resolve_content`).
918 // What this layer still populates: block_refs results. The
919 // wikilink body links `[[guide]]` and `![[disclaimer]]` pass
920 // through to Stage 2; standard markdown links pass through to
921 // the AST visitor (`ast/resolve_urls`).
922 let input = "[[guide]]\n\n![[disclaimer]]";
923 let result = resolve_content("note.md", input, &graph, &mock_reader(&files));
924
925 // The disclaimer body got inlined (via `<!-- moss-embed -->`
926 // lowering + resolve_embeds), but its `[[guide]]` is now raw
927 // markdown for Stage 2 — none of these appear in
928 // outgoing_links from this layer.
929 let wikilinks: Vec<_> = result
930 .outgoing_links
931 .iter()
932 .filter(|l| l.link_type == LinkType::Wikilink)
933 .collect();
934 let embeds: Vec<_> = result
935 .outgoing_links
936 .iter()
937 .filter(|l| l.link_type == LinkType::Embed)
938 .collect();
939
940 assert!(
941 wikilinks.is_empty(),
942 "Expected zero wikilink outgoing links from resolve_content post-PR2; got {}: {:?}",
943 wikilinks.len(),
944 wikilinks
945 );
946 // No-op smoke check that the rest of the assertions still
947 // exercise the embed-tracking path through `embed_deps`.
948 let _ = embeds; // not populated by this layer either
949 assert!(
950 !result.embed_deps.is_empty(),
951 "Expected at least 1 embed outgoing link"
952 );
953 }
954
955 #[test]
956 fn test_embed_deps_tracked() {
957 let graph = test_graph();
958 let files = test_files();
959
960 let input = "![[disclaimer]]";
961 let result = resolve_content("note.md", input, &graph, &mock_reader(&files));
962
963 assert!(
964 result
965 .embed_deps
966 .contains(&("disclaimer.md".to_string(), "note.md".to_string())),
967 "Expected embed dep (disclaimer.md, note.md), got: {:?}",
968 result.embed_deps
969 );
970 }
971
972 // ----- Regression test for deeply-nested Unicode paths (#342) -----
973
974 #[test]
975 fn test_deeply_nested_unicode_bare_filename() {
976 let mut b = ContentGraphBuilder::new();
977 b.add_file(
978 "assets/d9512f2d-fdcf-4a22-b1d5-340f74ddedae.jpg",
979 "d9512f2d",
980 );
981 b.add_file(
982 "articles/\u{65e0}\u{7528}\u{4e4b}\u{65c5}/\u{771f}\u{6b63}\u{7684}\u{65c5}\u{7a0b}.md",
983 "articles/\u{65e0}\u{7528}\u{4e4b}\u{65c5}/\u{771f}\u{6b63}\u{7684}\u{65c5}\u{7a0b}",
984 );
985 let graph = b.build();
986 let files = HashMap::new();
987
988 let input = "---\ndate: 2025-12-03\n---\n\n\nSome text.";
989 let result = resolve_content(
990 "articles/\u{65e0}\u{7528}\u{4e4b}\u{65c5}/\u{771f}\u{6b63}\u{7684}\u{65c5}\u{7a0b}.md",
991 input,
992 &graph,
993 &mock_reader(&files),
994 );
995
996 // Phase 4 PR7a (2026-05-28): Stage 1 `resolve_markdown_refs` is
997 // deleted. Bare-filename image resolution now happens in the
998 // typed AST visitor (`ast/resolve_urls::resolve_image_urls`)
999 // downstream of `resolve_content`. The image src passes through
1000 // verbatim here.
1001 assert!(
1002 result
1003 .content_markdown
1004 .contains(""),
1005 "Expected bare filename to pass through verbatim, got: {}",
1006 result.content_markdown
1007 );
1008 }
1009
1010 // ----- Integration test for markdown image bare-filename resolution -----
1011
1012 #[test]
1013 fn test_bare_filename_image_passes_through_in_pipeline() {
1014 let mut b = ContentGraphBuilder::new();
1015 b.add_file("guide.md", "guide");
1016 b.add_file("note.md", "note");
1017 b.add_file("assets/photo.jpg", "photo");
1018 b.add_headings("guide.md", vec![("Setup".into(), "setup".into())]);
1019 b.add_blocks("guide.md", vec!["key-point".into()]);
1020 let graph = b.build();
1021 let files = HashMap::new();
1022
1023 let input = "---\ntitle: Test\n---\n\n\nSome text.";
1024 let result = resolve_content("articles/post.md", input, &graph, &mock_reader(&files));
1025
1026 // Frontmatter preserved
1027 assert!(result
1028 .content_markdown
1029 .starts_with("---\ntitle: Test\n---\n"));
1030
1031 // Phase 4 PR7a (2026-05-28): Stage 1 `resolve_markdown_refs` is
1032 // deleted. The bare filename now passes through `resolve_content`
1033 // verbatim; the typed AST visitor
1034 // (`ast/resolve_urls::resolve_image_urls`) resolves it later in
1035 // `process_markdown_file`. The visitor has its own coverage in
1036 // `resolve_urls.rs::tests::resolves_bare_filename_image_against_graph`.
1037 assert!(
1038 result.content_markdown.contains(""),
1039 "Expected bare filename to pass through verbatim, got: {}",
1040 result.content_markdown
1041 );
1042
1043 // No Standard outgoing link from this layer either — the visitor
1044 // emits them downstream.
1045 let standard_links: Vec<_> = result
1046 .outgoing_links
1047 .iter()
1048 .filter(|l| l.link_type == LinkType::Standard)
1049 .collect();
1050 assert!(
1051 standard_links.is_empty(),
1052 "Expected zero standard outgoing links from resolve_content post-PR7a, got: {:?}",
1053 standard_links
1054 );
1055 }
1056
1057 // ----- resolve_frontmatter_wikilinks unit tests -----
1058
1059 fn fm_test_graph() -> ContentGraph {
1060 let mut b = ContentGraphBuilder::new();
1061 b.add_file("index.md", "index");
1062 b.add_file("news.md", "news");
1063 b.add_file("news/index.md", "news-index");
1064 b.add_file("assets/photo.jpg", "photo");
1065 b.add_file("posts/ch-1.md", "ch-1");
1066 b.add_file("posts/ch-2.md", "ch-2");
1067 b.build()
1068 }
1069
1070 #[test]
1071 fn test_fm_wikilink_basic_quoted() {
1072 let graph = fm_test_graph();
1073 let fm = "---\nsidebar: \"[[news]]\"\n---\n";
1074 let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1075 assert_eq!(result.content, "---\nsidebar: \"news.md\"\n---\n");
1076 assert!(result.diagnostics.is_empty());
1077 }
1078
1079 #[test]
1080 fn test_fm_wikilink_unquoted() {
1081 let graph = fm_test_graph();
1082 let fm = "---\nsidebar: [[news]]\n---\n";
1083 let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1084 assert_eq!(result.content, "---\nsidebar: news.md\n---\n");
1085 assert!(result.diagnostics.is_empty());
1086 }
1087
1088 #[test]
1089 fn test_fm_wikilink_cover_image() {
1090 let graph = fm_test_graph();
1091 let fm = "---\ncover: \"[[photo.jpg]]\"\n---\n";
1092 let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1093 assert_eq!(result.content, "---\ncover: \"assets/photo.jpg\"\n---\n");
1094 assert!(result.diagnostics.is_empty());
1095 }
1096
1097 #[test]
1098 fn test_fm_wikilink_folder_note() {
1099 // [[news]] when news/index.md exists should resolve to folder note path.
1100 // But news.md also exists and is an exact stem match, so it resolves to news.md.
1101 // Let's build a graph where only the folder note exists.
1102 let mut b = ContentGraphBuilder::new();
1103 b.add_file("index.md", "index");
1104 b.add_file("news/index.md", "news-index");
1105 let graph = b.build();
1106
1107 let fm = "---\nsidebar: \"[[news]]\"\n---\n";
1108 let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1109 assert_eq!(result.content, "---\nsidebar: \"news/index.md\"\n---\n");
1110 assert!(result.diagnostics.is_empty());
1111 }
1112
1113 #[test]
1114 fn test_fm_wikilink_array_items() {
1115 let graph = fm_test_graph();
1116 let fm = "---\nseries: [\"[[ch-1]]\", \"[[ch-2]]\"]\n---\n";
1117 let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1118 assert_eq!(
1119 result.content,
1120 "---\nseries: [\"posts/ch-1.md\", \"posts/ch-2.md\"]\n---\n"
1121 );
1122 assert!(result.diagnostics.is_empty());
1123 }
1124
1125 #[test]
1126 fn test_fm_wikilink_unresolved() {
1127 let graph = fm_test_graph();
1128 let fm = "---\nsidebar: \"[[missing]]\"\n---\n";
1129 let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1130 // Brackets stripped, inner text used as fallback
1131 assert_eq!(result.content, "---\nsidebar: \"missing\"\n---\n");
1132 assert_eq!(result.diagnostics.len(), 1);
1133 assert_eq!(result.diagnostics[0].reference, "missing");
1134 assert_eq!(result.diagnostics[0].source_path, "index.md");
1135 assert!(result.diagnostics[0].message.contains("[[missing]]"));
1136 }
1137
1138 #[test]
1139 fn test_fm_wikilink_multiple() {
1140 let graph = fm_test_graph();
1141 let fm = "---\nsidebar: \"[[news]]\"\ncover: \"[[photo.jpg]]\"\n---\n";
1142 let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1143 assert_eq!(
1144 result.content,
1145 "---\nsidebar: \"news.md\"\ncover: \"assets/photo.jpg\"\n---\n"
1146 );
1147 assert!(result.diagnostics.is_empty());
1148 }
1149
1150 #[test]
1151 fn test_fm_no_wikilinks() {
1152 let graph = fm_test_graph();
1153 let fm = "---\ntitle: Hello\ntags:\n - rust\n---\n";
1154 let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1155 assert_eq!(result.content, fm);
1156 assert!(result.diagnostics.is_empty());
1157 }
1158
1159 #[test]
1160 fn test_fm_simplified_frontmatter_wikilink() {
1161 let graph = fm_test_graph();
1162 // Simplified frontmatter (no opening ---)
1163 let fm = "sidebar: \"[[news]]\"\nchildren: false\n---\n";
1164 let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1165 assert_eq!(
1166 result.content,
1167 "sidebar: \"news.md\"\nchildren: false\n---\n"
1168 );
1169 assert!(result.diagnostics.is_empty());
1170 }
1171
1172 #[test]
1173 fn test_fm_unclosed_wikilink_preserved() {
1174 let graph = fm_test_graph();
1175 let fm = "---\nsidebar: \"[[unclosed\"\n---\n";
1176 let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1177 // No closing ]] — the [[ is preserved as-is
1178 assert_eq!(result.content, "---\nsidebar: \"[[unclosed\"\n---\n");
1179 assert!(result.diagnostics.is_empty());
1180 }
1181
1182 #[test]
1183 fn test_fm_mixed_resolved_and_unresolved() {
1184 let graph = fm_test_graph();
1185 let fm = "---\nsidebar: \"[[news]]\"\nrelated: \"[[missing]]\"\n---\n";
1186 let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1187 assert_eq!(
1188 result.content,
1189 "---\nsidebar: \"news.md\"\nrelated: \"missing\"\n---\n"
1190 );
1191 assert_eq!(result.diagnostics.len(), 1);
1192 assert_eq!(result.diagnostics[0].reference, "missing");
1193 }
1194
1195 // ----- Pipe-aware frontmatter wikilink resolution -----
1196
1197 #[test]
1198 fn test_fm_wikilink_alias_discarded() {
1199 // [[photo.jpg|left]] — pipe content is alias (Obsidian convention), discarded
1200 let graph = fm_test_graph();
1201 let fm = "---\ncover: \"[[photo.jpg|left]]\"\n---\n";
1202 let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1203 assert_eq!(result.content, "---\ncover: \"assets/photo.jpg\"\n---\n");
1204 assert!(result.diagnostics.is_empty());
1205 }
1206
1207 #[test]
1208 fn test_fm_embed_wikilink_with_attrs() {
1209 // ![[photo.jpg|cover left]] — embed syntax preserves display params
1210 let graph = fm_test_graph();
1211 let fm = "---\ncover: \"![[photo.jpg|cover left]]\"\n---\n";
1212 let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1213 assert_eq!(
1214 result.content,
1215 "---\ncover: \"assets/photo.jpg|cover left\"\n---\n"
1216 );
1217 assert!(result.diagnostics.is_empty());
1218 }
1219
1220 #[test]
1221 fn test_fm_wikilink_no_attrs_unchanged() {
1222 // [[photo.jpg]] without pipe should work exactly as before
1223 let graph = fm_test_graph();
1224 let fm = "---\ncover: \"[[photo.jpg]]\"\n---\n";
1225 let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1226 assert_eq!(result.content, "---\ncover: \"assets/photo.jpg\"\n---\n");
1227 assert!(result.diagnostics.is_empty());
1228 }
1229
1230 #[test]
1231 fn test_fm_wikilink_alias_unresolved_discarded() {
1232 // [[missing.jpg|left]] — unresolved, alias still discarded
1233 let graph = fm_test_graph();
1234 let fm = "---\ncover: \"[[missing.jpg|left]]\"\n---\n";
1235 let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1236 assert_eq!(result.content, "---\ncover: \"missing.jpg\"\n---\n");
1237 assert_eq!(result.diagnostics.len(), 1);
1238 assert_eq!(result.diagnostics[0].reference, "missing.jpg");
1239 }
1240
1241 #[test]
1242 fn test_fm_embed_wikilink_with_fit_and_position() {
1243 // ![[photo.jpg|contain top-right]] — embed syntax preserves both keywords
1244 let graph = fm_test_graph();
1245 let fm = "---\ncover: \"![[photo.jpg|contain top-right]]\"\n---\n";
1246 let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1247 assert_eq!(
1248 result.content,
1249 "---\ncover: \"assets/photo.jpg|contain top-right\"\n---\n"
1250 );
1251 assert!(result.diagnostics.is_empty());
1252 }
1253
1254 // ----- Frontmatter wikilinks are now resolved to paths -----
1255
1256 #[test]
1257 fn test_simplified_frontmatter_wikilink_resolved_to_path() {
1258 let mut b = ContentGraphBuilder::new();
1259 b.add_file("index.md", "index");
1260 b.add_file("news.md", "news");
1261 let graph = b.build();
1262 let files = HashMap::new();
1263
1264 // Simplified frontmatter (no leading ---) with a wikilink in sidebar value.
1265 // Frontmatter wikilinks ARE still resolved here (to a path).
1266 let input = "children: false\nsidebar: \"[[news]]\"\nuid: a48746ca\n---\n\n# Welcome\n\nBody with [[news]] link.";
1267 let result = resolve_content("index.md", input, &graph, &mock_reader(&files));
1268
1269 // Frontmatter wikilink [[news]] resolved to path "news.md", quotes preserved.
1270 assert!(
1271 result
1272 .content_markdown
1273 .starts_with("children: false\nsidebar: \"news.md\"\nuid: a48746ca\n---\n"),
1274 "Frontmatter wikilink not resolved to path: {}",
1275 result.content_markdown
1276 );
1277
1278 // Phase 3 PR2: body wikilink `[[news]]` passes through as raw
1279 // markdown — Stage 2 in src-tauri resolves it via the
1280 // `dispatch_wikilink_embed` arm in `transform_events`.
1281 assert!(
1282 result.content_markdown.contains("[[news]]"),
1283 "Expected body wikilink to pass through verbatim, got: {}",
1284 result.content_markdown
1285 );
1286 }
1287
1288 #[test]
1289 fn test_frontmatter_embed_wikilink_stripped() {
1290 let mut b = ContentGraphBuilder::new();
1291 b.add_file("index.md", "index");
1292 b.add_file("photos/hero.jpg", "hero");
1293 let graph = b.build();
1294 let files = HashMap::new();
1295
1296 // Embed wikilink ![[hero.jpg]] in frontmatter cover — the ! prefix should be consumed.
1297 let input = "cover: \"![[hero.jpg]]\"\n---\n\n# Page";
1298 let result = resolve_content("index.md", input, &graph, &mock_reader(&files));
1299
1300 // Should resolve to path without ! prefix
1301 assert!(
1302 result
1303 .content_markdown
1304 .starts_with("cover: \"photos/hero.jpg\"\n---"),
1305 "Embed wikilink ! prefix not stripped: {}",
1306 result.content_markdown
1307 );
1308 }
1309
1310 #[test]
1311 fn test_frontmatter_embed_wikilink_with_attrs() {
1312 let mut b = ContentGraphBuilder::new();
1313 b.add_file("index.md", "index");
1314 b.add_file("photos/hero.jpg", "hero");
1315 let graph = b.build();
1316 let files = HashMap::new();
1317
1318 // Embed wikilink with display attrs: ![[hero.jpg|cover left]]
1319 let input = "cover: \"![[hero.jpg|cover left]]\"\n---\n\n# Page";
1320 let result = resolve_content("index.md", input, &graph, &mock_reader(&files));
1321
1322 // Should resolve path and preserve attrs
1323 assert!(
1324 result
1325 .content_markdown
1326 .starts_with("cover: \"photos/hero.jpg|cover left\"\n---"),
1327 "Embed wikilink with attrs not resolved correctly: {}",
1328 result.content_markdown
1329 );
1330 }
1331
1332 #[test]
1333 fn standard_markdown_link_passes_through_in_pipeline() {
1334 // Phase 4 PR7a-stage1b (2026-05-28): Stage 1
1335 // `markdown_links::resolve_markdown_links` is deleted. The bare
1336 // markdown link now passes through `resolve_content` verbatim;
1337 // the typed AST visitor
1338 // (`ast/resolve_urls::resolve_link_urls`) emits the
1339 // `moss-resolved:文字/文字.md` sentinel later in
1340 // `process_markdown_file`, and src-tauri's `classify_url_prod`
1341 // decodes the sentinel into the final pretty URL. Visitor
1342 // coverage lives in
1343 // `resolve_urls.rs::tests::standard_markdown_link_emits_sentinel`.
1344 let mut b = ContentGraphBuilder::new();
1345 b.add_file("index.md", "index");
1346 b.add_file("文字/文字.md", "writings");
1347 let graph = b.build();
1348
1349 let files = HashMap::new();
1350 let result = resolve_content(
1351 "index.md",
1352 "[文字](文字.md)\n",
1353 &graph,
1354 &mock_reader(&files),
1355 );
1356
1357 // resolve_content now passes the link through verbatim.
1358 assert!(
1359 result.content_markdown.contains("[文字](文字.md)"),
1360 "expected verbatim pass-through, got: {}",
1361 result.content_markdown
1362 );
1363 }
1364
1365 #[test]
1366 fn test_frontmatter_link_wikilink_alias_discarded() {
1367 let mut b = ContentGraphBuilder::new();
1368 b.add_file("index.md", "index");
1369 b.add_file("photos/hero.jpg", "hero");
1370 let graph = b.build();
1371 let files = HashMap::new();
1372
1373 // Regular wikilink [[hero.jpg|My Hero]] — pipe content is alias, should be discarded
1374 let input = "cover: \"[[hero.jpg|My Hero]]\"\n---\n\n# Page";
1375 let result = resolve_content("index.md", input, &graph, &mock_reader(&files));
1376
1377 // Should resolve path but discard alias (Obsidian convention: pipe = alias in [[...]])
1378 assert!(
1379 result
1380 .content_markdown
1381 .starts_with("cover: \"photos/hero.jpg\"\n---"),
1382 "Link wikilink alias should be discarded, got: {}",
1383 result.content_markdown
1384 );
1385 }
1386}