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 fn is_partial_rel(rel_path: &str) -> bool {
19 rel_path
20 .rsplit('/')
21 .next()
22 .map(|name| name.starts_with('_'))
23 .unwrap_or(false)
24}
25
26pub fn partition_partials(raws: Vec<RawDoc>) -> (Vec<RawDoc>, Partials) {
29 let mut pages = Vec::new();
30 let mut partials = Partials::new();
31 for raw in raws {
32 if is_partial_rel(&raw.rel_path) {
33 let body = parse_frontmatter(&raw.raw).body;
34 partials.insert(raw.rel_path, body);
35 } else {
36 pages.push(raw);
37 }
38 }
39 (pages, partials)
40}
41
42pub fn resolve_include_key(base_dir: &str, src: &str) -> Option<String> {
47 let src = src.trim();
48 let combined = if let Some(rest) = src.strip_prefix('/') {
49 rest.to_string()
50 } else if base_dir.is_empty() {
51 src.to_string()
52 } else {
53 format!("{base_dir}/{src}")
54 };
55 let mut parts: Vec<&str> = Vec::new();
56 for seg in combined.split('/') {
57 match seg {
58 "" | "." => continue,
59 ".." => {
60 parts.pop()?;
61 }
62 s => parts.push(s),
63 }
64 }
65 Some(parts.join("/"))
66}
67
68#[derive(Debug, Clone, PartialEq)]
70pub struct PreparedDoc {
71 pub rel_path: String,
72 pub slug: String,
73 pub title: String,
74 pub description: Option<String>,
76 pub body_md: String,
77}
78
79pub struct SiteBuild {
81 pub docs: Vec<Doc>,
82 pub graph: LinkGraph,
83 pub outbound: BTreeMap<String, Vec<String>>,
88 pub search: Vec<SearchEntry>,
89 pub any_mermaid: bool,
92 pub any_components: bool,
94}
95
96impl SiteBuild {
97 pub fn graph_data(
101 &self,
102 params: crate::graphlayout::LayoutParams,
103 ) -> crate::graphlayout::GraphData {
104 let meta: Vec<(String, String)> = self
105 .docs
106 .iter()
107 .map(|d| (d.slug.clone(), d.title.clone()))
108 .collect();
109 crate::graphlayout::layout_graph(&meta, &self.graph, params)
110 }
111}
112
113fn first_h1(body: &str) -> Option<String> {
114 body.lines()
115 .find_map(|line| line.strip_prefix("# ").map(|h| h.trim().to_string()))
116}
117
118pub fn prepare(raw: RawDoc) -> PreparedDoc {
120 let parsed = parse_frontmatter(&raw.raw);
121 let slug = raw
122 .rel_path
123 .strip_suffix(".md")
124 .unwrap_or(&raw.rel_path)
125 .to_string();
126
127 let fm_title = parsed
128 .frontmatter
129 .get("title")
130 .and_then(|v| v.as_str())
131 .map(|s| s.to_string());
132 let title = fm_title
133 .or_else(|| first_h1(&parsed.body))
134 .unwrap_or_else(|| slug.rsplit('/').next().unwrap_or("").to_string());
135
136 let description = parsed
137 .frontmatter
138 .get("description")
139 .and_then(|v| v.as_str())
140 .map(|s| s.to_string());
141
142 PreparedDoc {
143 rel_path: raw.rel_path,
144 slug,
145 title,
146 description,
147 body_md: parsed.body,
148 }
149}
150
151#[allow(clippy::too_many_arguments)]
164pub fn render_block_markdown(
165 md: &str,
166 config: &docgen_config::SiteConfig,
167 registry: &docgen_components::Registry,
168 slugs: &SlugSet,
169 partials: &Partials,
170 base_dir: &str,
171 stack: &[String],
172 asset_urls: Option<&dyn crate::asseturl::AssetUrlResolver>,
173) -> String {
174 let (rewritten, instances) = crate::directivepass::extract(md);
175 let options = comrak_options();
176 let arena = Arena::new();
177 let root = parse_document(&arena, &rewritten, &options);
178 let _pass = transform_wikilinks(root, &arena, slugs, &config.base);
181 crate::assetpass::transform_asset_urls(root, &config.base, base_dir, slugs, asset_urls);
184 if config.features.math {
185 crate::mathpass::transform_math(root);
186 }
187 if config.features.mermaid {
188 crate::mermaidpass::transform_mermaid(root);
189 }
190 crate::tablepass::transform_tables(root, &arena);
193 let inner_html = format_ast(root, &options);
194 let render_inner = |m: &str| {
195 render_block_markdown(
196 m, config, registry, slugs, partials, base_dir, stack, asset_urls,
197 )
198 };
199 let resolve_include = |src: &str| {
200 resolve_include_src(
201 src, base_dir, partials, stack, config, registry, slugs, asset_urls,
202 )
203 };
204 let (out, _used) = crate::directivepass::substitute(
205 &inner_html,
206 &instances,
207 registry,
208 &render_inner,
209 &resolve_include,
210 );
211 out
212}
213
214#[allow(clippy::too_many_arguments)]
219fn resolve_include_src(
220 src: &str,
221 base_dir: &str,
222 partials: &Partials,
223 stack: &[String],
224 config: &docgen_config::SiteConfig,
225 registry: &docgen_components::Registry,
226 slugs: &SlugSet,
227 asset_urls: Option<&dyn crate::asseturl::AssetUrlResolver>,
228) -> String {
229 let key = match resolve_include_key(base_dir, src) {
230 Some(k) => k,
231 None => return crate::directivepass::error_span("include", "src escapes docs root"),
232 };
233 if stack.iter().any(|s| s == &key) {
234 return crate::directivepass::error_span("include", "include cycle");
235 }
236 let Some(body) = partials.get(&key) else {
237 return crate::directivepass::error_span("include", "missing `src`");
238 };
239 let mut next = stack.to_vec();
240 next.push(key.clone());
241 let child_dir = key.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
242 render_block_markdown(
243 body, config, registry, slugs, partials, child_dir, &next, asset_urls,
244 )
245}
246
247pub struct RenderedDoc {
252 pub doc: Doc,
253 pub search_text: String,
255 pub resolved_links: Vec<String>,
257}
258
259pub fn render_doc(
269 p: &PreparedDoc,
270 config: &docgen_config::SiteConfig,
271 registry: &docgen_components::Registry,
272 slugs: &SlugSet,
273 partials: &Partials,
274 asset_urls: Option<&dyn crate::asseturl::AssetUrlResolver>,
275) -> RenderedDoc {
276 let options = comrak_options();
277
278 let (rewritten, instances) = crate::directivepass::extract(&p.body_md);
281
282 let arena = Arena::new();
285 let root = parse_document(&arena, &rewritten, &options);
286
287 let search_text = plaintext(root);
288
289 let headings = crate::headings::collect_headings(root);
293
294 let pass = transform_wikilinks(root, &arena, slugs, &config.base);
296 let resolved_links = pass.resolved;
297 let source_dir = p.rel_path.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
301 crate::assetpass::transform_asset_urls(root, &config.base, source_dir, slugs, asset_urls);
302 let math_count = if config.features.math {
304 crate::mathpass::transform_math(root)
305 } else {
306 0
307 };
308 let mermaid_count = if config.features.mermaid {
310 crate::mermaidpass::transform_mermaid(root)
311 } else {
312 0
313 };
314 crate::tablepass::transform_tables(root, &arena);
317 let formatted = format_ast(root, &options);
318 let formatted = crate::headings::stamp_heading_ids(&formatted, &headings);
321
322 let base_dir = p.rel_path.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
326 let stack: Vec<String> = Vec::new();
327 let render_inner = |m: &str| {
328 render_block_markdown(
329 m, config, registry, slugs, partials, base_dir, &stack, asset_urls,
330 )
331 };
332 let resolve_include = |src: &str| {
333 resolve_include_src(
334 src, base_dir, partials, &stack, config, registry, slugs, asset_urls,
335 )
336 };
337 let (body_html, used) = crate::directivepass::substitute(
338 &formatted,
339 &instances,
340 registry,
341 &render_inner,
342 &resolve_include,
343 );
344
345 RenderedDoc {
346 doc: Doc {
347 rel_path: p.rel_path.clone(),
348 slug: p.slug.clone(),
349 title: p.title.clone(),
350 description: p.description.clone(),
351 body_html,
352 has_math: math_count > 0,
353 has_mermaid: mermaid_count > 0,
354 components_used: used,
355 headings,
356 },
357 search_text,
358 resolved_links,
359 }
360}
361
362pub fn render_docs(
365 prepared: Vec<PreparedDoc>,
366 partials: &Partials,
367 config: &docgen_config::SiteConfig,
368 registry: &docgen_components::Registry,
369 asset_urls: Option<&dyn crate::asseturl::AssetUrlResolver>,
370) -> SiteBuild {
371 let slugs: SlugSet = prepared.iter().map(|p| p.slug.clone()).collect();
372 let doc_meta: Vec<(String, String, Option<String>)> = prepared
373 .iter()
374 .map(|p| (p.slug.clone(), p.title.clone(), p.description.clone()))
375 .collect();
376
377 let mut docs = Vec::with_capacity(prepared.len());
378 let mut outbound: BTreeMap<String, Vec<String>> = BTreeMap::new();
379 let mut search = Vec::with_capacity(prepared.len());
380
381 for p in &prepared {
382 let rendered = render_doc(p, config, registry, &slugs, partials, asset_urls);
384 search.push(SearchEntry {
385 slug: p.slug.clone(),
386 title: p.title.clone(),
387 text: rendered.search_text,
388 });
389 outbound.insert(p.slug.clone(), rendered.resolved_links);
390 docs.push(rendered.doc);
391 }
392
393 let graph = build_link_graph(&doc_meta, &outbound);
394 let any_mermaid = docs.iter().any(|d| d.has_mermaid);
395 let any_components = docs.iter().any(|d| !d.components_used.is_empty());
396 SiteBuild {
397 docs,
398 graph,
399 outbound,
400 search,
401 any_mermaid,
402 any_components,
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use crate::model::RawDoc;
410
411 fn raw(path: &str, body: &str) -> RawDoc {
412 RawDoc {
413 rel_path: path.into(),
414 raw: body.into(),
415 }
416 }
417
418 #[test]
419 fn is_partial_rel_detects_underscore_basename() {
420 assert!(is_partial_rel("dev/server/_systems.gen.md"));
421 assert!(is_partial_rel("_root.md"));
422 assert!(!is_partial_rel("dev/server/index.md"));
423 assert!(!is_partial_rel("dev/_dir/page.md")); }
425
426 #[test]
427 fn partition_partials_splits_pages_and_strips_frontmatter() {
428 let raws = vec![
429 raw("a/index.md", "# Page\n"),
430 raw("a/_inc.md", "---\ntitle: x\n---\n## Inc\n"),
431 ];
432 let (pages, partials) = partition_partials(raws);
433 assert_eq!(pages.len(), 1);
434 assert_eq!(pages[0].rel_path, "a/index.md");
435 assert_eq!(
436 partials.get("a/_inc.md").map(String::as_str),
437 Some("## Inc\n")
438 );
439 }
440
441 #[test]
442 fn resolve_include_key_normalizes_relative_and_absolute() {
443 assert_eq!(
444 resolve_include_key("dev/server", "./_s.gen.md").as_deref(),
445 Some("dev/server/_s.gen.md")
446 );
447 assert_eq!(
448 resolve_include_key("dev/server", "../_top.md").as_deref(),
449 Some("dev/_top.md")
450 );
451 assert_eq!(
452 resolve_include_key("dev/server", "/root/_x.md").as_deref(),
453 Some("root/_x.md")
454 );
455 assert_eq!(resolve_include_key("", "_x.md").as_deref(), Some("_x.md"));
456 assert_eq!(resolve_include_key("dev", "../../escape.md"), None); }
458
459 #[test]
460 fn prepare_keeps_raw_body_and_derives_meta() {
461 let p = prepare(raw(
462 "guide/intro.md",
463 "---\ntitle: Intro\n---\n# H\nbody [[index]]\n",
464 ));
465 assert_eq!(p.slug, "guide/intro");
466 assert_eq!(p.title, "Intro");
467 assert!(p.body_md.contains("[[index]]"));
468 assert!(!p.body_md.contains("title:")); }
470
471 #[test]
472 fn render_doc_matches_render_docs_for_one_doc() {
473 let prepared = vec![
476 prepare(raw("index.md", "# Home\nGo to [[guide/intro]].\n")),
477 prepare(raw(
478 "guide/intro.md",
479 "# Intro\n```rust\nfn x(){}\n```\nBack to [[index]] and [[ghost]].\n",
480 )),
481 ];
482 let slugs: SlugSet = prepared.iter().map(|p| p.slug.clone()).collect();
483 let cfg = docgen_config::SiteConfig::default();
484 let reg = docgen_components::Registry::empty();
485
486 let site = render_docs(prepared.clone(), &Partials::new(), &cfg, ®, None);
487 let single = render_doc(&prepared[1], &cfg, ®, &slugs, &Partials::new(), None);
488
489 assert_eq!(single.doc.body_html, site.docs[1].body_html);
490 assert_eq!(single.doc.has_mermaid, site.docs[1].has_mermaid);
491 assert_eq!(single.doc.has_math, site.docs[1].has_math);
492 assert_eq!(single.doc.headings, site.docs[1].headings);
493 assert_eq!(single.search_text, site.search[1].text);
494 assert!(single.resolved_links.contains(&"index".to_string()));
496 assert!(!single.resolved_links.contains(&"ghost".to_string()));
497 }
498
499 #[test]
500 fn render_docs_resolves_links_highlights_and_indexes() {
501 let prepared = vec![
502 prepare(raw("index.md", "# Home\nGo to [[guide/intro]].\n")),
503 prepare(raw(
504 "guide/intro.md",
505 "# Intro\n```rust\nfn x(){}\n```\nBack to [[index]] and [[ghost]].\n",
506 )),
507 ];
508 let site = render_docs(
509 prepared,
510 &Partials::new(),
511 &docgen_config::SiteConfig::default(),
512 &docgen_components::Registry::empty(),
513 None,
514 );
515
516 assert_eq!(site.docs[0].slug, "index");
518 assert_eq!(site.docs[1].slug, "guide/intro");
519
520 assert!(site.docs[0].body_html.contains(r#"href="/guide/intro""#));
522 assert!(site.docs[1]
524 .body_html
525 .contains(r#"<pre class="docgen-code">"#));
526 assert!(site.docs[1].body_html.contains(r#"href="/index""#));
527 assert!(site.docs[1].body_html.contains("docgen-wikilink--broken"));
528
529 assert!(site
531 .graph
532 .edges
533 .iter()
534 .any(|e| e.from == "index" && e.to == "guide/intro"));
535 assert!(site
536 .graph
537 .edges
538 .iter()
539 .any(|e| e.from == "guide/intro" && e.to == "index"));
540 assert!(!site.graph.edges.iter().any(|e| e.to == "ghost"));
541
542 assert_eq!(
544 site.graph.backlinks.get("index").unwrap()[0].slug,
545 "guide/intro"
546 );
547
548 assert_eq!(site.search.len(), 2);
550 let home = site.search.iter().find(|e| e.slug == "index").unwrap();
551 assert_eq!(home.title, "Home");
552 assert!(home.text.contains("Go to"));
553 assert!(!home.text.contains("[["));
554 }
555
556 #[test]
557 fn render_docs_renders_math_at_build_time() {
558 let prepared = vec![prepare(raw("m.md", "# M\nmass: $E=mc^2$\n"))];
559 let site = render_docs(
560 prepared,
561 &Partials::new(),
562 &docgen_config::SiteConfig::default(),
563 &docgen_components::Registry::empty(),
564 None,
565 );
566 assert!(site.docs[0].body_html.contains("katex"));
567 assert!(site.docs[0].has_math);
568 assert!(!site.docs[0].body_html.contains("$E=mc^2$"));
569 }
570
571 #[test]
572 fn math_feature_off_skips_build_time_katex() {
573 let prepared = vec![prepare(raw("m.md", "# M\n$E=mc^2$\n"))];
574 let mut cfg = docgen_config::SiteConfig::default();
575 cfg.features.math = false;
576 let site = render_docs(
577 prepared,
578 &Partials::new(),
579 &cfg,
580 &docgen_components::Registry::empty(),
581 None,
582 );
583 assert!(!site.docs[0].has_math);
584 assert!(!site.docs[0].body_html.contains("katex"));
585 }
586
587 #[test]
588 fn mermaid_feature_off_leaves_code_block() {
589 let prepared = vec![prepare(raw(
590 "d.md",
591 "# D\n```mermaid\ngraph TD;A-->B;\n```\n",
592 ))];
593 let mut cfg = docgen_config::SiteConfig::default();
594 cfg.features.mermaid = false;
595 let site = render_docs(
596 prepared,
597 &Partials::new(),
598 &cfg,
599 &docgen_components::Registry::empty(),
600 None,
601 );
602 assert!(!site.docs[0].has_mermaid);
603 assert!(!site.any_mermaid);
604 }
605
606 #[test]
607 fn render_docs_marks_mermaid_pages_and_site() {
608 let prepared = vec![
609 prepare(raw("d.md", "# D\n```mermaid\ngraph TD;A-->B;\n```\n")),
610 prepare(raw("p.md", "# P\nplain\n")),
611 ];
612 let site = render_docs(
613 prepared,
614 &Partials::new(),
615 &docgen_config::SiteConfig::default(),
616 &docgen_components::Registry::empty(),
617 None,
618 );
619 assert!(site.docs[0].has_mermaid && site.docs[0].body_html.contains("docgen-mermaid"));
620 assert!(!site.docs[1].has_mermaid);
621 assert!(site.any_mermaid);
622 }
623
624 #[test]
625 fn site_graph_data_matches_docs_and_links() {
626 let prepared = vec![
627 prepare(raw("index.md", "# Home\nGo to [[guide/intro]].\n")),
628 prepare(raw("guide/intro.md", "# Intro\nBack to [[index]].\n")),
629 ];
630 let site = render_docs(
631 prepared,
632 &Partials::new(),
633 &docgen_config::SiteConfig::default(),
634 &docgen_components::Registry::empty(),
635 None,
636 );
637 let gd = site.graph_data(crate::graphlayout::LayoutParams::default());
638 assert_eq!(gd.nodes.len(), 2);
639 assert!(gd
640 .nodes
641 .iter()
642 .any(|n| n.slug == "index" && n.title == "Home"));
643 assert!(gd
644 .nodes
645 .iter()
646 .any(|n| n.slug == "guide/intro" && n.title == "Intro"));
647 let is_pair = |e: &crate::graphlayout::GraphDataEdge| {
649 (e.from == "index" && e.to == "guide/intro")
650 || (e.from == "guide/intro" && e.to == "index")
651 };
652 assert_eq!(gd.edges.iter().filter(|e| is_pair(e)).count(), 1);
653 assert_eq!(gd.edges.len(), 1);
654 }
655
656 #[test]
657 fn render_docs_without_mermaid_clears_site_flag() {
658 let prepared = vec![prepare(raw("p.md", "# P\nplain\n"))];
659 let site = render_docs(
660 prepared,
661 &Partials::new(),
662 &docgen_config::SiteConfig::default(),
663 &docgen_components::Registry::empty(),
664 None,
665 );
666 assert!(!site.any_mermaid);
667 }
668
669 #[test]
670 fn render_docs_renders_callout_directive_with_inner_markdown() {
671 let mut reg = docgen_components::Registry::empty();
672 reg.insert(docgen_components::Component::from_parts(
673 "callout",
674 "<aside class=\"docgen-callout--{{ attrs.type | default('note') }}\">{{ content | safe }}</aside>",
675 None,
676 None,
677 ));
678 let prepared = vec![prepare(raw(
679 "d.md",
680 "# D\n\n:::callout{type=warning}\nBe **careful**.\n:::\n",
681 ))];
682 let site = render_docs(
683 prepared,
684 &Partials::new(),
685 &docgen_config::SiteConfig::default(),
686 ®,
687 None,
688 );
689 let h = &site.docs[0].body_html;
690 assert!(h.contains("docgen-callout--warning"));
691 assert!(h.contains("<strong>careful</strong>")); assert!(site.docs[0].components_used.contains("callout"));
693 assert!(site.any_components);
694 }
695
696 #[test]
697 fn unknown_directive_in_doc_yields_error_span_not_crash() {
698 let prepared = vec![prepare(raw("d.md", "# D\n\n:nope[x]{}\n"))];
699 let site = render_docs(
700 prepared,
701 &Partials::new(),
702 &docgen_config::SiteConfig::default(),
703 &docgen_components::Registry::empty(),
704 None,
705 );
706 assert!(site.docs[0].body_html.contains("docgen-directive-error"));
707 assert!(!site.any_components);
708 }
709
710 #[test]
711 fn wikilink_outside_directive_still_resolves() {
712 let mut reg = docgen_components::Registry::empty();
713 reg.insert(docgen_components::Component::from_parts(
714 "callout",
715 "<aside>{{ content | safe }}</aside>",
716 None,
717 None,
718 ));
719 let prepared = vec![
720 prepare(raw(
721 "index.md",
722 "# Home\nSee [[guide]].\n\n:::callout{}\nx\n:::\n",
723 )),
724 prepare(raw("guide.md", "# Guide\n")),
725 ];
726 let site = render_docs(
727 prepared,
728 &Partials::new(),
729 &docgen_config::SiteConfig::default(),
730 ®,
731 None,
732 );
733 assert!(site.docs[0].body_html.contains(r#"href="/guide""#));
734 }
735
736 #[test]
737 fn wikilink_inside_directive_body_resolves_to_anchor() {
738 let mut reg = docgen_components::Registry::empty();
739 reg.insert(docgen_components::Component::from_parts(
740 "callout",
741 "<aside>{{ content | safe }}</aside>",
742 None,
743 None,
744 ));
745 let prepared = vec![
746 prepare(raw(
747 "index.md",
748 "# Home\n\n:::callout{}\nSee [[guide/intro|wikilink]] and [[ghost]].\n:::\n",
749 )),
750 prepare(raw("guide/intro.md", "# Intro\n")),
751 ];
752 let site = render_docs(
753 prepared,
754 &Partials::new(),
755 &docgen_config::SiteConfig::default(),
756 ®,
757 None,
758 );
759 let h = &site.docs[0].body_html;
760 assert!(h.contains(r#"href="/guide/intro""#));
763 assert!(h.contains(r#">wikilink</a>"#));
764 assert!(!h.contains("[[guide/intro|wikilink]]"));
765 assert!(h.contains("docgen-wikilink--broken"));
767 assert!(!h.contains("[[ghost]]"));
768 }
769
770 #[test]
771 fn self_link_renders_anchor_but_no_self_backlink() {
772 let prepared = vec![prepare(raw("index.md", "# Home\nBack to [[index]].\n"))];
775 let site = render_docs(
776 prepared,
777 &Partials::new(),
778 &docgen_config::SiteConfig::default(),
779 &docgen_components::Registry::empty(),
780 None,
781 );
782
783 assert!(site.docs[0].body_html.contains(r#"href="/index""#));
784 assert!(!site
785 .graph
786 .edges
787 .iter()
788 .any(|e| e.from == "index" && e.to == "index"));
789 assert!(!site.graph.backlinks.contains_key("index"));
790 }
791}