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