Skip to main content

docgen_core/
basepass.rs

1//! AST pass that replaces ` ```base ` fenced code blocks with a rendered Obsidian
2//! Base (static table/cards/list HTML), computed against the whole-site corpus.
3//!
4//! Runs in `render_doc` (top level) when the `bases` feature is on and a corpus is
5//! available. Mirrors [`crate::mermaidpass`] structurally, but the block body is
6//! the `.base` YAML source rendered to HTML at build time (no island, no runtime).
7//! Malformed YAML degrades to an inline error block (never a panic), matching the
8//! PlantUML/`:include` graceful-degradation ethos.
9
10use comrak::nodes::{AstNode, NodeHtmlBlock, NodeValue};
11use docgen_bases::{render_base_source, Corpus, RenderOptions};
12
13/// Replace every ` ```base ` fenced block with its rendered view HTML. Returns the
14/// count of bases rendered (lets callers record whether a page used one).
15pub fn transform_bases<'a>(root: &'a AstNode<'a>, corpus: &Corpus, base_path: &str) -> usize {
16    let opts = RenderOptions {
17        base: base_path.to_string(),
18        default_view_name: String::new(),
19        interactive: true,
20        // Overwritten per block below; `count` doubles as the block index.
21        block_index: 0,
22    };
23    let mut count = 0;
24    transform(root, corpus, &opts, &mut count);
25    count
26}
27
28fn transform<'a>(node: &'a AstNode<'a>, corpus: &Corpus, opts: &RenderOptions, count: &mut usize) {
29    let replacement = {
30        let data = node.data.borrow();
31        if let NodeValue::CodeBlock(cb) = &data.value {
32            let lang = cb.info.split_whitespace().next().unwrap_or("");
33            if lang.eq_ignore_ascii_case("base") {
34                // Each block gets its own index so the views it emits are
35                // namespaced to it. Two blocks on a page would otherwise both
36                // number their views from 0, and the island keys URL-hash
37                // segments and facet DOM ids off that number — each block would
38                // strip the other's state from the URL on every keystroke.
39                let opts = RenderOptions {
40                    block_index: *count,
41                    ..opts.clone()
42                };
43                Some(render_base_source(&cb.literal, corpus, &opts))
44            } else {
45                None
46            }
47        } else {
48            None
49        }
50    };
51    if let Some(html) = replacement {
52        node.data.borrow_mut().value = NodeValue::HtmlBlock(NodeHtmlBlock {
53            block_type: 0,
54            literal: html,
55        });
56        *count += 1;
57        return;
58    }
59    for child in node.children() {
60        transform(child, corpus, opts, count);
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use crate::markdown::{comrak_options, format_ast};
68    use comrak::{parse_document, Arena};
69    use docgen_bases::Note;
70
71    fn corpus() -> Corpus {
72        let n = Note {
73            slug: "books/dune".into(),
74            basename: "Dune".into(),
75            name: "Dune.md".into(),
76            path: "books/Dune.md".into(),
77            tags: vec!["book".into()],
78            ..Default::default()
79        };
80        Corpus::new(vec![n])
81    }
82
83    fn render(md: &str, corpus: &Corpus) -> (String, usize) {
84        let arena = Arena::new();
85        let opts = comrak_options();
86        let root = parse_document(&arena, md, &opts);
87        let n = transform_bases(root, corpus, "");
88        (format_ast(root, &opts), n)
89    }
90
91    #[test]
92    fn base_block_becomes_table() {
93        let md = "```base\nfilters:\n  and:\n    - file.hasTag(\"book\")\nviews:\n  - type: table\n    order: [file.name]\n```\n";
94        let (html, n) = render(md, &corpus());
95        assert_eq!(n, 1);
96        assert!(html.contains("docgen-base-table"));
97        assert!(html.contains(">Dune<"));
98        assert!(!html.contains("<code")); // not a normal code block
99    }
100
101    /// The reported failure was page-level, not base-level: two ` ```base ` blocks
102    /// each numbering their views from 0. The island keys URL-hash segments
103    /// (`b{idx}.`) and facet-panel DOM ids off `data-base-view`, so colliding ids
104    /// meant each block stripped the other's state out of the URL on every
105    /// keystroke, and both restored the same state on reload.
106    #[test]
107    fn two_base_blocks_on_a_page_get_distinct_view_ids() {
108        let block = "```base\nviews:\n  - type: table\n    order: [file.name]\n```\n";
109        let (html, n) = render(&format!("{block}\n{block}"), &corpus());
110        assert_eq!(n, 2);
111        assert!(html.contains("data-base-view=\"0-0\""));
112        assert!(html.contains("data-base-view=\"1-0\""));
113    }
114
115    #[test]
116    fn non_base_code_block_untouched() {
117        let (html, n) = render("```rust\nfn x() {}\n```\n", &corpus());
118        assert_eq!(n, 0);
119        assert!(html.contains("<pre"));
120    }
121
122    #[test]
123    fn malformed_base_yields_error_block_not_panic() {
124        let md = "```base\nfilters: [unclosed\n```\n";
125        let (html, n) = render(md, &corpus());
126        assert_eq!(n, 1);
127        assert!(html.contains("docgen-base-error"));
128    }
129
130    #[test]
131    fn empty_corpus_renders_no_results() {
132        let md = "```base\nviews:\n  - type: table\n```\n";
133        let (html, _) = render(md, &Corpus::default());
134        assert!(html.contains("No results"));
135    }
136}