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