docgen_core/math.rs
1//! Build-time KaTeX rendering. Each math expression in a document is rendered
2//! to static HTML at build time via the `katex` crate (default `quick-js`
3//! backend), so the generated site ships **zero runtime JS for math**.
4
5use crate::util::escape_html;
6
7/// Render one math expression to KaTeX HTML at build time.
8///
9/// `display` selects block (`$$`) vs inline (`$`) layout. On a KaTeX parse
10/// error we fall back to an escaped `<code>` so a bad expression degrades
11/// gracefully instead of failing the whole build.
12///
13/// `throw_on_error` is **true** here: with it false, KaTeX swallows invalid
14/// input and emits its own red error markup (returning `Ok`), which would never
15/// reach our graceful fallback. Letting KaTeX return `Err` on a genuine parse
16/// failure lets us emit a clean escaped `<code class="docgen-math-error">`.
17///
18/// The error fallback honors `display`: a failed display (`$$`) equation is
19/// wrapped in a block `<div class="katex-display docgen-math-error">` so it
20/// still renders as a centered block (matching `.katex-display` spacing),
21/// while inline math degrades to inline `<code>`. The KaTeX error message is
22/// logged to stderr so a malformed expression leaves a build-time diagnostic.
23pub fn render_math(src: &str, display: bool) -> String {
24 let opts = katex::Opts::builder()
25 .display_mode(display)
26 .throw_on_error(true)
27 .build()
28 .expect("katex opts build");
29 match katex::render_with_opts(src, &opts) {
30 Ok(html) => html,
31 Err(e) => {
32 eprintln!("docgen: KaTeX failed to render math `{src}`: {e}");
33 let escaped = escape_html(src);
34 if display {
35 format!("<div class=\"katex-display docgen-math-error\">{escaped}</div>")
36 } else {
37 format!("<code class=\"docgen-math-error\">{escaped}</code>")
38 }
39 }
40 }
41}
42
43#[cfg(test)]
44mod tests {
45 use super::*;
46
47 #[test]
48 fn renders_inline_math_to_katex_html() {
49 let html = render_math("E=mc^2", false);
50 assert!(html.contains("katex"));
51 assert!(!html.contains("katex-display")); // inline → no display wrapper
52 }
53
54 #[test]
55 fn renders_display_math_with_display_wrapper() {
56 let html = render_math("\\int_0^1 x\\,dx", true);
57 assert!(html.contains("katex-display"));
58 }
59
60 #[test]
61 fn bad_expression_degrades_to_escaped_code() {
62 let html = render_math("\\frac{", false);
63 assert!(html.contains("docgen-math-error"));
64 assert!(html.contains("<code")); // inline → inline code
65 assert!(!html.contains("<script"));
66 }
67
68 #[test]
69 fn bad_display_expression_degrades_to_block() {
70 // A failed *display* equation must stay a centered block, not collapse
71 // to an inline <code> fragment.
72 let html = render_math("\\frac{", true);
73 assert!(html.contains("docgen-math-error"));
74 assert!(html.contains("katex-display")); // block wrapper retained
75 assert!(html.contains("<div")); // block element, not inline <code>
76 assert!(!html.contains("<code"));
77 }
78
79 #[test]
80 fn bad_expression_escapes_html_metacharacters() {
81 // A malformed expression carrying HTML metacharacters must be escaped
82 // before landing in raw HTML (render.unsafe = true downstream).
83 let html = render_math("<script>\\frac{", false);
84 assert!(html.contains("<script>"));
85 assert!(!html.contains("<script>"));
86 }
87}