1use std::collections::BTreeMap;
2
3use comrak::{parse_document, Arena};
4
5use crate::frontmatter::parse_frontmatter;
6use crate::graph::{build_link_graph, LinkGraph};
7use crate::markdown::{comrak_options, format_ast};
8use crate::model::{Doc, RawDoc, SearchEntry};
9use crate::search::plaintext;
10use crate::wikilink::{transform_wikilinks, SlugSet};
11
12pub type Partials = std::collections::BTreeMap<String, String>;
14
15pub type Diagrams = std::collections::BTreeMap<String, String>;
19
20pub fn is_partial_rel(rel_path: &str) -> bool {
24 rel_path
25 .rsplit('/')
26 .next()
27 .map(|name| name.starts_with('_'))
28 .unwrap_or(false)
29}
30
31pub fn partition_partials(raws: Vec<RawDoc>) -> (Vec<RawDoc>, Partials) {
34 let mut pages = Vec::new();
35 let mut partials = Partials::new();
36 for raw in raws {
37 if is_partial_rel(&raw.rel_path) {
38 let body = parse_frontmatter(&raw.raw).body;
39 partials.insert(raw.rel_path, body);
40 } else {
41 pages.push(raw);
42 }
43 }
44 (pages, partials)
45}
46
47pub fn resolve_include_key(base_dir: &str, src: &str) -> Option<String> {
52 let src = src.trim();
53 let combined = if let Some(rest) = src.strip_prefix('/') {
54 rest.to_string()
55 } else if base_dir.is_empty() {
56 src.to_string()
57 } else {
58 format!("{base_dir}/{src}")
59 };
60 let mut parts: Vec<&str> = Vec::new();
61 for seg in combined.split('/') {
62 match seg {
63 "" | "." => continue,
64 ".." => {
65 parts.pop()?;
66 }
67 s => parts.push(s),
68 }
69 }
70 Some(parts.join("/"))
71}
72
73#[derive(Debug, Clone, PartialEq)]
75pub struct PreparedDoc {
76 pub rel_path: String,
77 pub slug: String,
78 pub title: String,
79 pub description: Option<String>,
81 pub body_md: String,
82 pub frontmatter: serde_yml::Value,
85}
86
87pub struct SiteBuild {
89 pub docs: Vec<Doc>,
90 pub graph: LinkGraph,
91 pub outbound: BTreeMap<String, Vec<String>>,
96 pub search: Vec<SearchEntry>,
97 pub any_mermaid: bool,
100 pub any_components: bool,
102}
103
104impl SiteBuild {
105 pub fn graph_data(
109 &self,
110 params: crate::graphlayout::LayoutParams,
111 ) -> crate::graphlayout::GraphData {
112 let meta: Vec<(String, String)> = self
113 .docs
114 .iter()
115 .map(|d| (d.slug.clone(), d.title.clone()))
116 .collect();
117 crate::graphlayout::layout_graph(&meta, &self.graph, params)
118 }
119}
120
121fn first_h1(body: &str) -> Option<String> {
122 body.lines()
123 .find_map(|line| line.strip_prefix("# ").map(|h| h.trim().to_string()))
124}
125
126pub fn prepare(raw: RawDoc) -> PreparedDoc {
128 let parsed = parse_frontmatter(&raw.raw);
129 let slug = raw
130 .rel_path
131 .strip_suffix(".md")
132 .unwrap_or(&raw.rel_path)
133 .to_string();
134
135 let fm_title = parsed
136 .frontmatter
137 .get("title")
138 .and_then(|v| v.as_str())
139 .map(|s| s.to_string());
140 let title = fm_title
141 .or_else(|| first_h1(&parsed.body))
142 .unwrap_or_else(|| slug.rsplit('/').next().unwrap_or("").to_string());
143
144 let description = parsed
145 .frontmatter
146 .get("description")
147 .and_then(|v| v.as_str())
148 .map(|s| s.to_string());
149
150 PreparedDoc {
151 rel_path: raw.rel_path,
152 slug,
153 title,
154 description,
155 body_md: parsed.body,
156 frontmatter: parsed.frontmatter,
157 }
158}
159
160#[allow(clippy::too_many_arguments)]
173pub fn render_block_markdown(
174 md: &str,
175 config: &docgen_config::SiteConfig,
176 registry: &docgen_components::Registry,
177 slugs: &SlugSet,
178 partials: &Partials,
179 base_dir: &str,
180 stack: &[String],
181 asset_urls: Option<&dyn crate::asseturl::AssetUrlResolver>,
182 plantuml: Option<&crate::plantuml::PlantumlSupport>,
183) -> String {
184 let (rewritten, instances) = crate::directivepass::extract(md);
185 let options = comrak_options();
186 let arena = Arena::new();
187 let root = parse_document(&arena, &rewritten, &options);
188 let _pass = transform_wikilinks(root, &arena, slugs, &config.base);
191 crate::assetpass::transform_asset_urls(root, &config.base, base_dir, slugs, asset_urls);
194 if config.features.math {
195 crate::mathpass::transform_math(root);
196 }
197 if config.features.mermaid {
198 crate::mermaidpass::transform_mermaid(root);
199 }
200 crate::tablepass::transform_tables(root, &arena);
203 let inner_html = format_ast(root, &options);
204 let render_inner = |m: &str| {
205 render_block_markdown(
206 m, config, registry, slugs, partials, base_dir, stack, asset_urls, plantuml,
207 )
208 };
209 let resolve_include = |src: &str| {
210 resolve_include_src(
211 src, base_dir, partials, stack, config, registry, slugs, asset_urls, plantuml,
212 )
213 };
214 let render_plantuml = |idx: usize, inst: &crate::directivepass::DirectiveInstance| {
215 crate::plantuml::render_directive(
216 inst,
217 base_dir,
218 plantuml,
219 &format!("docgen-plantuml-{idx}"),
220 )
221 };
222 let (out, _used) = crate::directivepass::substitute(
223 &inner_html,
224 &instances,
225 registry,
226 &render_inner,
227 &resolve_include,
228 &render_plantuml,
229 );
230 out
231}
232
233#[allow(clippy::too_many_arguments)]
238fn resolve_include_src(
239 src: &str,
240 base_dir: &str,
241 partials: &Partials,
242 stack: &[String],
243 config: &docgen_config::SiteConfig,
244 registry: &docgen_components::Registry,
245 slugs: &SlugSet,
246 asset_urls: Option<&dyn crate::asseturl::AssetUrlResolver>,
247 plantuml: Option<&crate::plantuml::PlantumlSupport>,
248) -> String {
249 let key = match resolve_include_key(base_dir, src) {
250 Some(k) => k,
251 None => return crate::directivepass::error_span("include", "src escapes docs root"),
252 };
253 if stack.iter().any(|s| s == &key) {
254 return crate::directivepass::error_span("include", "include cycle");
255 }
256 let Some(body) = partials.get(&key) else {
257 return crate::directivepass::error_span("include", "missing `src`");
258 };
259 let mut next = stack.to_vec();
260 next.push(key.clone());
261 let child_dir = key.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
262 render_block_markdown(
263 body, config, registry, slugs, partials, child_dir, &next, asset_urls, plantuml,
264 )
265}
266
267pub struct RenderedDoc {
272 pub doc: Doc,
273 pub search_text: String,
275 pub resolved_links: Vec<String>,
277}
278
279#[allow(clippy::too_many_arguments)]
289pub fn render_doc(
290 p: &PreparedDoc,
291 config: &docgen_config::SiteConfig,
292 registry: &docgen_components::Registry,
293 slugs: &SlugSet,
294 partials: &Partials,
295 asset_urls: Option<&dyn crate::asseturl::AssetUrlResolver>,
296 plantuml: Option<&crate::plantuml::PlantumlSupport>,
297 bases: Option<&docgen_bases::Corpus>,
298) -> RenderedDoc {
299 let options = comrak_options();
300
301 let (rewritten, instances) = crate::directivepass::extract(&p.body_md);
304
305 let arena = Arena::new();
308 let root = parse_document(&arena, &rewritten, &options);
309
310 let search_text = plaintext(root);
311
312 let headings = crate::headings::collect_headings(root);
316
317 let pass = transform_wikilinks(root, &arena, slugs, &config.base);
319 let resolved_links = pass.resolved;
320 let source_dir = p.rel_path.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
324 crate::assetpass::transform_asset_urls(root, &config.base, source_dir, slugs, asset_urls);
325 let math_count = if config.features.math {
327 crate::mathpass::transform_math(root)
328 } else {
329 0
330 };
331 let mermaid_count = if config.features.mermaid {
333 crate::mermaidpass::transform_mermaid(root)
334 } else {
335 0
336 };
337 if config.features.bases {
341 if let Some(corpus) = bases {
342 crate::basepass::transform_bases(root, corpus, &config.base);
343 }
344 }
345 crate::tablepass::transform_tables(root, &arena);
348 let formatted = format_ast(root, &options);
349 let formatted = crate::headings::stamp_heading_ids(&formatted, &headings);
352
353 let base_dir = p.rel_path.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
357 let stack: Vec<String> = Vec::new();
358 let render_inner = |m: &str| {
359 render_block_markdown(
360 m, config, registry, slugs, partials, base_dir, &stack, asset_urls, plantuml,
361 )
362 };
363 let resolve_include = |src: &str| {
364 resolve_include_src(
365 src, base_dir, partials, &stack, config, registry, slugs, asset_urls, plantuml,
366 )
367 };
368 let render_plantuml = |idx: usize, inst: &crate::directivepass::DirectiveInstance| {
369 crate::plantuml::render_directive(
370 inst,
371 base_dir,
372 plantuml,
373 &format!("docgen-plantuml-{idx}"),
374 )
375 };
376 let (body_html, used) = crate::directivepass::substitute(
377 &formatted,
378 &instances,
379 registry,
380 &render_inner,
381 &resolve_include,
382 &render_plantuml,
383 );
384
385 RenderedDoc {
386 doc: Doc {
387 rel_path: p.rel_path.clone(),
388 slug: p.slug.clone(),
389 title: p.title.clone(),
390 description: p.description.clone(),
391 body_html,
392 has_math: math_count > 0,
393 has_mermaid: mermaid_count > 0,
394 components_used: used,
395 headings,
396 },
397 search_text,
398 resolved_links,
399 }
400}
401
402#[allow(clippy::too_many_arguments)]
405pub fn render_docs(
406 prepared: Vec<PreparedDoc>,
407 partials: &Partials,
408 config: &docgen_config::SiteConfig,
409 registry: &docgen_components::Registry,
410 asset_urls: Option<&dyn crate::asseturl::AssetUrlResolver>,
411 plantuml: Option<&crate::plantuml::PlantumlSupport>,
412 bases: Option<&docgen_bases::Corpus>,
413) -> SiteBuild {
414 let slugs: SlugSet = prepared.iter().map(|p| p.slug.clone()).collect();
415 let doc_meta: Vec<(String, String, Option<String>)> = prepared
416 .iter()
417 .map(|p| (p.slug.clone(), p.title.clone(), p.description.clone()))
418 .collect();
419
420 let mut docs = Vec::with_capacity(prepared.len());
421 let mut outbound: BTreeMap<String, Vec<String>> = BTreeMap::new();
422 let mut search = Vec::with_capacity(prepared.len());
423
424 for p in &prepared {
425 let rendered = render_doc(
427 p, config, registry, &slugs, partials, asset_urls, plantuml, bases,
428 );
429 search.push(SearchEntry {
430 slug: p.slug.clone(),
431 title: p.title.clone(),
432 text: rendered.search_text,
433 });
434 outbound.insert(p.slug.clone(), rendered.resolved_links);
435 docs.push(rendered.doc);
436 }
437
438 let graph = build_link_graph(&doc_meta, &outbound);
439 let any_mermaid = docs.iter().any(|d| d.has_mermaid);
440 let any_components = docs.iter().any(|d| !d.components_used.is_empty());
441 SiteBuild {
442 docs,
443 graph,
444 outbound,
445 search,
446 any_mermaid,
447 any_components,
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use crate::model::RawDoc;
455
456 fn raw(path: &str, body: &str) -> RawDoc {
457 RawDoc {
458 rel_path: path.into(),
459 raw: body.into(),
460 }
461 }
462
463 #[test]
464 fn is_partial_rel_detects_underscore_basename() {
465 assert!(is_partial_rel("dev/server/_systems.gen.md"));
466 assert!(is_partial_rel("_root.md"));
467 assert!(!is_partial_rel("dev/server/index.md"));
468 assert!(!is_partial_rel("dev/_dir/page.md")); }
470
471 #[test]
472 fn partition_partials_splits_pages_and_strips_frontmatter() {
473 let raws = vec![
474 raw("a/index.md", "# Page\n"),
475 raw("a/_inc.md", "---\ntitle: x\n---\n## Inc\n"),
476 ];
477 let (pages, partials) = partition_partials(raws);
478 assert_eq!(pages.len(), 1);
479 assert_eq!(pages[0].rel_path, "a/index.md");
480 assert_eq!(
481 partials.get("a/_inc.md").map(String::as_str),
482 Some("## Inc\n")
483 );
484 }
485
486 #[test]
487 fn resolve_include_key_normalizes_relative_and_absolute() {
488 assert_eq!(
489 resolve_include_key("dev/server", "./_s.gen.md").as_deref(),
490 Some("dev/server/_s.gen.md")
491 );
492 assert_eq!(
493 resolve_include_key("dev/server", "../_top.md").as_deref(),
494 Some("dev/_top.md")
495 );
496 assert_eq!(
497 resolve_include_key("dev/server", "/root/_x.md").as_deref(),
498 Some("root/_x.md")
499 );
500 assert_eq!(resolve_include_key("", "_x.md").as_deref(), Some("_x.md"));
501 assert_eq!(resolve_include_key("dev", "../../escape.md"), None); }
503
504 #[test]
505 fn prepare_keeps_raw_body_and_derives_meta() {
506 let p = prepare(raw(
507 "guide/intro.md",
508 "---\ntitle: Intro\n---\n# H\nbody [[index]]\n",
509 ));
510 assert_eq!(p.slug, "guide/intro");
511 assert_eq!(p.title, "Intro");
512 assert!(p.body_md.contains("[[index]]"));
513 assert!(!p.body_md.contains("title:")); }
515
516 #[test]
517 fn render_doc_matches_render_docs_for_one_doc() {
518 let prepared = vec![
521 prepare(raw("index.md", "# Home\nGo to [[guide/intro]].\n")),
522 prepare(raw(
523 "guide/intro.md",
524 "# Intro\n```rust\nfn x(){}\n```\nBack to [[index]] and [[ghost]].\n",
525 )),
526 ];
527 let slugs: SlugSet = prepared.iter().map(|p| p.slug.clone()).collect();
528 let cfg = docgen_config::SiteConfig::default();
529 let reg = docgen_components::Registry::empty();
530
531 let site = render_docs(
532 prepared.clone(),
533 &Partials::new(),
534 &cfg,
535 ®,
536 None,
537 None,
538 None,
539 );
540 let single = render_doc(
541 &prepared[1],
542 &cfg,
543 ®,
544 &slugs,
545 &Partials::new(),
546 None,
547 None,
548 None,
549 );
550
551 assert_eq!(single.doc.body_html, site.docs[1].body_html);
552 assert_eq!(single.doc.has_mermaid, site.docs[1].has_mermaid);
553 assert_eq!(single.doc.has_math, site.docs[1].has_math);
554 assert_eq!(single.doc.headings, site.docs[1].headings);
555 assert_eq!(single.search_text, site.search[1].text);
556 assert!(single.resolved_links.contains(&"index".to_string()));
558 assert!(!single.resolved_links.contains(&"ghost".to_string()));
559 }
560
561 #[test]
562 fn render_docs_resolves_links_highlights_and_indexes() {
563 let prepared = vec![
564 prepare(raw("index.md", "# Home\nGo to [[guide/intro]].\n")),
565 prepare(raw(
566 "guide/intro.md",
567 "# Intro\n```rust\nfn x(){}\n```\nBack to [[index]] and [[ghost]].\n",
568 )),
569 ];
570 let site = render_docs(
571 prepared,
572 &Partials::new(),
573 &docgen_config::SiteConfig::default(),
574 &docgen_components::Registry::empty(),
575 None,
576 None,
577 None,
578 );
579
580 assert_eq!(site.docs[0].slug, "index");
582 assert_eq!(site.docs[1].slug, "guide/intro");
583
584 assert!(site.docs[0].body_html.contains(r#"href="/guide/intro""#));
586 assert!(site.docs[1]
588 .body_html
589 .contains(r#"<pre class="docgen-code">"#));
590 assert!(site.docs[1].body_html.contains(r#"href="/index""#));
591 assert!(site.docs[1].body_html.contains("docgen-wikilink--broken"));
592
593 assert!(site
595 .graph
596 .edges
597 .iter()
598 .any(|e| e.from == "index" && e.to == "guide/intro"));
599 assert!(site
600 .graph
601 .edges
602 .iter()
603 .any(|e| e.from == "guide/intro" && e.to == "index"));
604 assert!(!site.graph.edges.iter().any(|e| e.to == "ghost"));
605
606 assert_eq!(
608 site.graph.backlinks.get("index").unwrap()[0].slug,
609 "guide/intro"
610 );
611
612 assert_eq!(site.search.len(), 2);
614 let home = site.search.iter().find(|e| e.slug == "index").unwrap();
615 assert_eq!(home.title, "Home");
616 assert!(home.text.contains("Go to"));
617 assert!(!home.text.contains("[["));
618 }
619
620 #[test]
621 fn render_docs_renders_math_at_build_time() {
622 let prepared = vec![prepare(raw("m.md", "# M\nmass: $E=mc^2$\n"))];
623 let site = render_docs(
624 prepared,
625 &Partials::new(),
626 &docgen_config::SiteConfig::default(),
627 &docgen_components::Registry::empty(),
628 None,
629 None,
630 None,
631 );
632 assert!(site.docs[0].body_html.contains("katex"));
633 assert!(site.docs[0].has_math);
634 assert!(!site.docs[0].body_html.contains("$E=mc^2$"));
635 }
636
637 #[test]
638 fn math_feature_off_skips_build_time_katex() {
639 let prepared = vec![prepare(raw("m.md", "# M\n$E=mc^2$\n"))];
640 let mut cfg = docgen_config::SiteConfig::default();
641 cfg.features.math = false;
642 let site = render_docs(
643 prepared,
644 &Partials::new(),
645 &cfg,
646 &docgen_components::Registry::empty(),
647 None,
648 None,
649 None,
650 );
651 assert!(!site.docs[0].has_math);
652 assert!(!site.docs[0].body_html.contains("katex"));
653 }
654
655 #[test]
656 fn mermaid_feature_off_leaves_code_block() {
657 let prepared = vec![prepare(raw(
658 "d.md",
659 "# D\n```mermaid\ngraph TD;A-->B;\n```\n",
660 ))];
661 let mut cfg = docgen_config::SiteConfig::default();
662 cfg.features.mermaid = false;
663 let site = render_docs(
664 prepared,
665 &Partials::new(),
666 &cfg,
667 &docgen_components::Registry::empty(),
668 None,
669 None,
670 None,
671 );
672 assert!(!site.docs[0].has_mermaid);
673 assert!(!site.any_mermaid);
674 }
675
676 #[test]
677 fn render_docs_marks_mermaid_pages_and_site() {
678 let prepared = vec![
679 prepare(raw("d.md", "# D\n```mermaid\ngraph TD;A-->B;\n```\n")),
680 prepare(raw("p.md", "# P\nplain\n")),
681 ];
682 let site = render_docs(
683 prepared,
684 &Partials::new(),
685 &docgen_config::SiteConfig::default(),
686 &docgen_components::Registry::empty(),
687 None,
688 None,
689 None,
690 );
691 assert!(site.docs[0].has_mermaid && site.docs[0].body_html.contains("docgen-mermaid"));
692 assert!(!site.docs[1].has_mermaid);
693 assert!(site.any_mermaid);
694 }
695
696 #[test]
697 fn site_graph_data_matches_docs_and_links() {
698 let prepared = vec![
699 prepare(raw("index.md", "# Home\nGo to [[guide/intro]].\n")),
700 prepare(raw("guide/intro.md", "# Intro\nBack to [[index]].\n")),
701 ];
702 let site = render_docs(
703 prepared,
704 &Partials::new(),
705 &docgen_config::SiteConfig::default(),
706 &docgen_components::Registry::empty(),
707 None,
708 None,
709 None,
710 );
711 let gd = site.graph_data(crate::graphlayout::LayoutParams::default());
712 assert_eq!(gd.nodes.len(), 2);
713 assert!(gd
714 .nodes
715 .iter()
716 .any(|n| n.slug == "index" && n.title == "Home"));
717 assert!(gd
718 .nodes
719 .iter()
720 .any(|n| n.slug == "guide/intro" && n.title == "Intro"));
721 let is_pair = |e: &crate::graphlayout::GraphDataEdge| {
723 (e.from == "index" && e.to == "guide/intro")
724 || (e.from == "guide/intro" && e.to == "index")
725 };
726 assert_eq!(gd.edges.iter().filter(|e| is_pair(e)).count(), 1);
727 assert_eq!(gd.edges.len(), 1);
728 }
729
730 #[test]
731 fn render_docs_without_mermaid_clears_site_flag() {
732 let prepared = vec![prepare(raw("p.md", "# P\nplain\n"))];
733 let site = render_docs(
734 prepared,
735 &Partials::new(),
736 &docgen_config::SiteConfig::default(),
737 &docgen_components::Registry::empty(),
738 None,
739 None,
740 None,
741 );
742 assert!(!site.any_mermaid);
743 }
744
745 #[test]
746 fn render_docs_renders_callout_directive_with_inner_markdown() {
747 let mut reg = docgen_components::Registry::empty();
748 reg.insert(docgen_components::Component::from_parts(
749 "callout",
750 "<aside class=\"docgen-callout--{{ attrs.type | default('note') }}\">{{ content | safe }}</aside>",
751 None,
752 None,
753 ));
754 let prepared = vec![prepare(raw(
755 "d.md",
756 "# D\n\n:::callout{type=warning}\nBe **careful**.\n:::\n",
757 ))];
758 let site = render_docs(
759 prepared,
760 &Partials::new(),
761 &docgen_config::SiteConfig::default(),
762 ®,
763 None,
764 None,
765 None,
766 );
767 let h = &site.docs[0].body_html;
768 assert!(h.contains("docgen-callout--warning"));
769 assert!(h.contains("<strong>careful</strong>")); assert!(site.docs[0].components_used.contains("callout"));
771 assert!(site.any_components);
772 }
773
774 #[test]
775 fn unknown_directive_in_doc_yields_error_span_not_crash() {
776 let prepared = vec![prepare(raw("d.md", "# D\n\n:nope[x]{}\n"))];
777 let site = render_docs(
778 prepared,
779 &Partials::new(),
780 &docgen_config::SiteConfig::default(),
781 &docgen_components::Registry::empty(),
782 None,
783 None,
784 None,
785 );
786 assert!(site.docs[0].body_html.contains("docgen-directive-error"));
787 assert!(!site.any_components);
788 }
789
790 #[test]
791 fn wikilink_outside_directive_still_resolves() {
792 let mut reg = docgen_components::Registry::empty();
793 reg.insert(docgen_components::Component::from_parts(
794 "callout",
795 "<aside>{{ content | safe }}</aside>",
796 None,
797 None,
798 ));
799 let prepared = vec![
800 prepare(raw(
801 "index.md",
802 "# Home\nSee [[guide]].\n\n:::callout{}\nx\n:::\n",
803 )),
804 prepare(raw("guide.md", "# Guide\n")),
805 ];
806 let site = render_docs(
807 prepared,
808 &Partials::new(),
809 &docgen_config::SiteConfig::default(),
810 ®,
811 None,
812 None,
813 None,
814 );
815 assert!(site.docs[0].body_html.contains(r#"href="/guide""#));
816 }
817
818 #[test]
819 fn wikilink_inside_directive_body_resolves_to_anchor() {
820 let mut reg = docgen_components::Registry::empty();
821 reg.insert(docgen_components::Component::from_parts(
822 "callout",
823 "<aside>{{ content | safe }}</aside>",
824 None,
825 None,
826 ));
827 let prepared = vec![
828 prepare(raw(
829 "index.md",
830 "# Home\n\n:::callout{}\nSee [[guide/intro|wikilink]] and [[ghost]].\n:::\n",
831 )),
832 prepare(raw("guide/intro.md", "# Intro\n")),
833 ];
834 let site = render_docs(
835 prepared,
836 &Partials::new(),
837 &docgen_config::SiteConfig::default(),
838 ®,
839 None,
840 None,
841 None,
842 );
843 let h = &site.docs[0].body_html;
844 assert!(h.contains(r#"href="/guide/intro""#));
847 assert!(h.contains(r#">wikilink</a>"#));
848 assert!(!h.contains("[[guide/intro|wikilink]]"));
849 assert!(h.contains("docgen-wikilink--broken"));
851 assert!(!h.contains("[[ghost]]"));
852 }
853
854 #[test]
855 fn self_link_renders_anchor_but_no_self_backlink() {
856 let prepared = vec![prepare(raw("index.md", "# Home\nBack to [[index]].\n"))];
859 let site = render_docs(
860 prepared,
861 &Partials::new(),
862 &docgen_config::SiteConfig::default(),
863 &docgen_components::Registry::empty(),
864 None,
865 None,
866 None,
867 );
868
869 assert!(site.docs[0].body_html.contains(r#"href="/index""#));
870 assert!(!site
871 .graph
872 .edges
873 .iter()
874 .any(|e| e.from == "index" && e.to == "index"));
875 assert!(!site.graph.backlinks.contains_key("index"));
876 }
877}