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