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
151pub fn render_block_markdown(
164 md: &str,
165 config: &docgen_config::SiteConfig,
166 registry: &docgen_components::Registry,
167 slugs: &SlugSet,
168 partials: &Partials,
169 base_dir: &str,
170 stack: &[String],
171) -> String {
172 let (rewritten, instances) = crate::directivepass::extract(md);
173 let options = comrak_options();
174 let arena = Arena::new();
175 let root = parse_document(&arena, &rewritten, &options);
176 let _pass = transform_wikilinks(root, &arena, slugs, &config.base);
179 if config.features.math {
180 crate::mathpass::transform_math(root);
181 }
182 if config.features.mermaid {
183 crate::mermaidpass::transform_mermaid(root);
184 }
185 let inner_html = format_ast(root, &options);
186 let render_inner =
187 |m: &str| render_block_markdown(m, config, registry, slugs, partials, base_dir, stack);
188 let resolve_include =
189 |src: &str| resolve_include_src(src, base_dir, partials, stack, config, registry, slugs);
190 let (out, _used) = crate::directivepass::substitute(
191 &inner_html,
192 &instances,
193 registry,
194 &render_inner,
195 &resolve_include,
196 );
197 out
198}
199
200fn resolve_include_src(
205 src: &str,
206 base_dir: &str,
207 partials: &Partials,
208 stack: &[String],
209 config: &docgen_config::SiteConfig,
210 registry: &docgen_components::Registry,
211 slugs: &SlugSet,
212) -> String {
213 let key = match resolve_include_key(base_dir, src) {
214 Some(k) => k,
215 None => return crate::directivepass::error_span("include", "src escapes docs root"),
216 };
217 if stack.iter().any(|s| s == &key) {
218 return crate::directivepass::error_span("include", "include cycle");
219 }
220 let Some(body) = partials.get(&key) else {
221 return crate::directivepass::error_span("include", "missing `src`");
222 };
223 let mut next = stack.to_vec();
224 next.push(key.clone());
225 let child_dir = key.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
226 render_block_markdown(body, config, registry, slugs, partials, child_dir, &next)
227}
228
229pub struct RenderedDoc {
234 pub doc: Doc,
235 pub search_text: String,
237 pub resolved_links: Vec<String>,
239}
240
241pub fn render_doc(
251 p: &PreparedDoc,
252 config: &docgen_config::SiteConfig,
253 registry: &docgen_components::Registry,
254 slugs: &SlugSet,
255 partials: &Partials,
256) -> RenderedDoc {
257 let options = comrak_options();
258
259 let (rewritten, instances) = crate::directivepass::extract(&p.body_md);
262
263 let arena = Arena::new();
266 let root = parse_document(&arena, &rewritten, &options);
267
268 let search_text = plaintext(root);
269
270 let headings = crate::headings::collect_headings(root);
274
275 let pass = transform_wikilinks(root, &arena, slugs, &config.base);
277 let resolved_links = pass.resolved;
278 let math_count = if config.features.math {
280 crate::mathpass::transform_math(root)
281 } else {
282 0
283 };
284 let mermaid_count = if config.features.mermaid {
286 crate::mermaidpass::transform_mermaid(root)
287 } else {
288 0
289 };
290 let formatted = format_ast(root, &options);
291 let formatted = crate::headings::stamp_heading_ids(&formatted, &headings);
294
295 let base_dir = p.rel_path.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
299 let stack: Vec<String> = Vec::new();
300 let render_inner =
301 |m: &str| render_block_markdown(m, config, registry, slugs, partials, base_dir, &stack);
302 let resolve_include =
303 |src: &str| resolve_include_src(src, base_dir, partials, &stack, config, registry, slugs);
304 let (body_html, used) = crate::directivepass::substitute(
305 &formatted,
306 &instances,
307 registry,
308 &render_inner,
309 &resolve_include,
310 );
311
312 RenderedDoc {
313 doc: Doc {
314 rel_path: p.rel_path.clone(),
315 slug: p.slug.clone(),
316 title: p.title.clone(),
317 description: p.description.clone(),
318 body_html,
319 has_math: math_count > 0,
320 has_mermaid: mermaid_count > 0,
321 components_used: used,
322 headings,
323 },
324 search_text,
325 resolved_links,
326 }
327}
328
329pub fn render_docs(
332 prepared: Vec<PreparedDoc>,
333 partials: &Partials,
334 config: &docgen_config::SiteConfig,
335 registry: &docgen_components::Registry,
336) -> SiteBuild {
337 let slugs: SlugSet = prepared.iter().map(|p| p.slug.clone()).collect();
338 let doc_meta: Vec<(String, String, Option<String>)> = prepared
339 .iter()
340 .map(|p| (p.slug.clone(), p.title.clone(), p.description.clone()))
341 .collect();
342
343 let mut docs = Vec::with_capacity(prepared.len());
344 let mut outbound: BTreeMap<String, Vec<String>> = BTreeMap::new();
345 let mut search = Vec::with_capacity(prepared.len());
346
347 for p in &prepared {
348 let rendered = render_doc(p, config, registry, &slugs, partials);
350 search.push(SearchEntry {
351 slug: p.slug.clone(),
352 title: p.title.clone(),
353 text: rendered.search_text,
354 });
355 outbound.insert(p.slug.clone(), rendered.resolved_links);
356 docs.push(rendered.doc);
357 }
358
359 let graph = build_link_graph(&doc_meta, &outbound);
360 let any_mermaid = docs.iter().any(|d| d.has_mermaid);
361 let any_components = docs.iter().any(|d| !d.components_used.is_empty());
362 SiteBuild {
363 docs,
364 graph,
365 outbound,
366 search,
367 any_mermaid,
368 any_components,
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use crate::model::RawDoc;
376
377 fn raw(path: &str, body: &str) -> RawDoc {
378 RawDoc {
379 rel_path: path.into(),
380 raw: body.into(),
381 }
382 }
383
384 #[test]
385 fn is_partial_rel_detects_underscore_basename() {
386 assert!(is_partial_rel("dev/server/_systems.gen.md"));
387 assert!(is_partial_rel("_root.md"));
388 assert!(!is_partial_rel("dev/server/index.md"));
389 assert!(!is_partial_rel("dev/_dir/page.md")); }
391
392 #[test]
393 fn partition_partials_splits_pages_and_strips_frontmatter() {
394 let raws = vec![
395 raw("a/index.md", "# Page\n"),
396 raw("a/_inc.md", "---\ntitle: x\n---\n## Inc\n"),
397 ];
398 let (pages, partials) = partition_partials(raws);
399 assert_eq!(pages.len(), 1);
400 assert_eq!(pages[0].rel_path, "a/index.md");
401 assert_eq!(
402 partials.get("a/_inc.md").map(String::as_str),
403 Some("## Inc\n")
404 );
405 }
406
407 #[test]
408 fn resolve_include_key_normalizes_relative_and_absolute() {
409 assert_eq!(
410 resolve_include_key("dev/server", "./_s.gen.md").as_deref(),
411 Some("dev/server/_s.gen.md")
412 );
413 assert_eq!(
414 resolve_include_key("dev/server", "../_top.md").as_deref(),
415 Some("dev/_top.md")
416 );
417 assert_eq!(
418 resolve_include_key("dev/server", "/root/_x.md").as_deref(),
419 Some("root/_x.md")
420 );
421 assert_eq!(resolve_include_key("", "_x.md").as_deref(), Some("_x.md"));
422 assert_eq!(resolve_include_key("dev", "../../escape.md"), None); }
424
425 #[test]
426 fn prepare_keeps_raw_body_and_derives_meta() {
427 let p = prepare(raw(
428 "guide/intro.md",
429 "---\ntitle: Intro\n---\n# H\nbody [[index]]\n",
430 ));
431 assert_eq!(p.slug, "guide/intro");
432 assert_eq!(p.title, "Intro");
433 assert!(p.body_md.contains("[[index]]"));
434 assert!(!p.body_md.contains("title:")); }
436
437 #[test]
438 fn render_doc_matches_render_docs_for_one_doc() {
439 let prepared = vec![
442 prepare(raw("index.md", "# Home\nGo to [[guide/intro]].\n")),
443 prepare(raw(
444 "guide/intro.md",
445 "# Intro\n```rust\nfn x(){}\n```\nBack to [[index]] and [[ghost]].\n",
446 )),
447 ];
448 let slugs: SlugSet = prepared.iter().map(|p| p.slug.clone()).collect();
449 let cfg = docgen_config::SiteConfig::default();
450 let reg = docgen_components::Registry::empty();
451
452 let site = render_docs(prepared.clone(), &Partials::new(), &cfg, ®);
453 let single = render_doc(&prepared[1], &cfg, ®, &slugs, &Partials::new());
454
455 assert_eq!(single.doc.body_html, site.docs[1].body_html);
456 assert_eq!(single.doc.has_mermaid, site.docs[1].has_mermaid);
457 assert_eq!(single.doc.has_math, site.docs[1].has_math);
458 assert_eq!(single.doc.headings, site.docs[1].headings);
459 assert_eq!(single.search_text, site.search[1].text);
460 assert!(single.resolved_links.contains(&"index".to_string()));
462 assert!(!single.resolved_links.contains(&"ghost".to_string()));
463 }
464
465 #[test]
466 fn render_docs_resolves_links_highlights_and_indexes() {
467 let prepared = vec![
468 prepare(raw("index.md", "# Home\nGo to [[guide/intro]].\n")),
469 prepare(raw(
470 "guide/intro.md",
471 "# Intro\n```rust\nfn x(){}\n```\nBack to [[index]] and [[ghost]].\n",
472 )),
473 ];
474 let site = render_docs(
475 prepared,
476 &Partials::new(),
477 &docgen_config::SiteConfig::default(),
478 &docgen_components::Registry::empty(),
479 );
480
481 assert_eq!(site.docs[0].slug, "index");
483 assert_eq!(site.docs[1].slug, "guide/intro");
484
485 assert!(site.docs[0].body_html.contains(r#"href="/guide/intro""#));
487 assert!(site.docs[1]
489 .body_html
490 .contains(r#"<pre class="docgen-code">"#));
491 assert!(site.docs[1].body_html.contains(r#"href="/index""#));
492 assert!(site.docs[1].body_html.contains("docgen-wikilink--broken"));
493
494 assert!(site
496 .graph
497 .edges
498 .iter()
499 .any(|e| e.from == "index" && e.to == "guide/intro"));
500 assert!(site
501 .graph
502 .edges
503 .iter()
504 .any(|e| e.from == "guide/intro" && e.to == "index"));
505 assert!(!site.graph.edges.iter().any(|e| e.to == "ghost"));
506
507 assert_eq!(
509 site.graph.backlinks.get("index").unwrap()[0].slug,
510 "guide/intro"
511 );
512
513 assert_eq!(site.search.len(), 2);
515 let home = site.search.iter().find(|e| e.slug == "index").unwrap();
516 assert_eq!(home.title, "Home");
517 assert!(home.text.contains("Go to"));
518 assert!(!home.text.contains("[["));
519 }
520
521 #[test]
522 fn render_docs_renders_math_at_build_time() {
523 let prepared = vec![prepare(raw("m.md", "# M\nmass: $E=mc^2$\n"))];
524 let site = render_docs(
525 prepared,
526 &Partials::new(),
527 &docgen_config::SiteConfig::default(),
528 &docgen_components::Registry::empty(),
529 );
530 assert!(site.docs[0].body_html.contains("katex"));
531 assert!(site.docs[0].has_math);
532 assert!(!site.docs[0].body_html.contains("$E=mc^2$"));
533 }
534
535 #[test]
536 fn math_feature_off_skips_build_time_katex() {
537 let prepared = vec![prepare(raw("m.md", "# M\n$E=mc^2$\n"))];
538 let mut cfg = docgen_config::SiteConfig::default();
539 cfg.features.math = false;
540 let site = render_docs(
541 prepared,
542 &Partials::new(),
543 &cfg,
544 &docgen_components::Registry::empty(),
545 );
546 assert!(!site.docs[0].has_math);
547 assert!(!site.docs[0].body_html.contains("katex"));
548 }
549
550 #[test]
551 fn mermaid_feature_off_leaves_code_block() {
552 let prepared = vec![prepare(raw(
553 "d.md",
554 "# D\n```mermaid\ngraph TD;A-->B;\n```\n",
555 ))];
556 let mut cfg = docgen_config::SiteConfig::default();
557 cfg.features.mermaid = false;
558 let site = render_docs(
559 prepared,
560 &Partials::new(),
561 &cfg,
562 &docgen_components::Registry::empty(),
563 );
564 assert!(!site.docs[0].has_mermaid);
565 assert!(!site.any_mermaid);
566 }
567
568 #[test]
569 fn render_docs_marks_mermaid_pages_and_site() {
570 let prepared = vec![
571 prepare(raw("d.md", "# D\n```mermaid\ngraph TD;A-->B;\n```\n")),
572 prepare(raw("p.md", "# P\nplain\n")),
573 ];
574 let site = render_docs(
575 prepared,
576 &Partials::new(),
577 &docgen_config::SiteConfig::default(),
578 &docgen_components::Registry::empty(),
579 );
580 assert!(site.docs[0].has_mermaid && site.docs[0].body_html.contains("docgen-mermaid"));
581 assert!(!site.docs[1].has_mermaid);
582 assert!(site.any_mermaid);
583 }
584
585 #[test]
586 fn site_graph_data_matches_docs_and_links() {
587 let prepared = vec![
588 prepare(raw("index.md", "# Home\nGo to [[guide/intro]].\n")),
589 prepare(raw("guide/intro.md", "# Intro\nBack to [[index]].\n")),
590 ];
591 let site = render_docs(
592 prepared,
593 &Partials::new(),
594 &docgen_config::SiteConfig::default(),
595 &docgen_components::Registry::empty(),
596 );
597 let gd = site.graph_data(crate::graphlayout::LayoutParams::default());
598 assert_eq!(gd.nodes.len(), 2);
599 assert!(gd
600 .nodes
601 .iter()
602 .any(|n| n.slug == "index" && n.title == "Home"));
603 assert!(gd
604 .nodes
605 .iter()
606 .any(|n| n.slug == "guide/intro" && n.title == "Intro"));
607 let is_pair = |e: &crate::graphlayout::GraphDataEdge| {
609 (e.from == "index" && e.to == "guide/intro")
610 || (e.from == "guide/intro" && e.to == "index")
611 };
612 assert_eq!(gd.edges.iter().filter(|e| is_pair(e)).count(), 1);
613 assert_eq!(gd.edges.len(), 1);
614 }
615
616 #[test]
617 fn render_docs_without_mermaid_clears_site_flag() {
618 let prepared = vec![prepare(raw("p.md", "# P\nplain\n"))];
619 let site = render_docs(
620 prepared,
621 &Partials::new(),
622 &docgen_config::SiteConfig::default(),
623 &docgen_components::Registry::empty(),
624 );
625 assert!(!site.any_mermaid);
626 }
627
628 #[test]
629 fn render_docs_renders_callout_directive_with_inner_markdown() {
630 let mut reg = docgen_components::Registry::empty();
631 reg.insert(docgen_components::Component::from_parts(
632 "callout",
633 "<aside class=\"docgen-callout--{{ attrs.type | default('note') }}\">{{ content | safe }}</aside>",
634 None,
635 None,
636 ));
637 let prepared = vec![prepare(raw(
638 "d.md",
639 "# D\n\n:::callout{type=warning}\nBe **careful**.\n:::\n",
640 ))];
641 let site = render_docs(
642 prepared,
643 &Partials::new(),
644 &docgen_config::SiteConfig::default(),
645 ®,
646 );
647 let h = &site.docs[0].body_html;
648 assert!(h.contains("docgen-callout--warning"));
649 assert!(h.contains("<strong>careful</strong>")); assert!(site.docs[0].components_used.contains("callout"));
651 assert!(site.any_components);
652 }
653
654 #[test]
655 fn unknown_directive_in_doc_yields_error_span_not_crash() {
656 let prepared = vec![prepare(raw("d.md", "# D\n\n:nope[x]{}\n"))];
657 let site = render_docs(
658 prepared,
659 &Partials::new(),
660 &docgen_config::SiteConfig::default(),
661 &docgen_components::Registry::empty(),
662 );
663 assert!(site.docs[0].body_html.contains("docgen-directive-error"));
664 assert!(!site.any_components);
665 }
666
667 #[test]
668 fn wikilink_outside_directive_still_resolves() {
669 let mut reg = docgen_components::Registry::empty();
670 reg.insert(docgen_components::Component::from_parts(
671 "callout",
672 "<aside>{{ content | safe }}</aside>",
673 None,
674 None,
675 ));
676 let prepared = vec![
677 prepare(raw(
678 "index.md",
679 "# Home\nSee [[guide]].\n\n:::callout{}\nx\n:::\n",
680 )),
681 prepare(raw("guide.md", "# Guide\n")),
682 ];
683 let site = render_docs(
684 prepared,
685 &Partials::new(),
686 &docgen_config::SiteConfig::default(),
687 ®,
688 );
689 assert!(site.docs[0].body_html.contains(r#"href="/guide""#));
690 }
691
692 #[test]
693 fn wikilink_inside_directive_body_resolves_to_anchor() {
694 let mut reg = docgen_components::Registry::empty();
695 reg.insert(docgen_components::Component::from_parts(
696 "callout",
697 "<aside>{{ content | safe }}</aside>",
698 None,
699 None,
700 ));
701 let prepared = vec![
702 prepare(raw(
703 "index.md",
704 "# Home\n\n:::callout{}\nSee [[guide/intro|wikilink]] and [[ghost]].\n:::\n",
705 )),
706 prepare(raw("guide/intro.md", "# Intro\n")),
707 ];
708 let site = render_docs(
709 prepared,
710 &Partials::new(),
711 &docgen_config::SiteConfig::default(),
712 ®,
713 );
714 let h = &site.docs[0].body_html;
715 assert!(h.contains(r#"href="/guide/intro""#));
718 assert!(h.contains(r#">wikilink</a>"#));
719 assert!(!h.contains("[[guide/intro|wikilink]]"));
720 assert!(h.contains("docgen-wikilink--broken"));
722 assert!(!h.contains("[[ghost]]"));
723 }
724
725 #[test]
726 fn self_link_renders_anchor_but_no_self_backlink() {
727 let prepared = vec![prepare(raw("index.md", "# Home\nBack to [[index]].\n"))];
730 let site = render_docs(
731 prepared,
732 &Partials::new(),
733 &docgen_config::SiteConfig::default(),
734 &docgen_components::Registry::empty(),
735 );
736
737 assert!(site.docs[0].body_html.contains(r#"href="/index""#));
738 assert!(!site
739 .graph
740 .edges
741 .iter()
742 .any(|e| e.from == "index" && e.to == "index"));
743 assert!(!site.graph.backlinks.contains_key("index"));
744 }
745}