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