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