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
121pub fn first_h1(body: &str) -> Option<String> {
126 body.lines()
127 .find_map(|line| line.strip_prefix("# ").map(|h| h.trim().to_string()))
128}
129
130pub fn prepare(raw: RawDoc) -> PreparedDoc {
132 let parsed = parse_frontmatter(&raw.raw);
133 let slug = raw
134 .rel_path
135 .strip_suffix(".md")
136 .unwrap_or(&raw.rel_path)
137 .to_string();
138
139 let fm_title = parsed
140 .frontmatter
141 .get("title")
142 .and_then(|v| v.as_str())
143 .map(|s| s.to_string());
144 let title = fm_title
145 .or_else(|| first_h1(&parsed.body))
146 .unwrap_or_else(|| slug.rsplit('/').next().unwrap_or("").to_string());
147
148 let description = parsed
149 .frontmatter
150 .get("description")
151 .and_then(|v| v.as_str())
152 .map(|s| s.to_string());
153
154 PreparedDoc {
155 rel_path: raw.rel_path,
156 slug,
157 title,
158 description,
159 body_md: parsed.body,
160 frontmatter: parsed.frontmatter,
161 }
162}
163
164#[allow(clippy::too_many_arguments)]
177pub fn render_block_markdown(
178 md: &str,
179 config: &docgen_config::SiteConfig,
180 registry: &docgen_components::Registry,
181 slugs: &SlugSet,
182 partials: &Partials,
183 base_dir: &str,
184 stack: &[String],
185 asset_urls: Option<&dyn crate::asseturl::AssetUrlResolver>,
186 plantuml: Option<&crate::plantuml::PlantumlSupport>,
187) -> String {
188 let (rewritten, instances) = crate::directivepass::extract(md);
189 let options = comrak_options();
190 let arena = Arena::new();
191 let root = parse_document(&arena, &rewritten, &options);
192 let _pass = transform_wikilinks(root, &arena, slugs, &config.base);
195 crate::assetpass::transform_asset_urls(root, &config.base, base_dir, slugs, asset_urls);
198 if config.features.math {
199 crate::mathpass::transform_math(root);
200 }
201 if config.features.mermaid {
202 crate::mermaidpass::transform_mermaid(root);
203 }
204 crate::tablepass::transform_tables(root, &arena);
207 let inner_html = format_ast(root, &options);
208 let render_inner = |m: &str| {
209 render_block_markdown(
210 m, config, registry, slugs, partials, base_dir, stack, asset_urls, plantuml,
211 )
212 };
213 let resolve_include = |src: &str| {
214 resolve_include_src(
215 src, base_dir, partials, stack, config, registry, slugs, asset_urls, plantuml,
216 )
217 };
218 let render_plantuml = |idx: usize, inst: &crate::directivepass::DirectiveInstance| {
219 crate::plantuml::render_directive(
220 inst,
221 base_dir,
222 plantuml,
223 &format!("docgen-plantuml-{idx}"),
224 )
225 };
226 let (out, _used) = crate::directivepass::substitute(
227 &inner_html,
228 &instances,
229 registry,
230 &render_inner,
231 &resolve_include,
232 &render_plantuml,
233 );
234 out
235}
236
237#[allow(clippy::too_many_arguments)]
242fn resolve_include_src(
243 src: &str,
244 base_dir: &str,
245 partials: &Partials,
246 stack: &[String],
247 config: &docgen_config::SiteConfig,
248 registry: &docgen_components::Registry,
249 slugs: &SlugSet,
250 asset_urls: Option<&dyn crate::asseturl::AssetUrlResolver>,
251 plantuml: Option<&crate::plantuml::PlantumlSupport>,
252) -> String {
253 let key = match resolve_include_key(base_dir, src) {
254 Some(k) => k,
255 None => return crate::directivepass::error_span("include", "src escapes docs root"),
256 };
257 if stack.iter().any(|s| s == &key) {
258 return crate::directivepass::error_span("include", "include cycle");
259 }
260 let Some(body) = partials.get(&key) else {
261 return crate::directivepass::error_span("include", "missing `src`");
262 };
263 let mut next = stack.to_vec();
264 next.push(key.clone());
265 let child_dir = key.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
266 render_block_markdown(
267 body, config, registry, slugs, partials, child_dir, &next, asset_urls, plantuml,
268 )
269}
270
271pub struct RenderedDoc {
276 pub doc: Doc,
277 pub search_text: String,
279 pub resolved_links: Vec<String>,
281}
282
283#[allow(clippy::too_many_arguments)]
293pub fn render_doc(
294 p: &PreparedDoc,
295 config: &docgen_config::SiteConfig,
296 registry: &docgen_components::Registry,
297 slugs: &SlugSet,
298 partials: &Partials,
299 asset_urls: Option<&dyn crate::asseturl::AssetUrlResolver>,
300 plantuml: Option<&crate::plantuml::PlantumlSupport>,
301 bases: Option<&docgen_bases::Corpus>,
302) -> RenderedDoc {
303 let options = comrak_options();
304
305 let (rewritten, instances) = crate::directivepass::extract(&p.body_md);
308
309 let arena = Arena::new();
312 let root = parse_document(&arena, &rewritten, &options);
313
314 let search_text = plaintext(root);
315
316 let headings = crate::headings::collect_headings(root);
320
321 let pass = transform_wikilinks(root, &arena, slugs, &config.base);
323 let resolved_links = pass.resolved;
324 let source_dir = p.rel_path.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
328 crate::assetpass::transform_asset_urls(root, &config.base, source_dir, slugs, asset_urls);
329 let math_count = if config.features.math {
331 crate::mathpass::transform_math(root)
332 } else {
333 0
334 };
335 let mermaid_count = if config.features.mermaid {
337 crate::mermaidpass::transform_mermaid(root)
338 } else {
339 0
340 };
341 if config.features.bases {
345 if let Some(corpus) = bases {
346 crate::basepass::transform_bases(root, corpus, &config.base);
347 }
348 }
349 crate::tablepass::transform_tables(root, &arena);
352 let formatted = format_ast(root, &options);
353 let formatted = crate::headings::stamp_heading_ids(&formatted, &headings);
356
357 let base_dir = p.rel_path.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
361 let stack: Vec<String> = Vec::new();
362 let render_inner = |m: &str| {
363 render_block_markdown(
364 m, config, registry, slugs, partials, base_dir, &stack, asset_urls, plantuml,
365 )
366 };
367 let resolve_include = |src: &str| {
368 resolve_include_src(
369 src, base_dir, partials, &stack, config, registry, slugs, asset_urls, plantuml,
370 )
371 };
372 let render_plantuml = |idx: usize, inst: &crate::directivepass::DirectiveInstance| {
373 crate::plantuml::render_directive(
374 inst,
375 base_dir,
376 plantuml,
377 &format!("docgen-plantuml-{idx}"),
378 )
379 };
380 let (body_html, used) = crate::directivepass::substitute(
381 &formatted,
382 &instances,
383 registry,
384 &render_inner,
385 &resolve_include,
386 &render_plantuml,
387 );
388
389 RenderedDoc {
390 doc: Doc {
391 rel_path: p.rel_path.clone(),
392 slug: p.slug.clone(),
393 title: p.title.clone(),
394 description: p.description.clone(),
395 body_html,
396 has_math: math_count > 0,
397 has_mermaid: mermaid_count > 0,
398 components_used: used,
399 headings,
400 },
401 search_text,
402 resolved_links,
403 }
404}
405
406#[allow(clippy::too_many_arguments)]
409pub fn render_docs(
410 prepared: Vec<PreparedDoc>,
411 partials: &Partials,
412 config: &docgen_config::SiteConfig,
413 registry: &docgen_components::Registry,
414 asset_urls: Option<&dyn crate::asseturl::AssetUrlResolver>,
415 plantuml: Option<&crate::plantuml::PlantumlSupport>,
416 bases: Option<&docgen_bases::Corpus>,
417) -> SiteBuild {
418 let slugs: SlugSet = prepared.iter().map(|p| p.slug.clone()).collect();
419 let doc_meta: Vec<(String, String, Option<String>)> = prepared
420 .iter()
421 .map(|p| (p.slug.clone(), p.title.clone(), p.description.clone()))
422 .collect();
423
424 let mut docs = Vec::with_capacity(prepared.len());
425 let mut outbound: BTreeMap<String, Vec<String>> = BTreeMap::new();
426 let mut search = Vec::with_capacity(prepared.len());
427
428 for p in &prepared {
429 let rendered = render_doc(
431 p, config, registry, &slugs, partials, asset_urls, plantuml, bases,
432 );
433 search.push(SearchEntry {
434 slug: p.slug.clone(),
435 title: p.title.clone(),
436 text: rendered.search_text,
437 });
438 outbound.insert(p.slug.clone(), rendered.resolved_links);
439 docs.push(rendered.doc);
440 }
441
442 let graph = build_link_graph(&doc_meta, &outbound);
443 let any_mermaid = docs.iter().any(|d| d.has_mermaid);
444 let any_components = docs.iter().any(|d| !d.components_used.is_empty());
445 SiteBuild {
446 docs,
447 graph,
448 outbound,
449 search,
450 any_mermaid,
451 any_components,
452 }
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458 use crate::model::RawDoc;
459
460 fn raw(path: &str, body: &str) -> RawDoc {
461 RawDoc {
462 rel_path: path.into(),
463 raw: body.into(),
464 }
465 }
466
467 #[test]
468 fn is_partial_rel_detects_underscore_basename() {
469 assert!(is_partial_rel("dev/server/_systems.gen.md"));
470 assert!(is_partial_rel("_root.md"));
471 assert!(!is_partial_rel("dev/server/index.md"));
472 assert!(!is_partial_rel("dev/_dir/page.md")); }
474
475 #[test]
476 fn partition_partials_splits_pages_and_strips_frontmatter() {
477 let raws = vec![
478 raw("a/index.md", "# Page\n"),
479 raw("a/_inc.md", "---\ntitle: x\n---\n## Inc\n"),
480 ];
481 let (pages, partials) = partition_partials(raws);
482 assert_eq!(pages.len(), 1);
483 assert_eq!(pages[0].rel_path, "a/index.md");
484 assert_eq!(
485 partials.get("a/_inc.md").map(String::as_str),
486 Some("## Inc\n")
487 );
488 }
489
490 #[test]
491 fn resolve_include_key_normalizes_relative_and_absolute() {
492 assert_eq!(
493 resolve_include_key("dev/server", "./_s.gen.md").as_deref(),
494 Some("dev/server/_s.gen.md")
495 );
496 assert_eq!(
497 resolve_include_key("dev/server", "../_top.md").as_deref(),
498 Some("dev/_top.md")
499 );
500 assert_eq!(
501 resolve_include_key("dev/server", "/root/_x.md").as_deref(),
502 Some("root/_x.md")
503 );
504 assert_eq!(resolve_include_key("", "_x.md").as_deref(), Some("_x.md"));
505 assert_eq!(resolve_include_key("dev", "../../escape.md"), None); }
507
508 #[test]
509 fn prepare_keeps_raw_body_and_derives_meta() {
510 let p = prepare(raw(
511 "guide/intro.md",
512 "---\ntitle: Intro\n---\n# H\nbody [[index]]\n",
513 ));
514 assert_eq!(p.slug, "guide/intro");
515 assert_eq!(p.title, "Intro");
516 assert!(p.body_md.contains("[[index]]"));
517 assert!(!p.body_md.contains("title:")); }
519
520 #[test]
521 fn render_doc_matches_render_docs_for_one_doc() {
522 let prepared = vec![
525 prepare(raw("index.md", "# Home\nGo to [[guide/intro]].\n")),
526 prepare(raw(
527 "guide/intro.md",
528 "# Intro\n```rust\nfn x(){}\n```\nBack to [[index]] and [[ghost]].\n",
529 )),
530 ];
531 let slugs: SlugSet = prepared.iter().map(|p| p.slug.clone()).collect();
532 let cfg = docgen_config::SiteConfig::default();
533 let reg = docgen_components::Registry::empty();
534
535 let site = render_docs(
536 prepared.clone(),
537 &Partials::new(),
538 &cfg,
539 ®,
540 None,
541 None,
542 None,
543 );
544 let single = render_doc(
545 &prepared[1],
546 &cfg,
547 ®,
548 &slugs,
549 &Partials::new(),
550 None,
551 None,
552 None,
553 );
554
555 assert_eq!(single.doc.body_html, site.docs[1].body_html);
556 assert_eq!(single.doc.has_mermaid, site.docs[1].has_mermaid);
557 assert_eq!(single.doc.has_math, site.docs[1].has_math);
558 assert_eq!(single.doc.headings, site.docs[1].headings);
559 assert_eq!(single.search_text, site.search[1].text);
560 assert!(single.resolved_links.contains(&"index".to_string()));
562 assert!(!single.resolved_links.contains(&"ghost".to_string()));
563 }
564
565 #[test]
566 fn render_docs_resolves_links_highlights_and_indexes() {
567 let prepared = vec![
568 prepare(raw("index.md", "# Home\nGo to [[guide/intro]].\n")),
569 prepare(raw(
570 "guide/intro.md",
571 "# Intro\n```rust\nfn x(){}\n```\nBack to [[index]] and [[ghost]].\n",
572 )),
573 ];
574 let site = render_docs(
575 prepared,
576 &Partials::new(),
577 &docgen_config::SiteConfig::default(),
578 &docgen_components::Registry::empty(),
579 None,
580 None,
581 None,
582 );
583
584 assert_eq!(site.docs[0].slug, "index");
586 assert_eq!(site.docs[1].slug, "guide/intro");
587
588 assert!(site.docs[0].body_html.contains(r#"href="/guide/intro""#));
590 assert!(site.docs[1]
592 .body_html
593 .contains(r#"<pre class="docgen-code">"#));
594 assert!(site.docs[1].body_html.contains(r#"href="/index""#));
595 assert!(site.docs[1].body_html.contains("docgen-wikilink--broken"));
596
597 assert!(site
599 .graph
600 .edges
601 .iter()
602 .any(|e| e.from == "index" && e.to == "guide/intro"));
603 assert!(site
604 .graph
605 .edges
606 .iter()
607 .any(|e| e.from == "guide/intro" && e.to == "index"));
608 assert!(!site.graph.edges.iter().any(|e| e.to == "ghost"));
609
610 assert_eq!(
612 site.graph.backlinks.get("index").unwrap()[0].slug,
613 "guide/intro"
614 );
615
616 assert_eq!(site.search.len(), 2);
618 let home = site.search.iter().find(|e| e.slug == "index").unwrap();
619 assert_eq!(home.title, "Home");
620 assert!(home.text.contains("Go to"));
621 assert!(!home.text.contains("[["));
622 }
623
624 #[test]
625 fn render_docs_renders_math_at_build_time() {
626 let prepared = vec![prepare(raw("m.md", "# M\nmass: $E=mc^2$\n"))];
627 let site = render_docs(
628 prepared,
629 &Partials::new(),
630 &docgen_config::SiteConfig::default(),
631 &docgen_components::Registry::empty(),
632 None,
633 None,
634 None,
635 );
636 assert!(site.docs[0].body_html.contains("katex"));
637 assert!(site.docs[0].has_math);
638 assert!(!site.docs[0].body_html.contains("$E=mc^2$"));
639 }
640
641 #[test]
642 fn math_feature_off_skips_build_time_katex() {
643 let prepared = vec![prepare(raw("m.md", "# M\n$E=mc^2$\n"))];
644 let mut cfg = docgen_config::SiteConfig::default();
645 cfg.features.math = false;
646 let site = render_docs(
647 prepared,
648 &Partials::new(),
649 &cfg,
650 &docgen_components::Registry::empty(),
651 None,
652 None,
653 None,
654 );
655 assert!(!site.docs[0].has_math);
656 assert!(!site.docs[0].body_html.contains("katex"));
657 }
658
659 #[test]
660 fn mermaid_feature_off_leaves_code_block() {
661 let prepared = vec![prepare(raw(
662 "d.md",
663 "# D\n```mermaid\ngraph TD;A-->B;\n```\n",
664 ))];
665 let mut cfg = docgen_config::SiteConfig::default();
666 cfg.features.mermaid = false;
667 let site = render_docs(
668 prepared,
669 &Partials::new(),
670 &cfg,
671 &docgen_components::Registry::empty(),
672 None,
673 None,
674 None,
675 );
676 assert!(!site.docs[0].has_mermaid);
677 assert!(!site.any_mermaid);
678 }
679
680 #[test]
681 fn render_docs_marks_mermaid_pages_and_site() {
682 let prepared = vec![
683 prepare(raw("d.md", "# D\n```mermaid\ngraph TD;A-->B;\n```\n")),
684 prepare(raw("p.md", "# P\nplain\n")),
685 ];
686 let site = render_docs(
687 prepared,
688 &Partials::new(),
689 &docgen_config::SiteConfig::default(),
690 &docgen_components::Registry::empty(),
691 None,
692 None,
693 None,
694 );
695 assert!(site.docs[0].has_mermaid && site.docs[0].body_html.contains("docgen-mermaid"));
696 assert!(!site.docs[1].has_mermaid);
697 assert!(site.any_mermaid);
698 }
699
700 #[test]
701 fn site_graph_data_matches_docs_and_links() {
702 let prepared = vec![
703 prepare(raw("index.md", "# Home\nGo to [[guide/intro]].\n")),
704 prepare(raw("guide/intro.md", "# Intro\nBack to [[index]].\n")),
705 ];
706 let site = render_docs(
707 prepared,
708 &Partials::new(),
709 &docgen_config::SiteConfig::default(),
710 &docgen_components::Registry::empty(),
711 None,
712 None,
713 None,
714 );
715 let gd = site.graph_data(crate::graphlayout::LayoutParams::default());
716 assert_eq!(gd.nodes.len(), 2);
717 assert!(gd
718 .nodes
719 .iter()
720 .any(|n| n.slug == "index" && n.title == "Home"));
721 assert!(gd
722 .nodes
723 .iter()
724 .any(|n| n.slug == "guide/intro" && n.title == "Intro"));
725 let is_pair = |e: &crate::graphlayout::GraphDataEdge| {
727 (e.from == "index" && e.to == "guide/intro")
728 || (e.from == "guide/intro" && e.to == "index")
729 };
730 assert_eq!(gd.edges.iter().filter(|e| is_pair(e)).count(), 1);
731 assert_eq!(gd.edges.len(), 1);
732 }
733
734 #[test]
735 fn render_docs_without_mermaid_clears_site_flag() {
736 let prepared = vec![prepare(raw("p.md", "# P\nplain\n"))];
737 let site = render_docs(
738 prepared,
739 &Partials::new(),
740 &docgen_config::SiteConfig::default(),
741 &docgen_components::Registry::empty(),
742 None,
743 None,
744 None,
745 );
746 assert!(!site.any_mermaid);
747 }
748
749 #[test]
750 fn render_docs_renders_callout_directive_with_inner_markdown() {
751 let mut reg = docgen_components::Registry::empty();
752 reg.insert(docgen_components::Component::from_parts(
753 "callout",
754 "<aside class=\"docgen-callout--{{ attrs.type | default('note') }}\">{{ content | safe }}</aside>",
755 None,
756 None,
757 ));
758 let prepared = vec![prepare(raw(
759 "d.md",
760 "# D\n\n:::callout{type=warning}\nBe **careful**.\n:::\n",
761 ))];
762 let site = render_docs(
763 prepared,
764 &Partials::new(),
765 &docgen_config::SiteConfig::default(),
766 ®,
767 None,
768 None,
769 None,
770 );
771 let h = &site.docs[0].body_html;
772 assert!(h.contains("docgen-callout--warning"));
773 assert!(h.contains("<strong>careful</strong>")); assert!(site.docs[0].components_used.contains("callout"));
775 assert!(site.any_components);
776 }
777
778 #[test]
779 fn unknown_directive_in_doc_yields_error_span_not_crash() {
780 let prepared = vec![prepare(raw("d.md", "# D\n\n:nope[x]{}\n"))];
781 let site = render_docs(
782 prepared,
783 &Partials::new(),
784 &docgen_config::SiteConfig::default(),
785 &docgen_components::Registry::empty(),
786 None,
787 None,
788 None,
789 );
790 assert!(site.docs[0].body_html.contains("docgen-directive-error"));
791 assert!(!site.any_components);
792 }
793
794 #[test]
795 fn wikilink_outside_directive_still_resolves() {
796 let mut reg = docgen_components::Registry::empty();
797 reg.insert(docgen_components::Component::from_parts(
798 "callout",
799 "<aside>{{ content | safe }}</aside>",
800 None,
801 None,
802 ));
803 let prepared = vec![
804 prepare(raw(
805 "index.md",
806 "# Home\nSee [[guide]].\n\n:::callout{}\nx\n:::\n",
807 )),
808 prepare(raw("guide.md", "# Guide\n")),
809 ];
810 let site = render_docs(
811 prepared,
812 &Partials::new(),
813 &docgen_config::SiteConfig::default(),
814 ®,
815 None,
816 None,
817 None,
818 );
819 assert!(site.docs[0].body_html.contains(r#"href="/guide""#));
820 }
821
822 #[test]
823 fn wikilink_inside_directive_body_resolves_to_anchor() {
824 let mut reg = docgen_components::Registry::empty();
825 reg.insert(docgen_components::Component::from_parts(
826 "callout",
827 "<aside>{{ content | safe }}</aside>",
828 None,
829 None,
830 ));
831 let prepared = vec![
832 prepare(raw(
833 "index.md",
834 "# Home\n\n:::callout{}\nSee [[guide/intro|wikilink]] and [[ghost]].\n:::\n",
835 )),
836 prepare(raw("guide/intro.md", "# Intro\n")),
837 ];
838 let site = render_docs(
839 prepared,
840 &Partials::new(),
841 &docgen_config::SiteConfig::default(),
842 ®,
843 None,
844 None,
845 None,
846 );
847 let h = &site.docs[0].body_html;
848 assert!(h.contains(r#"href="/guide/intro""#));
851 assert!(h.contains(r#">wikilink</a>"#));
852 assert!(!h.contains("[[guide/intro|wikilink]]"));
853 assert!(h.contains("docgen-wikilink--broken"));
855 assert!(!h.contains("[[ghost]]"));
856 }
857
858 #[test]
859 fn self_link_renders_anchor_but_no_self_backlink() {
860 let prepared = vec![prepare(raw("index.md", "# Home\nBack to [[index]].\n"))];
863 let site = render_docs(
864 prepared,
865 &Partials::new(),
866 &docgen_config::SiteConfig::default(),
867 &docgen_components::Registry::empty(),
868 None,
869 None,
870 None,
871 );
872
873 assert!(site.docs[0].body_html.contains(r#"href="/index""#));
874 assert!(!site
875 .graph
876 .edges
877 .iter()
878 .any(|e| e.from == "index" && e.to == "index"));
879 assert!(!site.graph.backlinks.contains_key("index"));
880 }
881}