From dd6766907440c8375585e69e50a30d027646e033 Mon Sep 17 00:00:00 2001
From: Thomas Weitzel <tweitzel@synformation.com>
Date: Tue, 11 Aug 2026 19:05:41 +0200
Subject: [PATCH] add opt-in TeX math parsing that preserves Markdown
 delimiters
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Add a markdown.math configuration option that enables pulldown-cmark’s native math parsing and preserves inline and display delimiters for theme-provided rendering. Cover TeX syntax, HTML-sensitive content, heading IDs, TOC titles, and image alt text, and document the configuration and delimiter requirements.
---
 components/config/src/config/markup.rs        |  3 ++
 components/markdown/src/context.rs            |  3 ++
 components/markdown/src/markdown.rs           |  8 +++++
 components/markdown/tests/img.rs              | 14 ++++++++
 components/markdown/tests/markdown.rs         | 34 +++++++++++++++++++
 ...__can_preserve_math_in_image_alt_text.snap |  5 +++
 ...kdown__can_handle_math_in_heading_ids.snap |  5 +++
 .../snapshots/markdown__can_use_math.snap     | 13 +++++++
 .../getting-started/configuration.md          |  6 ++++
 9 files changed, 91 insertions(+)
 create mode 100644 components/markdown/tests/snapshots/img__can_preserve_math_in_image_alt_text.snap
 create mode 100644 components/markdown/tests/snapshots/markdown__can_handle_math_in_heading_ids.snap
 create mode 100644 components/markdown/tests/snapshots/markdown__can_use_math.snap

diff --git a/components/config/src/config/markup.rs b/components/config/src/config/markup.rs
index d7d8bc3f..57208e93 100644
--- a/components/config/src/config/markup.rs
+++ b/components/config/src/config/markup.rs
@@ -164,6 +164,8 @@ pub struct Markdown {
     pub insert_anchor_links: InsertAnchor,
     /// Whether to enable GitHub-style alerts
     pub github_alerts: bool,
+    /// Whether to recognize TeX math delimited by `$...$` and `$$...$$`
+    pub math: bool,
 }
 
 impl Markdown {
@@ -235,6 +237,7 @@ impl Default for Markdown {
             lazy_async_image: false,
             insert_anchor_links: InsertAnchor::None,
             github_alerts: false,
+            math: false,
         }
     }
 }
diff --git a/components/markdown/src/context.rs b/components/markdown/src/context.rs
index 7ac312fe..6313e985 100644
--- a/components/markdown/src/context.rs
+++ b/components/markdown/src/context.rs
@@ -38,6 +38,9 @@ impl<'a> MarkdownContext<'a> {
         if self.config.markdown.github_alerts {
             opts.insert(Options::ENABLE_GFM);
         }
+        if self.config.markdown.math {
+            opts.insert(Options::ENABLE_MATH);
+        }
         opts
     }
 }
diff --git a/components/markdown/src/markdown.rs b/components/markdown/src/markdown.rs
index 065931aa..e555a753 100644
--- a/components/markdown/src/markdown.rs
+++ b/components/markdown/src/markdown.rs
@@ -596,6 +596,14 @@ impl<'a> State<'a> {
                 }
             }
 
+            // Keep math as text so Zola's buffers retain it and the final HTML renderer escapes it.
+            Event::InlineMath(math) => {
+                self.push(Event::Text(format!("${math}$").into()));
+            }
+            Event::DisplayMath(math) => {
+                self.push(Event::Text(format!("$${math}$$").into()));
+            }
+
             // Everything else
             _ => self.push(event),
         }
diff --git a/components/markdown/tests/img.rs b/components/markdown/tests/img.rs
index c34713a9..ff80666a 100644
--- a/components/markdown/tests/img.rs
+++ b/components/markdown/tests/img.rs
@@ -15,6 +15,20 @@ fn can_transform_image() {
     insta::assert_snapshot!(body);
 }
 
+#[test]
+fn can_preserve_math_in_image_alt_text() {
+    let mut config = Config::default_for_test();
+    config.markdown.math = true;
+
+    let body = common::render_with_config(
+        r"![Momentum $\mathbf{p}_0$](https://example.com/momentum.jpg)",
+        config,
+    )
+    .unwrap()
+    .body;
+    insta::assert_snapshot!(body);
+}
+
 #[test]
 fn can_add_lazy_loading_and_async_decoding() {
     let cases = vec![
diff --git a/components/markdown/tests/markdown.rs b/components/markdown/tests/markdown.rs
index 2e608e26..41859f8f 100644
--- a/components/markdown/tests/markdown.rs
+++ b/components/markdown/tests/markdown.rs
@@ -83,6 +83,17 @@ fn can_handle_heading_ids() {
     insta::assert_snapshot!(body);
 }
 
+#[test]
+fn can_handle_math_in_heading_ids() {
+    let mut config = Config::default_for_test();
+    config.markdown.math = true;
+
+    let rendered = common::render_with_config(r"# $\frac{1}{2}$", config).unwrap();
+    assert_eq!(rendered.toc[0].title, r"$\frac{1}{2}$");
+    assert!(!rendered.toc[0].id.is_empty());
+    insta::assert_snapshot!(rendered.body);
+}
+
 #[test]
 fn can_insert_anchors() {
     let cases = vec![
@@ -486,3 +497,26 @@ fn github_alerts() {
     let body = common::render_with_config(&markdown, config).unwrap().body;
     insta::assert_snapshot!(body);
 }
+
+#[test]
+fn can_use_math() {
+    let mut config = Config::default_for_test();
+    config.markdown.math = true;
+
+    let markdown = r#"Inline: $a_1 * b_2 + \{ \alpha \} \,$.
+
+$$
+\begin{matrix}
+1 & 0 \\
+0 & 1
+\end{matrix}
+$$
+
+Math: $x < y \text{ and } a > b & c$.
+
+Costs \$50 and \$100.
+"#;
+
+    let body = common::render_with_config(markdown, config).unwrap().body;
+    insta::assert_snapshot!(body);
+}
diff --git a/components/markdown/tests/snapshots/img__can_preserve_math_in_image_alt_text.snap b/components/markdown/tests/snapshots/img__can_preserve_math_in_image_alt_text.snap
new file mode 100644
index 00000000..3998e13f
--- /dev/null
+++ b/components/markdown/tests/snapshots/img__can_preserve_math_in_image_alt_text.snap
@@ -0,0 +1,5 @@
+---
+source: components/markdown/tests/img.rs
+expression: body
+---
+<p><img src="https://example.com/momentum.jpg" alt="Momentum $\mathbf{p}_0$" /></p>
diff --git a/components/markdown/tests/snapshots/markdown__can_handle_math_in_heading_ids.snap b/components/markdown/tests/snapshots/markdown__can_handle_math_in_heading_ids.snap
new file mode 100644
index 00000000..c701a94a
--- /dev/null
+++ b/components/markdown/tests/snapshots/markdown__can_handle_math_in_heading_ids.snap
@@ -0,0 +1,5 @@
+---
+source: components/markdown/tests/markdown.rs
+expression: rendered.body
+---
+<h1 id="frac-1-2">$\frac{1}{2}$</h1>
diff --git a/components/markdown/tests/snapshots/markdown__can_use_math.snap b/components/markdown/tests/snapshots/markdown__can_use_math.snap
new file mode 100644
index 00000000..9f74f993
--- /dev/null
+++ b/components/markdown/tests/snapshots/markdown__can_use_math.snap
@@ -0,0 +1,13 @@
+---
+source: components/markdown/tests/markdown.rs
+expression: body
+---
+<p>Inline: $a_1 * b_2 + \{ \alpha \} \,$.</p>
+<p>$$
+\begin{matrix}
+1 &amp; 0 \\
+0 &amp; 1
+\end{matrix}
+$$</p>
+<p>Math: $x &lt; y \text{ and } a &gt; b &amp; c$.</p>
+<p>Costs $50 and $100.</p>
diff --git a/docs/content/documentation/getting-started/configuration.md b/docs/content/documentation/getting-started/configuration.md
index 12ba4400..f8c17d6c 100644
--- a/docs/content/documentation/getting-started/configuration.md
+++ b/docs/content/documentation/getting-started/configuration.md
@@ -147,6 +147,12 @@ external_links_external = true
 # For example, `...` into `…`, `"quote"` into `“curly”` etc
 smart_punctuation = false
 
+# Controls TeX math parsing (inline $...$ and display $$...$$)
+# To render inline and display math, configure your theme's renderer to recognize
+# `$...$` and `$$...$$`; Zola only preserves the TeX source.
+# When using `$...$` delimiters, consult your renderer's documentation for handling of literal dollar signs.
+math = false
+
 # Whether parsing of definition lists is enabled
 definition_list = false
 
-- 
2.55.0

