1use std::collections::BTreeSet;
2
3use comrak::nodes::{AstNode, NodeValue};
4use comrak::Arena;
5
6pub type SlugSet = BTreeSet<String>;
8
9pub 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
18pub 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
42pub struct WikilinkPass {
44 pub resolved: Vec<String>,
46}
47
48fn 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("&"),
55 '<' => out.push_str("<"),
56 '>' => out.push_str(">"),
57 '"' => out.push_str("""),
58 _ => out.push(c),
59 }
60 }
61 out
62}
63
64fn 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
73fn anchorize(text: &str) -> String {
80 comrak::Anchorizer::new().anchorize(text)
81}
82
83fn 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 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 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 }
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
145pub(crate) fn flat_source(node: &AstNode<'_>) -> Option<String> {
150 match &node.data.borrow().value {
151 NodeValue::Text(t) => Some(t.to_string()),
152 NodeValue::HtmlInline(h) => Some(h.clone()),
155 _ => None,
156 }
157}
158
159pub 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 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 let children: Vec<&'a AstNode<'a>> = parent.children().collect();
180
181 let mut i = 0;
184 while i < children.len() {
185 if flat_source(children[i]).is_none() {
186 i += 1;
187 continue;
188 }
189 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 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; }
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 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 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 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 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 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 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 assert!(
395 html.contains(r#"href="/docs/guide/intro#setup-steps""#),
396 "{html}"
397 );
398 assert!(html.contains(">guide/intro#Setup Steps</a>"), "{html}");
400 assert!(
402 html.contains(r#"data-wikilink-path="/guide/intro""#),
403 "{html}"
404 );
405 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 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 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<b>\""));
446 assert!(!html.contains("<b>"));
447 }
448}