Skip to main content

docgen_core/
markdown.rs

1use std::sync::OnceLock;
2
3use comrak::nodes::AstNode;
4use comrak::options::Plugins;
5use comrak::plugins::syntect::{SyntectAdapter, SyntectAdapterBuilder};
6use comrak::{format_html_with_plugins, markdown_to_html_with_plugins, Options};
7
8/// CSS class on the highlighted `<pre>` wrapper. Syntect runs in **class-based**
9/// mode (`ClassStyle::Spaced`): spans carry space-separated, lowercased TextMate
10/// scope atoms (e.g. `keyword`, `string`, `constant numeric`) and the colors live
11/// in the shipped `code.css`, theme-aware for both light and dark. There is no
12/// embedded syntect theme and no inline `style="color:…"`.
13pub const CODE_PRE_CLASS: &str = "docgen-code";
14
15/// The syntect adapter loads/builds syntect's syntax set, which is the single
16/// most expensive object in the pipeline. It is immutable and reusable, so build
17/// it once and share `&adapter` across every document. Built in class-based mode
18/// (`.css()`), so it emits scope-class spans rather than inline-styled ones.
19fn syntect_adapter() -> &'static SyntectAdapter {
20    static ADAPTER: OnceLock<SyntectAdapter> = OnceLock::new();
21    ADAPTER.get_or_init(|| SyntectAdapterBuilder::new().css().build())
22}
23
24/// Comrak's class-based adapter wraps highlighted code in
25/// `<pre class="syntax-highlighting">`. Rewrite that wrapper class to our
26/// canonical `docgen-code` so `code.css` can scope token colors under
27/// `.docgen-doc-content pre.docgen-code`. The literal only appears as the
28/// highlighter's own wrapper, so a plain replace is safe.
29fn rewrite_code_pre_class(html: String) -> String {
30    html.replace(
31        r#"<pre class="syntax-highlighting">"#,
32        &format!(r#"<pre class="{CODE_PRE_CLASS}">"#),
33    )
34}
35
36/// The comrak options used across the whole pipeline (GFM + P0 extensions).
37/// Single source of truth so the AST pass (Cluster B) and the one-shot render agree.
38pub fn comrak_options() -> Options<'static> {
39    let mut options = Options::default();
40    options.extension.strikethrough = true;
41    options.extension.table = true;
42    options.extension.tasklist = true;
43    options.extension.autolink = true;
44    options.extension.footnotes = true;
45    // Math: `$inline$` / `$$display$$` and the `` $`inline`$ `` code-math form.
46    // The AST math pass (Cluster B) renders these to KaTeX HTML at build time.
47    options.extension.math_dollars = true;
48    options.extension.math_code = true;
49    // Allow raw inline HTML through: the wikilink AST pass injects `HtmlInline`
50    // nodes (resolved anchors / broken spans) that must render, not be omitted.
51    options.render.r#unsafe = true;
52    options
53}
54
55/// Render a markdown body (frontmatter already stripped) to HTML with GFM
56/// extensions and server-side syntect syntax highlighting of fenced code.
57pub fn render_markdown(body: &str) -> String {
58    let options = comrak_options();
59    let mut plugins = Plugins::default();
60    plugins.render.codefence_syntax_highlighter = Some(syntect_adapter());
61    rewrite_code_pre_class(markdown_to_html_with_plugins(body, &options, &plugins))
62}
63
64/// Format an already-parsed (and possibly transformed) AST to HTML with syntect.
65pub fn format_ast<'a>(root: &'a AstNode<'a>, options: &Options) -> String {
66    let mut plugins = Plugins::default();
67    plugins.render.codefence_syntax_highlighter = Some(syntect_adapter());
68    let mut out = String::new();
69    format_html_with_plugins(root, options, &mut out, &plugins).expect("format AST to HTML");
70    rewrite_code_pre_class(out)
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn renders_heading_to_html() {
79        let html = render_markdown("# Title");
80        assert!(html.contains("<h1>"));
81        assert!(html.contains("Title"));
82    }
83
84    #[test]
85    fn renders_gfm_table() {
86        let md = "| a | b |\n| - | - |\n| 1 | 2 |\n";
87        let html = render_markdown(md);
88        assert!(html.contains("<table>"));
89    }
90
91    #[test]
92    fn renders_strikethrough() {
93        let html = render_markdown("~~gone~~");
94        assert!(html.contains("<del>"));
95    }
96
97    #[test]
98    fn renders_task_list() {
99        let html = render_markdown("- [x] done\n- [ ] todo\n");
100        assert!(html.contains("type=\"checkbox\""));
101        assert!(html.contains("checked"));
102    }
103
104    #[test]
105    fn renders_autolink() {
106        let html = render_markdown("see https://example.com here\n");
107        assert!(html.contains(r#"href="https://example.com""#));
108    }
109
110    #[test]
111    fn renders_footnote() {
112        let html = render_markdown("text[^1]\n\n[^1]: a note\n");
113        assert!(html.contains("<sup"));
114        assert!(html.contains("footnote"));
115    }
116
117    #[test]
118    fn highlights_fenced_rust_code() {
119        let md = "```rust\nfn main() { let x = 1; }\n```\n";
120        let html = render_markdown(md);
121        // Class-based syntect: spans carry scope classes (no inline styles), inside
122        // the canonical `pre.docgen-code` wrapper. Token colors live in code.css.
123        assert!(html.contains(r#"<pre class="docgen-code">"#));
124        assert!(!html.contains("style=\"color:"));
125        // The keyword `fn` is highlighted as its own classed span.
126        assert!(html.contains(r#"<span class="keyword"#));
127    }
128
129    #[test]
130    fn unknown_language_does_not_crash_and_still_wraps_pre() {
131        let md = "```not-a-real-lang\nplain text\n```\n";
132        let html = render_markdown(md);
133        assert!(html.contains("<pre"));
134        assert!(html.contains("plain text"));
135    }
136
137    #[test]
138    fn math_extension_is_enabled_in_shared_options() {
139        let opts = comrak_options();
140        assert!(opts.extension.math_dollars);
141        assert!(opts.extension.math_code);
142    }
143
144    #[test]
145    fn comrak_options_is_shared_source_of_truth() {
146        // The shared options keep the P0 GFM extensions on.
147        let html = render_markdown("~~gone~~\n");
148        assert!(html.contains("<del>"));
149    }
150}