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