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