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