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