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