Skip to main content

docgen_core/
wikilink.rs

1use std::collections::BTreeSet;
2
3use comrak::nodes::{AstNode, NodeValue};
4use comrak::Arena;
5
6/// The set of all known slugs, used to resolve wikilink targets.
7pub type SlugSet = BTreeSet<String>;
8
9/// Split a `[[...]]` inner string into `(target, Some(label))` or `(target, None)`.
10/// Splits on the FIRST `|` only; the remainder is the label.
11pub fn parse_wikilink(inner: &str) -> (String, Option<String>) {
12    match inner.split_once('|') {
13        Some((t, label)) => (t.trim().to_string(), Some(label.trim().to_string())),
14        None => (inner.trim().to_string(), None),
15    }
16}
17
18/// Resolve a wikilink target to a slug.
19/// Order: trimmed-exact slug match, then case-insensitive basename match
20/// (basename = last `/`-segment of a slug). First basename match wins by
21/// `SlugSet` (BTreeSet) order, making resolution deterministic.
22pub fn resolve_target(target: &str, slugs: &SlugSet) -> Option<String> {
23    let t = target.trim();
24    if t.is_empty() {
25        return None;
26    }
27    if slugs.contains(t) {
28        return Some(t.to_string());
29    }
30    let needle = t.to_ascii_lowercase();
31    slugs
32        .iter()
33        .find(|slug| {
34            slug.rsplit('/')
35                .next()
36                .unwrap_or(slug)
37                .eq_ignore_ascii_case(&needle)
38        })
39        .cloned()
40}
41
42/// Outcome of transforming one document's AST.
43pub struct WikilinkPass {
44    /// Target slugs this doc links to, deduped, in first-seen document order.
45    pub resolved: Vec<String>,
46}
47
48/// Minimal HTML-attribute / text escaper for the small strings we inject.
49/// Single-pass into one allocation (avoids the 4-string `replace` chain).
50fn esc(s: &str) -> String {
51    let mut out = String::with_capacity(s.len());
52    for c in s.chars() {
53        match c {
54            '&' => out.push_str("&amp;"),
55            '<' => out.push_str("&lt;"),
56            '>' => out.push_str("&gt;"),
57            '"' => out.push_str("&quot;"),
58            _ => out.push(c),
59        }
60    }
61    out
62}
63
64/// The display text for a wikilink: the label if present and non-blank, else the
65/// target. An empty/whitespace-only label is treated as absent so we never emit an
66/// anchor with invisible text. Mirrors `search::push_unwrapping_wikilinks`.
67fn display_text(target: &str, label: Option<String>) -> String {
68    label
69        .filter(|l| !l.trim().is_empty())
70        .unwrap_or_else(|| target.to_string())
71}
72
73/// Anchorize a single heading text into the base id the rendered page carries.
74/// Uses comrak's [`Anchorizer`] — the same scheme `headings::collect_headings`
75/// / `stamp_heading_ids` use — so `[[page#Heading]]` fragments match the ids
76/// stamped onto `<h2>`/`<h3>` tags. A fresh anchorizer per string means no
77/// `-1`/`-2` dedup context: an anchor always targets the FIRST heading with
78/// that text (write `[[page#heading-1]]` to hit a duplicate explicitly).
79fn anchorize(text: &str) -> String {
80    comrak::Anchorizer::new().anchorize(text)
81}
82
83/// Build the inline HTML for one wikilink occurrence and, if resolved, push its
84/// target slug into `resolved` (deduped, first-seen order).
85///
86/// `[[page#anchor]]` resolves the page part and links to `{base}/{slug}#{id}`;
87/// `[[#anchor]]` (empty page part) is a same-page fragment link. The display
88/// text stays the ORIGINAL target string, anchor included (Obsidian-style
89/// `page#anchor`), unless a label overrides it.
90fn render_link(
91    inner: &str,
92    slugs: &SlugSet,
93    base: &str,
94    resolved: &mut Vec<String>,
95    seen: &mut BTreeSet<String>,
96) -> String {
97    let (target, label) = parse_wikilink(inner);
98    // Split the optional `#anchor` off the page part. A blank anchor (`[[x#]]`)
99    // is treated as absent.
100    let (page, anchor) = match target.split_once('#') {
101        Some((p, a)) => (
102            p.trim().to_string(),
103            Some(a.trim()).filter(|a| !a.is_empty()),
104        ),
105        None => (target.clone(), None),
106    };
107    let text = display_text(&target, label);
108
109    // `[[#heading]]`: same-page anchor link — no page to resolve.
110    if page.is_empty() {
111        if let Some(a) = anchor {
112            return format!(
113                r##"<a class="docgen-wikilink" href="#{}">{}</a>"##,
114                esc(&anchorize(a)),
115                esc(&text)
116            );
117        }
118        // Empty target with no anchor falls through to the broken span.
119    }
120
121    match resolve_target(&page, slugs) {
122        Some(slug) => {
123            if seen.insert(slug.clone()) {
124                resolved.push(slug.clone());
125            }
126            let fragment = anchor
127                .map(|a| format!("#{}", esc(&anchorize(a))))
128                .unwrap_or_default();
129            format!(
130                r#"<a class="docgen-wikilink" href="{0}/{1}{2}" data-wikilink-title="{3}" data-wikilink-path="/{1}">{3}</a>"#,
131                base,
132                esc(&slug),
133                fragment,
134                esc(&text)
135            )
136        }
137        None => format!(
138            r#"<span class="docgen-wikilink docgen-wikilink--broken" data-target="{}">{}</span>"#,
139            esc(&target),
140            esc(&text)
141        ),
142    }
143}
144
145/// Walk the AST; for each Text node containing `[[...]]`, split it into
146/// surrounding Text nodes + raw-HTML inline nodes for each wikilink.
147/// The flat source text a child node contributes when reconstructing an inline
148/// run, or `None` if the node breaks the run (it is not foldable into text).
149pub(crate) fn flat_source(node: &AstNode<'_>) -> Option<String> {
150    match &node.data.borrow().value {
151        NodeValue::Text(t) => Some(t.to_string()),
152        // Raw inline HTML inside `[[ ... ]]` is folded back into the target string
153        // (e.g. `[[a<b>]]`), so the resolver/escaper sees the literal `a<b>`.
154        NodeValue::HtmlInline(h) => Some(h.clone()),
155        _ => None,
156    }
157}
158
159/// `base` is the deployed sub-path prefix (e.g. `/docs`); `""` for root
160/// deployment. Resolved-wikilink hrefs are emitted as `{base}/{slug}`.
161pub fn transform_wikilinks<'a>(
162    root: &'a AstNode<'a>,
163    arena: &'a Arena<'a>,
164    slugs: &SlugSet,
165    base: &str,
166) -> WikilinkPass {
167    let mut resolved: Vec<String> = Vec::new();
168    let mut seen: BTreeSet<String> = BTreeSet::new();
169
170    // Collect every node that has children, so we can scan their direct child
171    // runs. We snapshot the list first to avoid iterating while mutating.
172    let parents: Vec<&'a AstNode<'a>> = root
173        .descendants()
174        .filter(|n| n.first_child().is_some())
175        .collect();
176
177    for parent in parents {
178        // Snapshot direct children.
179        let children: Vec<&'a AstNode<'a>> = parent.children().collect();
180
181        // Walk maximal runs of foldable (Text/HtmlInline) children, rebuild any
182        // run that contains a complete `[[...]]`.
183        let mut i = 0;
184        while i < children.len() {
185            if flat_source(children[i]).is_none() {
186                i += 1;
187                continue;
188            }
189            // Extend the foldable run.
190            let start = i;
191            let mut combined = String::new();
192            while i < children.len() {
193                match flat_source(children[i]) {
194                    Some(s) => {
195                        combined.push_str(&s);
196                        i += 1;
197                    }
198                    None => break,
199                }
200            }
201
202            if !combined.contains("[[") {
203                continue;
204            }
205
206            // Build replacement nodes from the combined run, inserted before the
207            // first node of the run; then detach the whole run.
208            let anchor = children[start];
209            let mut rest = combined.as_str();
210            let mut produced_any = false;
211            while let Some(open) = rest.find("[[") {
212                if let Some(close_rel) = rest[open + 2..].find("]]") {
213                    let close = open + 2 + close_rel;
214                    let before = &rest[..open];
215                    let inner = &rest[open + 2..close];
216
217                    if !before.is_empty() {
218                        let n =
219                            arena.alloc(AstNode::from(NodeValue::Text(before.to_string().into())));
220                        anchor.insert_before(n);
221                    }
222                    let html = render_link(inner, slugs, base, &mut resolved, &mut seen);
223                    let n = arena.alloc(AstNode::from(NodeValue::HtmlInline(html)));
224                    anchor.insert_before(n);
225
226                    rest = &rest[close + 2..];
227                    produced_any = true;
228                } else {
229                    break; // unterminated `[[` — leave the remainder literal
230                }
231            }
232
233            if produced_any {
234                if !rest.is_empty() {
235                    let n = arena.alloc(AstNode::from(NodeValue::Text(rest.to_string().into())));
236                    anchor.insert_before(n);
237                }
238                for node in &children[start..i] {
239                    node.detach();
240                }
241            }
242        }
243    }
244
245    WikilinkPass { resolved }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::markdown::comrak_options;
252    use comrak::{parse_document, Arena};
253
254    fn slugs() -> SlugSet {
255        ["index", "guide/intro", "guide/Advanced", "reference/api"]
256            .iter()
257            .map(|s| s.to_string())
258            .collect()
259    }
260
261    #[test]
262    fn resolves_exact_slug() {
263        assert_eq!(
264            resolve_target("guide/intro", &slugs()),
265            Some("guide/intro".to_string())
266        );
267    }
268
269    #[test]
270    fn resolves_basename_case_insensitive() {
271        // "advanced" matches the basename of "guide/Advanced".
272        assert_eq!(
273            resolve_target("advanced", &slugs()),
274            Some("guide/Advanced".to_string())
275        );
276        assert_eq!(
277            resolve_target("INTRO", &slugs()),
278            Some("guide/intro".to_string())
279        );
280    }
281
282    #[test]
283    fn trims_surrounding_whitespace() {
284        assert_eq!(
285            resolve_target("  index  ", &slugs()),
286            Some("index".to_string())
287        );
288    }
289
290    #[test]
291    fn unresolved_returns_none() {
292        assert_eq!(resolve_target("does/not/exist", &slugs()), None);
293        assert_eq!(resolve_target("", &slugs()), None);
294    }
295
296    #[test]
297    fn parse_splits_label() {
298        assert_eq!(
299            parse_wikilink("target|Label"),
300            ("target".to_string(), Some("Label".to_string()))
301        );
302        assert_eq!(parse_wikilink("target"), ("target".to_string(), None));
303        // Only the first pipe splits; extra pipes belong to the label.
304        assert_eq!(
305            parse_wikilink("a|b|c"),
306            ("a".to_string(), Some("b|c".to_string()))
307        );
308    }
309
310    fn render(md: &str, slugs: &SlugSet) -> (String, Vec<String>) {
311        render_with_base(md, slugs, "")
312    }
313
314    fn render_with_base(md: &str, slugs: &SlugSet, base: &str) -> (String, Vec<String>) {
315        let arena = Arena::new();
316        let options = comrak_options();
317        let root = parse_document(&arena, md, &options);
318        let pass = transform_wikilinks(root, &arena, slugs, base);
319        let html = crate::markdown::format_ast(root, &options);
320        (html, pass.resolved)
321    }
322
323    #[test]
324    fn resolved_wikilink_href_is_prefixed_with_base() {
325        let (html, _) = render_with_base("[[guide/intro]]\n", &slugs(), "/docs");
326        assert!(html.contains(r#"href="/docs/guide/intro""#));
327        assert!(!html.contains(r#"href="/guide/intro""#));
328    }
329
330    #[test]
331    fn resolved_wikilink_becomes_anchor() {
332        let (html, resolved) = render("see [[guide/intro]] now\n", &slugs());
333        assert!(html.contains(
334            r#"<a class="docgen-wikilink" href="/guide/intro" data-wikilink-title="guide/intro" data-wikilink-path="/guide/intro">guide/intro</a>"#
335        ));
336        assert_eq!(resolved, vec!["guide/intro".to_string()]);
337    }
338
339    #[test]
340    fn labeled_wikilink_uses_label_text() {
341        let (html, _) = render("[[guide/intro|The Intro]]\n", &slugs());
342        assert!(html.contains(r#"href="/guide/intro""#));
343        assert!(html.contains(r#"data-wikilink-title="The Intro""#));
344        assert!(html.contains(r#">The Intro</a>"#));
345    }
346
347    #[test]
348    fn broken_wikilink_becomes_marked_span() {
349        let (html, resolved) = render("[[nope]] here\n", &slugs());
350        assert!(html.contains(
351            r#"<span class="docgen-wikilink docgen-wikilink--broken" data-target="nope">nope</span>"#
352        ));
353        assert!(resolved.is_empty());
354    }
355
356    #[test]
357    fn resolved_targets_are_deduped_in_order() {
358        let (_html, resolved) = render("[[guide/intro]] and [[index]] and [[intro]]\n", &slugs());
359        // "intro" resolves to guide/intro (already present) -> deduped.
360        assert_eq!(
361            resolved,
362            vec!["guide/intro".to_string(), "index".to_string()]
363        );
364    }
365
366    #[test]
367    fn empty_or_whitespace_label_falls_back_to_target() {
368        // `[[index|]]` and `[[index|   ]]` must not render an empty clickable text;
369        // they fall back to the target, matching the search-index unwrap path.
370        let (html, _) = render("[[index|]]\n", &slugs());
371        assert!(html.contains(r#"href="/index""#));
372        assert!(html.contains(r#">index</a>"#));
373        assert!(!html.contains(r#">      </a>"#));
374
375        let (html, _) = render("[[index|   ]]\n", &slugs());
376        assert!(html.contains(r#">index</a>"#));
377
378        // Broken target with empty label also falls back to the target text.
379        let (html, _) = render("[[nope|]] x\n", &slugs());
380        assert!(html.contains(r#"data-target="nope">nope</span>"#));
381    }
382
383    #[test]
384    fn ambiguous_basename_resolves_deterministically() {
385        // Two slugs share the basename `dup`; resolution is first by BTreeSet order.
386        let amb: SlugSet = ["a/dup", "b/dup"].iter().map(|s| s.to_string()).collect();
387        assert_eq!(resolve_target("dup", &amb), Some("a/dup".to_string()));
388    }
389
390    #[test]
391    fn anchored_wikilink_resolves_page_and_appends_anchor_id() {
392        let (html, resolved) = render_with_base("[[guide/intro#Setup Steps]]\n", &slugs(), "/docs");
393        // Page resolves; anchor is anchorized like heading ids.
394        assert!(
395            html.contains(r#"href="/docs/guide/intro#setup-steps""#),
396            "{html}"
397        );
398        // Display text keeps the original target INCLUDING the anchor part.
399        assert!(html.contains(">guide/intro#Setup Steps</a>"), "{html}");
400        // The preview path is the page (no fragment).
401        assert!(
402            html.contains(r#"data-wikilink-path="/guide/intro""#),
403            "{html}"
404        );
405        // Anchored links count as real links to the page (graph/backlinks).
406        assert_eq!(resolved, vec!["guide/intro".to_string()]);
407    }
408
409    #[test]
410    fn anchored_wikilink_label_overrides_display_text() {
411        let (html, _) = render("[[guide/intro#setup|Setup]]\n", &slugs());
412        assert!(html.contains(r#"href="/guide/intro#setup""#), "{html}");
413        assert!(html.contains(">Setup</a>"), "{html}");
414    }
415
416    #[test]
417    fn anchor_only_wikilink_is_a_same_page_fragment_link() {
418        let (html, resolved) = render("[[#My Heading]]\n", &slugs());
419        assert!(
420            html.contains(r##"<a class="docgen-wikilink" href="#my-heading">#My Heading</a>"##),
421            "{html}"
422        );
423        // A same-page link is not an outbound edge.
424        assert!(resolved.is_empty());
425    }
426
427    #[test]
428    fn broken_page_with_anchor_stays_a_broken_span() {
429        let (html, resolved) = render("[[nope#section]]\n", &slugs());
430        assert!(
431            html.contains(r#"data-target="nope#section">nope#section</span>"#),
432            "{html}"
433        );
434        assert!(resolved.is_empty());
435
436        // A blank anchor on a real page is ignored (plain page link).
437        let (html, _) = render("[[index#]]\n", &slugs());
438        assert!(html.contains(r#"href="/index""#), "{html}");
439        assert!(!html.contains(r##"href="/index#""##), "{html}");
440    }
441
442    #[test]
443    fn html_special_chars_in_broken_target_are_escaped() {
444        let (html, _) = render("[[a<b>]] x\n", &slugs());
445        assert!(html.contains("data-target=\"a&lt;b&gt;\""));
446        assert!(!html.contains("<b>"));
447    }
448}