use std::collections::BTreeMap;
use comrak::{parse_document, Arena};
use crate::frontmatter::parse_frontmatter;
use crate::graph::{build_link_graph, LinkGraph};
use crate::markdown::{comrak_options, format_ast};
use crate::model::{Doc, RawDoc, SearchEntry};
use crate::search::plaintext;
use crate::wikilink::{transform_wikilinks, SlugSet};
pub type Partials = std::collections::BTreeMap<String, String>;
pub fn is_partial_rel(rel_path: &str) -> bool {
rel_path
.rsplit('/')
.next()
.map(|name| name.starts_with('_'))
.unwrap_or(false)
}
pub fn partition_partials(raws: Vec<RawDoc>) -> (Vec<RawDoc>, Partials) {
let mut pages = Vec::new();
let mut partials = Partials::new();
for raw in raws {
if is_partial_rel(&raw.rel_path) {
let body = parse_frontmatter(&raw.raw).body;
partials.insert(raw.rel_path, body);
} else {
pages.push(raw);
}
}
(pages, partials)
}
pub fn resolve_include_key(base_dir: &str, src: &str) -> Option<String> {
let src = src.trim();
let combined = if let Some(rest) = src.strip_prefix('/') {
rest.to_string()
} else if base_dir.is_empty() {
src.to_string()
} else {
format!("{base_dir}/{src}")
};
let mut parts: Vec<&str> = Vec::new();
for seg in combined.split('/') {
match seg {
"" | "." => continue,
".." => {
parts.pop()?;
}
s => parts.push(s),
}
}
Some(parts.join("/"))
}
#[derive(Debug, Clone, PartialEq)]
pub struct PreparedDoc {
pub rel_path: String,
pub slug: String,
pub title: String,
pub description: Option<String>,
pub body_md: String,
}
pub struct SiteBuild {
pub docs: Vec<Doc>,
pub graph: LinkGraph,
pub search: Vec<SearchEntry>,
pub any_mermaid: bool,
pub any_components: bool,
}
impl SiteBuild {
pub fn graph_data(
&self,
params: crate::graphlayout::LayoutParams,
) -> crate::graphlayout::GraphData {
let meta: Vec<(String, String)> = self
.docs
.iter()
.map(|d| (d.slug.clone(), d.title.clone()))
.collect();
crate::graphlayout::layout_graph(&meta, &self.graph, params)
}
}
fn first_h1(body: &str) -> Option<String> {
body.lines()
.find_map(|line| line.strip_prefix("# ").map(|h| h.trim().to_string()))
}
pub fn prepare(raw: RawDoc) -> PreparedDoc {
let parsed = parse_frontmatter(&raw.raw);
let slug = raw
.rel_path
.strip_suffix(".md")
.unwrap_or(&raw.rel_path)
.to_string();
let fm_title = parsed
.frontmatter
.get("title")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let title = fm_title
.or_else(|| first_h1(&parsed.body))
.unwrap_or_else(|| slug.rsplit('/').next().unwrap_or("").to_string());
let description = parsed
.frontmatter
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
PreparedDoc {
rel_path: raw.rel_path,
slug,
title,
description,
body_md: parsed.body,
}
}
pub fn render_block_markdown(
md: &str,
config: &docgen_config::SiteConfig,
registry: &docgen_components::Registry,
slugs: &SlugSet,
partials: &Partials,
base_dir: &str,
stack: &[String],
) -> String {
let (rewritten, instances) = crate::directivepass::extract(md);
let options = comrak_options();
let arena = Arena::new();
let root = parse_document(&arena, &rewritten, &options);
let _pass = transform_wikilinks(root, &arena, slugs, &config.base);
if config.features.math {
crate::mathpass::transform_math(root);
}
if config.features.mermaid {
crate::mermaidpass::transform_mermaid(root);
}
let inner_html = format_ast(root, &options);
let render_inner =
|m: &str| render_block_markdown(m, config, registry, slugs, partials, base_dir, stack);
let resolve_include =
|src: &str| resolve_include_src(src, base_dir, partials, stack, config, registry, slugs);
let (out, _used) = crate::directivepass::substitute(
&inner_html,
&instances,
registry,
&render_inner,
&resolve_include,
);
out
}
fn resolve_include_src(
src: &str,
base_dir: &str,
partials: &Partials,
stack: &[String],
config: &docgen_config::SiteConfig,
registry: &docgen_components::Registry,
slugs: &SlugSet,
) -> String {
let key = match resolve_include_key(base_dir, src) {
Some(k) => k,
None => return crate::directivepass::error_span("include", "src escapes docs root"),
};
if stack.iter().any(|s| s == &key) {
return crate::directivepass::error_span("include", "include cycle");
}
let Some(body) = partials.get(&key) else {
return crate::directivepass::error_span("include", "missing `src`");
};
let mut next = stack.to_vec();
next.push(key.clone());
let child_dir = key.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
render_block_markdown(body, config, registry, slugs, partials, child_dir, &next)
}
pub struct RenderedDoc {
pub doc: Doc,
pub search_text: String,
pub resolved_links: Vec<String>,
}
pub fn render_doc(
p: &PreparedDoc,
config: &docgen_config::SiteConfig,
registry: &docgen_components::Registry,
slugs: &SlugSet,
partials: &Partials,
) -> RenderedDoc {
let options = comrak_options();
let (rewritten, instances) = crate::directivepass::extract(&p.body_md);
let arena = Arena::new();
let root = parse_document(&arena, &rewritten, &options);
let search_text = plaintext(root);
let headings = crate::headings::collect_headings(root);
let pass = transform_wikilinks(root, &arena, slugs, &config.base);
let resolved_links = pass.resolved;
let math_count = if config.features.math {
crate::mathpass::transform_math(root)
} else {
0
};
let mermaid_count = if config.features.mermaid {
crate::mermaidpass::transform_mermaid(root)
} else {
0
};
let formatted = format_ast(root, &options);
let formatted = crate::headings::stamp_heading_ids(&formatted, &headings);
let base_dir = p.rel_path.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
let stack: Vec<String> = Vec::new();
let render_inner =
|m: &str| render_block_markdown(m, config, registry, slugs, partials, base_dir, &stack);
let resolve_include =
|src: &str| resolve_include_src(src, base_dir, partials, &stack, config, registry, slugs);
let (body_html, used) = crate::directivepass::substitute(
&formatted,
&instances,
registry,
&render_inner,
&resolve_include,
);
RenderedDoc {
doc: Doc {
rel_path: p.rel_path.clone(),
slug: p.slug.clone(),
title: p.title.clone(),
description: p.description.clone(),
body_html,
has_math: math_count > 0,
has_mermaid: mermaid_count > 0,
components_used: used,
headings,
},
search_text,
resolved_links,
}
}
pub fn render_docs(
prepared: Vec<PreparedDoc>,
partials: &Partials,
config: &docgen_config::SiteConfig,
registry: &docgen_components::Registry,
) -> SiteBuild {
let slugs: SlugSet = prepared.iter().map(|p| p.slug.clone()).collect();
let doc_meta: Vec<(String, String, Option<String>)> = prepared
.iter()
.map(|p| (p.slug.clone(), p.title.clone(), p.description.clone()))
.collect();
let mut docs = Vec::with_capacity(prepared.len());
let mut outbound: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut search = Vec::with_capacity(prepared.len());
for p in &prepared {
let rendered = render_doc(p, config, registry, &slugs, partials);
search.push(SearchEntry {
slug: p.slug.clone(),
title: p.title.clone(),
text: rendered.search_text,
});
outbound.insert(p.slug.clone(), rendered.resolved_links);
docs.push(rendered.doc);
}
let graph = build_link_graph(&doc_meta, &outbound);
let any_mermaid = docs.iter().any(|d| d.has_mermaid);
let any_components = docs.iter().any(|d| !d.components_used.is_empty());
SiteBuild {
docs,
graph,
search,
any_mermaid,
any_components,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::RawDoc;
fn raw(path: &str, body: &str) -> RawDoc {
RawDoc {
rel_path: path.into(),
raw: body.into(),
}
}
#[test]
fn is_partial_rel_detects_underscore_basename() {
assert!(is_partial_rel("dev/server/_systems.gen.md"));
assert!(is_partial_rel("_root.md"));
assert!(!is_partial_rel("dev/server/index.md"));
assert!(!is_partial_rel("dev/_dir/page.md")); }
#[test]
fn partition_partials_splits_pages_and_strips_frontmatter() {
let raws = vec![
raw("a/index.md", "# Page\n"),
raw("a/_inc.md", "---\ntitle: x\n---\n## Inc\n"),
];
let (pages, partials) = partition_partials(raws);
assert_eq!(pages.len(), 1);
assert_eq!(pages[0].rel_path, "a/index.md");
assert_eq!(
partials.get("a/_inc.md").map(String::as_str),
Some("## Inc\n")
);
}
#[test]
fn resolve_include_key_normalizes_relative_and_absolute() {
assert_eq!(
resolve_include_key("dev/server", "./_s.gen.md").as_deref(),
Some("dev/server/_s.gen.md")
);
assert_eq!(
resolve_include_key("dev/server", "../_top.md").as_deref(),
Some("dev/_top.md")
);
assert_eq!(
resolve_include_key("dev/server", "/root/_x.md").as_deref(),
Some("root/_x.md")
);
assert_eq!(resolve_include_key("", "_x.md").as_deref(), Some("_x.md"));
assert_eq!(resolve_include_key("dev", "../../escape.md"), None); }
#[test]
fn prepare_keeps_raw_body_and_derives_meta() {
let p = prepare(raw(
"guide/intro.md",
"---\ntitle: Intro\n---\n# H\nbody [[index]]\n",
));
assert_eq!(p.slug, "guide/intro");
assert_eq!(p.title, "Intro");
assert!(p.body_md.contains("[[index]]"));
assert!(!p.body_md.contains("title:")); }
#[test]
fn render_doc_matches_render_docs_for_one_doc() {
let prepared = vec![
prepare(raw("index.md", "# Home\nGo to [[guide/intro]].\n")),
prepare(raw(
"guide/intro.md",
"# Intro\n```rust\nfn x(){}\n```\nBack to [[index]] and [[ghost]].\n",
)),
];
let slugs: SlugSet = prepared.iter().map(|p| p.slug.clone()).collect();
let cfg = docgen_config::SiteConfig::default();
let reg = docgen_components::Registry::empty();
let site = render_docs(prepared.clone(), &Partials::new(), &cfg, ®);
let single = render_doc(&prepared[1], &cfg, ®, &slugs, &Partials::new());
assert_eq!(single.doc.body_html, site.docs[1].body_html);
assert_eq!(single.doc.has_mermaid, site.docs[1].has_mermaid);
assert_eq!(single.doc.has_math, site.docs[1].has_math);
assert_eq!(single.doc.headings, site.docs[1].headings);
assert_eq!(single.search_text, site.search[1].text);
assert!(single.resolved_links.contains(&"index".to_string()));
assert!(!single.resolved_links.contains(&"ghost".to_string()));
}
#[test]
fn render_docs_resolves_links_highlights_and_indexes() {
let prepared = vec![
prepare(raw("index.md", "# Home\nGo to [[guide/intro]].\n")),
prepare(raw(
"guide/intro.md",
"# Intro\n```rust\nfn x(){}\n```\nBack to [[index]] and [[ghost]].\n",
)),
];
let site = render_docs(
prepared,
&Partials::new(),
&docgen_config::SiteConfig::default(),
&docgen_components::Registry::empty(),
);
assert_eq!(site.docs[0].slug, "index");
assert_eq!(site.docs[1].slug, "guide/intro");
assert!(site.docs[0].body_html.contains(r#"href="/guide/intro""#));
assert!(site.docs[1]
.body_html
.contains(r#"<pre class="docgen-code">"#));
assert!(site.docs[1].body_html.contains(r#"href="/index""#));
assert!(site.docs[1].body_html.contains("docgen-wikilink--broken"));
assert!(site
.graph
.edges
.iter()
.any(|e| e.from == "index" && e.to == "guide/intro"));
assert!(site
.graph
.edges
.iter()
.any(|e| e.from == "guide/intro" && e.to == "index"));
assert!(!site.graph.edges.iter().any(|e| e.to == "ghost"));
assert_eq!(
site.graph.backlinks.get("index").unwrap()[0].slug,
"guide/intro"
);
assert_eq!(site.search.len(), 2);
let home = site.search.iter().find(|e| e.slug == "index").unwrap();
assert_eq!(home.title, "Home");
assert!(home.text.contains("Go to"));
assert!(!home.text.contains("[["));
}
#[test]
fn render_docs_renders_math_at_build_time() {
let prepared = vec![prepare(raw("m.md", "# M\nmass: $E=mc^2$\n"))];
let site = render_docs(
prepared,
&Partials::new(),
&docgen_config::SiteConfig::default(),
&docgen_components::Registry::empty(),
);
assert!(site.docs[0].body_html.contains("katex"));
assert!(site.docs[0].has_math);
assert!(!site.docs[0].body_html.contains("$E=mc^2$"));
}
#[test]
fn math_feature_off_skips_build_time_katex() {
let prepared = vec![prepare(raw("m.md", "# M\n$E=mc^2$\n"))];
let mut cfg = docgen_config::SiteConfig::default();
cfg.features.math = false;
let site = render_docs(
prepared,
&Partials::new(),
&cfg,
&docgen_components::Registry::empty(),
);
assert!(!site.docs[0].has_math);
assert!(!site.docs[0].body_html.contains("katex"));
}
#[test]
fn mermaid_feature_off_leaves_code_block() {
let prepared = vec![prepare(raw(
"d.md",
"# D\n```mermaid\ngraph TD;A-->B;\n```\n",
))];
let mut cfg = docgen_config::SiteConfig::default();
cfg.features.mermaid = false;
let site = render_docs(
prepared,
&Partials::new(),
&cfg,
&docgen_components::Registry::empty(),
);
assert!(!site.docs[0].has_mermaid);
assert!(!site.any_mermaid);
}
#[test]
fn render_docs_marks_mermaid_pages_and_site() {
let prepared = vec![
prepare(raw("d.md", "# D\n```mermaid\ngraph TD;A-->B;\n```\n")),
prepare(raw("p.md", "# P\nplain\n")),
];
let site = render_docs(
prepared,
&Partials::new(),
&docgen_config::SiteConfig::default(),
&docgen_components::Registry::empty(),
);
assert!(site.docs[0].has_mermaid && site.docs[0].body_html.contains("docgen-mermaid"));
assert!(!site.docs[1].has_mermaid);
assert!(site.any_mermaid);
}
#[test]
fn site_graph_data_matches_docs_and_links() {
let prepared = vec![
prepare(raw("index.md", "# Home\nGo to [[guide/intro]].\n")),
prepare(raw("guide/intro.md", "# Intro\nBack to [[index]].\n")),
];
let site = render_docs(
prepared,
&Partials::new(),
&docgen_config::SiteConfig::default(),
&docgen_components::Registry::empty(),
);
let gd = site.graph_data(crate::graphlayout::LayoutParams::default());
assert_eq!(gd.nodes.len(), 2);
assert!(gd
.nodes
.iter()
.any(|n| n.slug == "index" && n.title == "Home"));
assert!(gd
.nodes
.iter()
.any(|n| n.slug == "guide/intro" && n.title == "Intro"));
let is_pair = |e: &crate::graphlayout::GraphDataEdge| {
(e.from == "index" && e.to == "guide/intro")
|| (e.from == "guide/intro" && e.to == "index")
};
assert_eq!(gd.edges.iter().filter(|e| is_pair(e)).count(), 1);
assert_eq!(gd.edges.len(), 1);
}
#[test]
fn render_docs_without_mermaid_clears_site_flag() {
let prepared = vec![prepare(raw("p.md", "# P\nplain\n"))];
let site = render_docs(
prepared,
&Partials::new(),
&docgen_config::SiteConfig::default(),
&docgen_components::Registry::empty(),
);
assert!(!site.any_mermaid);
}
#[test]
fn render_docs_renders_callout_directive_with_inner_markdown() {
let mut reg = docgen_components::Registry::empty();
reg.insert(docgen_components::Component::from_parts(
"callout",
"<aside class=\"docgen-callout--{{ attrs.type | default('note') }}\">{{ content | safe }}</aside>",
None,
None,
));
let prepared = vec![prepare(raw(
"d.md",
"# D\n\n:::callout{type=warning}\nBe **careful**.\n:::\n",
))];
let site = render_docs(
prepared,
&Partials::new(),
&docgen_config::SiteConfig::default(),
®,
);
let h = &site.docs[0].body_html;
assert!(h.contains("docgen-callout--warning"));
assert!(h.contains("<strong>careful</strong>")); assert!(site.docs[0].components_used.contains("callout"));
assert!(site.any_components);
}
#[test]
fn unknown_directive_in_doc_yields_error_span_not_crash() {
let prepared = vec![prepare(raw("d.md", "# D\n\n:nope[x]{}\n"))];
let site = render_docs(
prepared,
&Partials::new(),
&docgen_config::SiteConfig::default(),
&docgen_components::Registry::empty(),
);
assert!(site.docs[0].body_html.contains("docgen-directive-error"));
assert!(!site.any_components);
}
#[test]
fn wikilink_outside_directive_still_resolves() {
let mut reg = docgen_components::Registry::empty();
reg.insert(docgen_components::Component::from_parts(
"callout",
"<aside>{{ content | safe }}</aside>",
None,
None,
));
let prepared = vec![
prepare(raw(
"index.md",
"# Home\nSee [[guide]].\n\n:::callout{}\nx\n:::\n",
)),
prepare(raw("guide.md", "# Guide\n")),
];
let site = render_docs(
prepared,
&Partials::new(),
&docgen_config::SiteConfig::default(),
®,
);
assert!(site.docs[0].body_html.contains(r#"href="/guide""#));
}
#[test]
fn wikilink_inside_directive_body_resolves_to_anchor() {
let mut reg = docgen_components::Registry::empty();
reg.insert(docgen_components::Component::from_parts(
"callout",
"<aside>{{ content | safe }}</aside>",
None,
None,
));
let prepared = vec![
prepare(raw(
"index.md",
"# Home\n\n:::callout{}\nSee [[guide/intro|wikilink]] and [[ghost]].\n:::\n",
)),
prepare(raw("guide/intro.md", "# Intro\n")),
];
let site = render_docs(
prepared,
&Partials::new(),
&docgen_config::SiteConfig::default(),
®,
);
let h = &site.docs[0].body_html;
assert!(h.contains(r#"href="/guide/intro""#));
assert!(h.contains(r#">wikilink</a>"#));
assert!(!h.contains("[[guide/intro|wikilink]]"));
assert!(h.contains("docgen-wikilink--broken"));
assert!(!h.contains("[[ghost]]"));
}
#[test]
fn self_link_renders_anchor_but_no_self_backlink() {
let prepared = vec![prepare(raw("index.md", "# Home\nBack to [[index]].\n"))];
let site = render_docs(
prepared,
&Partials::new(),
&docgen_config::SiteConfig::default(),
&docgen_components::Registry::empty(),
);
assert!(site.docs[0].body_html.contains(r#"href="/index""#));
assert!(!site
.graph
.edges
.iter()
.any(|e| e.from == "index" && e.to == "index"));
assert!(!site.graph.backlinks.contains_key("index"));
}
}