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