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