Skip to main content

docgen_core/
pipeline.rs

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
12/// Docs-relative path → frontmatter-stripped body, for `:include` targets.
13pub type Partials = std::collections::BTreeMap<String, String>;
14
15/// Docs-relative path → raw PlantUML source, for `:::plantuml{src=...}` targets.
16/// Preloaded by the build (analogous to [`Partials`]) so the render pipeline
17/// itself performs no filesystem reads.
18pub type Diagrams = std::collections::BTreeMap<String, String>;
19
20/// A doc is an include-only *partial* (never its own page) when its filename
21/// starts with `_`. Only the basename matters — a `_dir/` directory does not
22/// hide the pages inside it.
23pub fn is_partial_rel(rel_path: &str) -> bool {
24    rel_path
25        .rsplit('/')
26        .next()
27        .map(|name| name.starts_with('_'))
28        .unwrap_or(false)
29}
30
31/// Split discovered raw docs into rendered pages and the include-only partial
32/// map (keyed by docs-relative path, frontmatter stripped).
33pub fn partition_partials(raws: Vec<RawDoc>) -> (Vec<RawDoc>, Partials) {
34    let mut pages = Vec::new();
35    let mut partials = Partials::new();
36    for raw in raws {
37        if is_partial_rel(&raw.rel_path) {
38            let body = parse_frontmatter(&raw.raw).body;
39            partials.insert(raw.rel_path, body);
40        } else {
41            pages.push(raw);
42        }
43    }
44    (pages, partials)
45}
46
47/// Resolve a relative include `src` against the docs-relative directory
48/// `base_dir` into a normalized docs-relative key (no `./`, `..` collapsed). A
49/// leading `/` is treated as docs-root-absolute. Returns `None` if the path
50/// escapes above the docs root.
51pub fn resolve_include_key(base_dir: &str, src: &str) -> Option<String> {
52    let src = src.trim();
53    let combined = if let Some(rest) = src.strip_prefix('/') {
54        rest.to_string()
55    } else if base_dir.is_empty() {
56        src.to_string()
57    } else {
58        format!("{base_dir}/{src}")
59    };
60    let mut parts: Vec<&str> = Vec::new();
61    for seg in combined.split('/') {
62        match seg {
63            "" | "." => continue,
64            ".." => {
65                parts.pop()?;
66            }
67            s => parts.push(s),
68        }
69    }
70    Some(parts.join("/"))
71}
72
73/// A document after pass 1: frontmatter parsed, slug/title derived, raw body kept.
74#[derive(Debug, Clone, PartialEq)]
75pub struct PreparedDoc {
76    pub rel_path: String,
77    pub slug: String,
78    pub title: String,
79    /// Optional `description:` from frontmatter, surfaced in backlink cards.
80    pub description: Option<String>,
81    pub body_md: String,
82    /// The full parsed frontmatter, retained so the bases engine can build a
83    /// queryable corpus of note properties. `Value::Null` when there is none.
84    pub frontmatter: serde_yml::Value,
85}
86
87/// The fully assembled site after pass 2.
88pub struct SiteBuild {
89    pub docs: Vec<Doc>,
90    pub graph: LinkGraph,
91    /// Per-doc resolved outbound link targets (slug → target slugs), the input
92    /// the link graph was built from. Retained so an incremental rebuild can
93    /// reconstruct the graph after re-rendering only the changed docs (swapping
94    /// their entries) instead of re-rendering every doc.
95    pub outbound: BTreeMap<String, Vec<String>>,
96    pub search: Vec<SearchEntry>,
97    /// True if any doc contains a mermaid diagram. Lets the build subcommand flip
98    /// `EmitOptions.include_mermaid` once for the whole site.
99    pub any_mermaid: bool,
100    /// True if any doc used ≥1 custom component (gates the components asset slice).
101    pub any_components: bool,
102}
103
104impl SiteBuild {
105    /// Build the deterministic `GraphData` for the `/graph/` page from this
106    /// site's docs (node order = doc order) and its already-built `LinkGraph`.
107    /// Never recomputes links.
108    pub fn graph_data(
109        &self,
110        params: crate::graphlayout::LayoutParams,
111    ) -> crate::graphlayout::GraphData {
112        let meta: Vec<(String, String)> = self
113            .docs
114            .iter()
115            .map(|d| (d.slug.clone(), d.title.clone()))
116            .collect();
117        crate::graphlayout::layout_graph(&meta, &self.graph, params)
118    }
119}
120
121/// The first `# `-prefixed line of a body, as the build's title fallback sees
122/// it. Deliberately a raw line scan (NOT an AST walk): a `# x` line inside a
123/// code fence still counts. Public so the linter's `missing-title` rule can
124/// mirror the build's derivation exactly instead of drifting on edge cases.
125pub fn first_h1(body: &str) -> Option<String> {
126    body.lines()
127        .find_map(|line| line.strip_prefix("# ").map(|h| h.trim().to_string()))
128}
129
130/// Pass 1: pure per-doc preparation, no cross-doc knowledge.
131pub fn prepare(raw: RawDoc) -> PreparedDoc {
132    let parsed = parse_frontmatter(&raw.raw);
133    let slug = raw
134        .rel_path
135        .strip_suffix(".md")
136        .unwrap_or(&raw.rel_path)
137        .to_string();
138
139    let fm_title = parsed
140        .frontmatter
141        .get("title")
142        .and_then(|v| v.as_str())
143        .map(|s| s.to_string());
144    let title = fm_title
145        .or_else(|| first_h1(&parsed.body))
146        .unwrap_or_else(|| slug.rsplit('/').next().unwrap_or("").to_string());
147
148    let description = parsed
149        .frontmatter
150        .get("description")
151        .and_then(|v| v.as_str())
152        .map(|s| s.to_string());
153
154    PreparedDoc {
155        rel_path: raw.rel_path,
156        slug,
157        title,
158        description,
159        body_md: parsed.body,
160        frontmatter: parsed.frontmatter,
161    }
162}
163
164/// Render a markdown fragment (a block directive's inner content) to inner HTML,
165/// running the full directive + AST pipeline but emitting no page chrome.
166///
167/// Wikilinks inside a directive body are resolved against the same site `slugs`
168/// and `base` as top-level body content, so `[[target|label]]` becomes a resolved
169/// `<a>` (or a broken span) exactly as it would outside a directive. The
170/// nested-directive case works because `substitute` recurses through this fn.
171///
172/// Note: resolved targets discovered inside directive bodies are NOT folded into
173/// the link graph / backlinks (the graph is built from the top-level pass only);
174/// the rendered HTML is correct, but a wikilink that *only* appears inside a
175/// directive body does not yet create a graph edge.
176#[allow(clippy::too_many_arguments)]
177pub fn render_block_markdown(
178    md: &str,
179    config: &docgen_config::SiteConfig,
180    registry: &docgen_components::Registry,
181    slugs: &SlugSet,
182    partials: &Partials,
183    base_dir: &str,
184    stack: &[String],
185    asset_urls: Option<&dyn crate::asseturl::AssetUrlResolver>,
186    plantuml: Option<&crate::plantuml::PlantumlSupport>,
187) -> String {
188    let (rewritten, instances) = crate::directivepass::extract(md);
189    let options = comrak_options();
190    let arena = Arena::new();
191    let root = parse_document(&arena, &rewritten, &options);
192    // Resolve wikilinks in the directive body the same way top-level body content
193    // does, before math/mermaid rewrite their nodes.
194    let _pass = transform_wikilinks(root, &arena, slugs, &config.base);
195    // Relative asset references inside a directive body resolve against the same
196    // source directory the directive/include lives in (`base_dir`).
197    crate::assetpass::transform_asset_urls(root, &config.base, base_dir, slugs, asset_urls);
198    if config.features.math {
199        crate::mathpass::transform_math(root);
200    }
201    if config.features.mermaid {
202        crate::mermaidpass::transform_mermaid(root);
203    }
204    // Wrap tables in a horizontal-scroll container (also inside directive/include
205    // bodies, so a table transcluded via `:include` scrolls like a top-level one).
206    crate::tablepass::transform_tables(root, &arena);
207    let inner_html = format_ast(root, &options);
208    let render_inner = |m: &str| {
209        render_block_markdown(
210            m, config, registry, slugs, partials, base_dir, stack, asset_urls, plantuml,
211        )
212    };
213    let resolve_include = |src: &str| {
214        resolve_include_src(
215            src, base_dir, partials, stack, config, registry, slugs, asset_urls, plantuml,
216        )
217    };
218    let render_plantuml = |idx: usize, inst: &crate::directivepass::DirectiveInstance| {
219        crate::plantuml::render_directive(
220            inst,
221            base_dir,
222            plantuml,
223            &format!("docgen-plantuml-{idx}"),
224        )
225    };
226    let (out, _used) = crate::directivepass::substitute(
227        &inner_html,
228        &instances,
229        registry,
230        &render_inner,
231        &resolve_include,
232        &render_plantuml,
233    );
234    out
235}
236
237/// Resolve `:include{src}` against `base_dir`, render the partial's body through
238/// the recursive pipeline. Missing target or an include cycle degrades to an
239/// inert error span (never panics). `stack` holds the include keys currently on
240/// the rendering path, for cycle detection.
241#[allow(clippy::too_many_arguments)]
242fn resolve_include_src(
243    src: &str,
244    base_dir: &str,
245    partials: &Partials,
246    stack: &[String],
247    config: &docgen_config::SiteConfig,
248    registry: &docgen_components::Registry,
249    slugs: &SlugSet,
250    asset_urls: Option<&dyn crate::asseturl::AssetUrlResolver>,
251    plantuml: Option<&crate::plantuml::PlantumlSupport>,
252) -> String {
253    let key = match resolve_include_key(base_dir, src) {
254        Some(k) => k,
255        None => return crate::directivepass::error_span("include", "src escapes docs root"),
256    };
257    if stack.iter().any(|s| s == &key) {
258        return crate::directivepass::error_span("include", "include cycle");
259    }
260    let Some(body) = partials.get(&key) else {
261        return crate::directivepass::error_span("include", "missing `src`");
262    };
263    let mut next = stack.to_vec();
264    next.push(key.clone());
265    let child_dir = key.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
266    render_block_markdown(
267        body, config, registry, slugs, partials, child_dir, &next, asset_urls, plantuml,
268    )
269}
270
271/// A single rendered doc plus the by-products the site assembly needs: its
272/// search plaintext and the slugs it links out to (for the link graph). Returned
273/// by [`render_doc`] so both the whole-site build and the editor live-preview run
274/// the *same* per-doc pipeline rather than two drifting copies.
275pub struct RenderedDoc {
276    pub doc: Doc,
277    /// Plaintext extracted from the pristine AST (no markup), for the search index.
278    pub search_text: String,
279    /// Resolved outbound wikilink target slugs, in document order (for the graph).
280    pub resolved_links: Vec<String>,
281}
282
283/// Render ONE prepared doc to its final inner HTML, running the full per-doc
284/// pipeline: directive pre-pass → parse → search plaintext → headings → wikilink
285/// resolve → math → mermaid → format → heading-id stamp → directive substitute.
286///
287/// `slugs` is the *whole site's* slug set so `[[wikilinks]]` resolve against every
288/// doc, not just this one — the caller must build it from all docs. This is the
289/// single source of truth the static build ([`render_docs`]) and the dev server's
290/// editor preview both call, so a doc previewed in the editor renders byte-for-byte
291/// like its published page.
292#[allow(clippy::too_many_arguments)]
293pub fn render_doc(
294    p: &PreparedDoc,
295    config: &docgen_config::SiteConfig,
296    registry: &docgen_components::Registry,
297    slugs: &SlugSet,
298    partials: &Partials,
299    asset_urls: Option<&dyn crate::asseturl::AssetUrlResolver>,
300    plantuml: Option<&crate::plantuml::PlantumlSupport>,
301    bases: Option<&docgen_bases::Corpus>,
302) -> RenderedDoc {
303    let options = comrak_options();
304
305    // Directive pre-pass: rewrite the raw body, replacing each `:::`/`:leaf`
306    // directive with an HTML-comment sentinel that survives comrak verbatim.
307    let (rewritten, instances) = crate::directivepass::extract(&p.body_md);
308
309    // Parse the (directive-free) body once. Extract search plaintext from the
310    // pristine AST *before* the wikilink pass rewrites `[[...]]` Text nodes.
311    let arena = Arena::new();
312    let root = parse_document(&arena, &rewritten, &options);
313
314    let search_text = plaintext(root);
315
316    // Heading outline for the right-rail TOC. Collected from the pristine
317    // AST (after parse, before formatting) so the anchorized ids match what
318    // `stamp_heading_ids` writes onto the rendered tags below.
319    let headings = crate::headings::collect_headings(root);
320
321    // Wikilink AST pass (mutates `root`) + highlighted HTML.
322    let pass = transform_wikilinks(root, &arena, slugs, &config.base);
323    let resolved_links = pass.resolved;
324    // Rewrite relative asset references (`![](./img.png)`, `[x](./y.pdf)`) to
325    // base-absolute URLs resolved against this page's source directory, so they
326    // survive clean-URL nesting and point at the copied asset.
327    let source_dir = p.rel_path.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
328    crate::assetpass::transform_asset_urls(root, &config.base, source_dir, slugs, asset_urls);
329    // Build-time math: replace math nodes with KaTeX HTML before formatting.
330    let math_count = if config.features.math {
331        crate::mathpass::transform_math(root)
332    } else {
333        0
334    };
335    // Mermaid: replace ```mermaid fences with island containers before formatting.
336    let mermaid_count = if config.features.mermaid {
337        crate::mermaidpass::transform_mermaid(root)
338    } else {
339        0
340    };
341    // Obsidian Bases: replace ```base fenced blocks with rendered view HTML,
342    // computed against the whole-site corpus. Feature-gated + inert without a
343    // corpus (embedded bases render as plain code then).
344    if config.features.bases {
345        if let Some(corpus) = bases {
346            crate::basepass::transform_bases(root, corpus, &config.base);
347        }
348    }
349    // Wrap every table in a horizontal-scroll container so wide tables scroll
350    // instead of squishing their columns (desktop and mobile alike).
351    crate::tablepass::transform_tables(root, &arena);
352    let formatted = format_ast(root, &options);
353    // Stamp the anchorized ids onto the `<h2>`/`<h3>` tags so the rail TOC +
354    // scroll-spy can target them via `h2[id]` / `h3[id]`.
355    let formatted = crate::headings::stamp_heading_ids(&formatted, &headings);
356
357    // Directive post-pass: substitute each sentinel with the component's
358    // rendered HTML; block inner content + `:include` partials are rendered by
359    // the full recursive pipeline. `used` drives per-page island/style gating.
360    let base_dir = p.rel_path.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
361    let stack: Vec<String> = Vec::new();
362    let render_inner = |m: &str| {
363        render_block_markdown(
364            m, config, registry, slugs, partials, base_dir, &stack, asset_urls, plantuml,
365        )
366    };
367    let resolve_include = |src: &str| {
368        resolve_include_src(
369            src, base_dir, partials, &stack, config, registry, slugs, asset_urls, plantuml,
370        )
371    };
372    let render_plantuml = |idx: usize, inst: &crate::directivepass::DirectiveInstance| {
373        crate::plantuml::render_directive(
374            inst,
375            base_dir,
376            plantuml,
377            &format!("docgen-plantuml-{idx}"),
378        )
379    };
380    let (body_html, used) = crate::directivepass::substitute(
381        &formatted,
382        &instances,
383        registry,
384        &render_inner,
385        &resolve_include,
386        &render_plantuml,
387    );
388
389    RenderedDoc {
390        doc: Doc {
391            rel_path: p.rel_path.clone(),
392            slug: p.slug.clone(),
393            title: p.title.clone(),
394            description: p.description.clone(),
395            body_html,
396            has_math: math_count > 0,
397            has_mermaid: mermaid_count > 0,
398            components_used: used,
399            headings,
400        },
401        search_text,
402        resolved_links,
403    }
404}
405
406/// Pass 2: build the slug set, run the wikilink pass + syntect highlight per doc,
407/// assemble the link graph + search index. Input order preserved.
408#[allow(clippy::too_many_arguments)]
409pub fn render_docs(
410    prepared: Vec<PreparedDoc>,
411    partials: &Partials,
412    config: &docgen_config::SiteConfig,
413    registry: &docgen_components::Registry,
414    asset_urls: Option<&dyn crate::asseturl::AssetUrlResolver>,
415    plantuml: Option<&crate::plantuml::PlantumlSupport>,
416    bases: Option<&docgen_bases::Corpus>,
417) -> SiteBuild {
418    let slugs: SlugSet = prepared.iter().map(|p| p.slug.clone()).collect();
419    let doc_meta: Vec<(String, String, Option<String>)> = prepared
420        .iter()
421        .map(|p| (p.slug.clone(), p.title.clone(), p.description.clone()))
422        .collect();
423
424    let mut docs = Vec::with_capacity(prepared.len());
425    let mut outbound: BTreeMap<String, Vec<String>> = BTreeMap::new();
426    let mut search = Vec::with_capacity(prepared.len());
427
428    for p in &prepared {
429        // Same per-doc pipeline the editor preview runs (single source of truth).
430        let rendered = render_doc(
431            p, config, registry, &slugs, partials, asset_urls, plantuml, bases,
432        );
433        search.push(SearchEntry {
434            slug: p.slug.clone(),
435            title: p.title.clone(),
436            text: rendered.search_text,
437        });
438        outbound.insert(p.slug.clone(), rendered.resolved_links);
439        docs.push(rendered.doc);
440    }
441
442    let graph = build_link_graph(&doc_meta, &outbound);
443    let any_mermaid = docs.iter().any(|d| d.has_mermaid);
444    let any_components = docs.iter().any(|d| !d.components_used.is_empty());
445    SiteBuild {
446        docs,
447        graph,
448        outbound,
449        search,
450        any_mermaid,
451        any_components,
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use crate::model::RawDoc;
459
460    fn raw(path: &str, body: &str) -> RawDoc {
461        RawDoc {
462            rel_path: path.into(),
463            raw: body.into(),
464        }
465    }
466
467    #[test]
468    fn is_partial_rel_detects_underscore_basename() {
469        assert!(is_partial_rel("dev/server/_systems.gen.md"));
470        assert!(is_partial_rel("_root.md"));
471        assert!(!is_partial_rel("dev/server/index.md"));
472        assert!(!is_partial_rel("dev/_dir/page.md")); // only the *basename* counts
473    }
474
475    #[test]
476    fn partition_partials_splits_pages_and_strips_frontmatter() {
477        let raws = vec![
478            raw("a/index.md", "# Page\n"),
479            raw("a/_inc.md", "---\ntitle: x\n---\n## Inc\n"),
480        ];
481        let (pages, partials) = partition_partials(raws);
482        assert_eq!(pages.len(), 1);
483        assert_eq!(pages[0].rel_path, "a/index.md");
484        assert_eq!(
485            partials.get("a/_inc.md").map(String::as_str),
486            Some("## Inc\n")
487        );
488    }
489
490    #[test]
491    fn resolve_include_key_normalizes_relative_and_absolute() {
492        assert_eq!(
493            resolve_include_key("dev/server", "./_s.gen.md").as_deref(),
494            Some("dev/server/_s.gen.md")
495        );
496        assert_eq!(
497            resolve_include_key("dev/server", "../_top.md").as_deref(),
498            Some("dev/_top.md")
499        );
500        assert_eq!(
501            resolve_include_key("dev/server", "/root/_x.md").as_deref(),
502            Some("root/_x.md")
503        );
504        assert_eq!(resolve_include_key("", "_x.md").as_deref(), Some("_x.md"));
505        assert_eq!(resolve_include_key("dev", "../../escape.md"), None); // escapes docs root
506    }
507
508    #[test]
509    fn prepare_keeps_raw_body_and_derives_meta() {
510        let p = prepare(raw(
511            "guide/intro.md",
512            "---\ntitle: Intro\n---\n# H\nbody [[index]]\n",
513        ));
514        assert_eq!(p.slug, "guide/intro");
515        assert_eq!(p.title, "Intro");
516        assert!(p.body_md.contains("[[index]]"));
517        assert!(!p.body_md.contains("title:")); // frontmatter stripped
518    }
519
520    #[test]
521    fn render_doc_matches_render_docs_for_one_doc() {
522        // The preview path (render_doc) and the build path (render_docs) must run
523        // the identical per-doc pipeline: same body_html, search text, and links.
524        let prepared = vec![
525            prepare(raw("index.md", "# Home\nGo to [[guide/intro]].\n")),
526            prepare(raw(
527                "guide/intro.md",
528                "# Intro\n```rust\nfn x(){}\n```\nBack to [[index]] and [[ghost]].\n",
529            )),
530        ];
531        let slugs: SlugSet = prepared.iter().map(|p| p.slug.clone()).collect();
532        let cfg = docgen_config::SiteConfig::default();
533        let reg = docgen_components::Registry::empty();
534
535        let site = render_docs(
536            prepared.clone(),
537            &Partials::new(),
538            &cfg,
539            &reg,
540            None,
541            None,
542            None,
543        );
544        let single = render_doc(
545            &prepared[1],
546            &cfg,
547            &reg,
548            &slugs,
549            &Partials::new(),
550            None,
551            None,
552            None,
553        );
554
555        assert_eq!(single.doc.body_html, site.docs[1].body_html);
556        assert_eq!(single.doc.has_mermaid, site.docs[1].has_mermaid);
557        assert_eq!(single.doc.has_math, site.docs[1].has_math);
558        assert_eq!(single.doc.headings, site.docs[1].headings);
559        assert_eq!(single.search_text, site.search[1].text);
560        // Resolved outbound links match what the graph was built from (ghost dropped).
561        assert!(single.resolved_links.contains(&"index".to_string()));
562        assert!(!single.resolved_links.contains(&"ghost".to_string()));
563    }
564
565    #[test]
566    fn render_docs_resolves_links_highlights_and_indexes() {
567        let prepared = vec![
568            prepare(raw("index.md", "# Home\nGo to [[guide/intro]].\n")),
569            prepare(raw(
570                "guide/intro.md",
571                "# Intro\n```rust\nfn x(){}\n```\nBack to [[index]] and [[ghost]].\n",
572            )),
573        ];
574        let site = render_docs(
575            prepared,
576            &Partials::new(),
577            &docgen_config::SiteConfig::default(),
578            &docgen_components::Registry::empty(),
579            None,
580            None,
581            None,
582        );
583
584        // Doc order preserved.
585        assert_eq!(site.docs[0].slug, "index");
586        assert_eq!(site.docs[1].slug, "guide/intro");
587
588        // index links to guide/intro (resolved anchor).
589        assert!(site.docs[0].body_html.contains(r#"href="/guide/intro""#));
590        // intro has highlighted code (class-based) + a resolved link + a broken span.
591        assert!(site.docs[1]
592            .body_html
593            .contains(r#"<pre class="docgen-code">"#));
594        assert!(site.docs[1].body_html.contains(r#"href="/index""#));
595        assert!(site.docs[1].body_html.contains("docgen-wikilink--broken"));
596
597        // Graph: index->guide/intro and guide/intro->index (ghost dropped).
598        assert!(site
599            .graph
600            .edges
601            .iter()
602            .any(|e| e.from == "index" && e.to == "guide/intro"));
603        assert!(site
604            .graph
605            .edges
606            .iter()
607            .any(|e| e.from == "guide/intro" && e.to == "index"));
608        assert!(!site.graph.edges.iter().any(|e| e.to == "ghost"));
609
610        // Backlinks: index is linked from guide/intro.
611        assert_eq!(
612            site.graph.backlinks.get("index").unwrap()[0].slug,
613            "guide/intro"
614        );
615
616        // Search index: one entry per doc, plaintext, no markup.
617        assert_eq!(site.search.len(), 2);
618        let home = site.search.iter().find(|e| e.slug == "index").unwrap();
619        assert_eq!(home.title, "Home");
620        assert!(home.text.contains("Go to"));
621        assert!(!home.text.contains("[["));
622    }
623
624    #[test]
625    fn render_docs_renders_math_at_build_time() {
626        let prepared = vec![prepare(raw("m.md", "# M\nmass: $E=mc^2$\n"))];
627        let site = render_docs(
628            prepared,
629            &Partials::new(),
630            &docgen_config::SiteConfig::default(),
631            &docgen_components::Registry::empty(),
632            None,
633            None,
634            None,
635        );
636        assert!(site.docs[0].body_html.contains("katex"));
637        assert!(site.docs[0].has_math);
638        assert!(!site.docs[0].body_html.contains("$E=mc^2$"));
639    }
640
641    #[test]
642    fn math_feature_off_skips_build_time_katex() {
643        let prepared = vec![prepare(raw("m.md", "# M\n$E=mc^2$\n"))];
644        let mut cfg = docgen_config::SiteConfig::default();
645        cfg.features.math = false;
646        let site = render_docs(
647            prepared,
648            &Partials::new(),
649            &cfg,
650            &docgen_components::Registry::empty(),
651            None,
652            None,
653            None,
654        );
655        assert!(!site.docs[0].has_math);
656        assert!(!site.docs[0].body_html.contains("katex"));
657    }
658
659    #[test]
660    fn mermaid_feature_off_leaves_code_block() {
661        let prepared = vec![prepare(raw(
662            "d.md",
663            "# D\n```mermaid\ngraph TD;A-->B;\n```\n",
664        ))];
665        let mut cfg = docgen_config::SiteConfig::default();
666        cfg.features.mermaid = false;
667        let site = render_docs(
668            prepared,
669            &Partials::new(),
670            &cfg,
671            &docgen_components::Registry::empty(),
672            None,
673            None,
674            None,
675        );
676        assert!(!site.docs[0].has_mermaid);
677        assert!(!site.any_mermaid);
678    }
679
680    #[test]
681    fn render_docs_marks_mermaid_pages_and_site() {
682        let prepared = vec![
683            prepare(raw("d.md", "# D\n```mermaid\ngraph TD;A-->B;\n```\n")),
684            prepare(raw("p.md", "# P\nplain\n")),
685        ];
686        let site = render_docs(
687            prepared,
688            &Partials::new(),
689            &docgen_config::SiteConfig::default(),
690            &docgen_components::Registry::empty(),
691            None,
692            None,
693            None,
694        );
695        assert!(site.docs[0].has_mermaid && site.docs[0].body_html.contains("docgen-mermaid"));
696        assert!(!site.docs[1].has_mermaid);
697        assert!(site.any_mermaid);
698    }
699
700    #[test]
701    fn site_graph_data_matches_docs_and_links() {
702        let prepared = vec![
703            prepare(raw("index.md", "# Home\nGo to [[guide/intro]].\n")),
704            prepare(raw("guide/intro.md", "# Intro\nBack to [[index]].\n")),
705        ];
706        let site = render_docs(
707            prepared,
708            &Partials::new(),
709            &docgen_config::SiteConfig::default(),
710            &docgen_components::Registry::empty(),
711            None,
712            None,
713            None,
714        );
715        let gd = site.graph_data(crate::graphlayout::LayoutParams::default());
716        assert_eq!(gd.nodes.len(), 2);
717        assert!(gd
718            .nodes
719            .iter()
720            .any(|n| n.slug == "index" && n.title == "Home"));
721        assert!(gd
722            .nodes
723            .iter()
724            .any(|n| n.slug == "guide/intro" && n.title == "Intro"));
725        // Reciprocal [[..]] pair collapses to a single undirected edge.
726        let is_pair = |e: &crate::graphlayout::GraphDataEdge| {
727            (e.from == "index" && e.to == "guide/intro")
728                || (e.from == "guide/intro" && e.to == "index")
729        };
730        assert_eq!(gd.edges.iter().filter(|e| is_pair(e)).count(), 1);
731        assert_eq!(gd.edges.len(), 1);
732    }
733
734    #[test]
735    fn render_docs_without_mermaid_clears_site_flag() {
736        let prepared = vec![prepare(raw("p.md", "# P\nplain\n"))];
737        let site = render_docs(
738            prepared,
739            &Partials::new(),
740            &docgen_config::SiteConfig::default(),
741            &docgen_components::Registry::empty(),
742            None,
743            None,
744            None,
745        );
746        assert!(!site.any_mermaid);
747    }
748
749    #[test]
750    fn render_docs_renders_callout_directive_with_inner_markdown() {
751        let mut reg = docgen_components::Registry::empty();
752        reg.insert(docgen_components::Component::from_parts(
753            "callout",
754            "<aside class=\"docgen-callout--{{ attrs.type | default('note') }}\">{{ content | safe }}</aside>",
755            None,
756            None,
757        ));
758        let prepared = vec![prepare(raw(
759            "d.md",
760            "# D\n\n:::callout{type=warning}\nBe **careful**.\n:::\n",
761        ))];
762        let site = render_docs(
763            prepared,
764            &Partials::new(),
765            &docgen_config::SiteConfig::default(),
766            &reg,
767            None,
768            None,
769            None,
770        );
771        let h = &site.docs[0].body_html;
772        assert!(h.contains("docgen-callout--warning"));
773        assert!(h.contains("<strong>careful</strong>")); // inner markdown rendered
774        assert!(site.docs[0].components_used.contains("callout"));
775        assert!(site.any_components);
776    }
777
778    #[test]
779    fn unknown_directive_in_doc_yields_error_span_not_crash() {
780        let prepared = vec![prepare(raw("d.md", "# D\n\n:nope[x]{}\n"))];
781        let site = render_docs(
782            prepared,
783            &Partials::new(),
784            &docgen_config::SiteConfig::default(),
785            &docgen_components::Registry::empty(),
786            None,
787            None,
788            None,
789        );
790        assert!(site.docs[0].body_html.contains("docgen-directive-error"));
791        assert!(!site.any_components);
792    }
793
794    #[test]
795    fn wikilink_outside_directive_still_resolves() {
796        let mut reg = docgen_components::Registry::empty();
797        reg.insert(docgen_components::Component::from_parts(
798            "callout",
799            "<aside>{{ content | safe }}</aside>",
800            None,
801            None,
802        ));
803        let prepared = vec![
804            prepare(raw(
805                "index.md",
806                "# Home\nSee [[guide]].\n\n:::callout{}\nx\n:::\n",
807            )),
808            prepare(raw("guide.md", "# Guide\n")),
809        ];
810        let site = render_docs(
811            prepared,
812            &Partials::new(),
813            &docgen_config::SiteConfig::default(),
814            &reg,
815            None,
816            None,
817            None,
818        );
819        assert!(site.docs[0].body_html.contains(r#"href="/guide""#));
820    }
821
822    #[test]
823    fn wikilink_inside_directive_body_resolves_to_anchor() {
824        let mut reg = docgen_components::Registry::empty();
825        reg.insert(docgen_components::Component::from_parts(
826            "callout",
827            "<aside>{{ content | safe }}</aside>",
828            None,
829            None,
830        ));
831        let prepared = vec![
832            prepare(raw(
833                "index.md",
834                "# Home\n\n:::callout{}\nSee [[guide/intro|wikilink]] and [[ghost]].\n:::\n",
835            )),
836            prepare(raw("guide/intro.md", "# Intro\n")),
837        ];
838        let site = render_docs(
839            prepared,
840            &Partials::new(),
841            &docgen_config::SiteConfig::default(),
842            &reg,
843            None,
844            None,
845            None,
846        );
847        let h = &site.docs[0].body_html;
848        // The resolved wikilink inside the directive body is a real anchor with the
849        // label text, not literal `[[...]]`.
850        assert!(h.contains(r#"href="/guide/intro""#));
851        assert!(h.contains(r#">wikilink</a>"#));
852        assert!(!h.contains("[[guide/intro|wikilink]]"));
853        // An unresolved target inside a directive body still gets the broken span.
854        assert!(h.contains("docgen-wikilink--broken"));
855        assert!(!h.contains("[[ghost]]"));
856    }
857
858    #[test]
859    fn self_link_renders_anchor_but_no_self_backlink() {
860        // A doc that links to its own slug renders a resolved anchor, but the
861        // self-edge is dropped from the graph (no self-backlink).
862        let prepared = vec![prepare(raw("index.md", "# Home\nBack to [[index]].\n"))];
863        let site = render_docs(
864            prepared,
865            &Partials::new(),
866            &docgen_config::SiteConfig::default(),
867            &docgen_components::Registry::empty(),
868            None,
869            None,
870            None,
871        );
872
873        assert!(site.docs[0].body_html.contains(r#"href="/index""#));
874        assert!(!site
875            .graph
876            .edges
877            .iter()
878            .any(|e| e.from == "index" && e.to == "index"));
879        assert!(!site.graph.backlinks.contains_key("index"));
880    }
881}