How to fix escape issue in KaTeX support?

Currently when I use themes that support KaTeX, there’s a disturbing issue that, when I wish to put \{, I have to use \\{. The same goes for \}, \, and \\ (and maybe some else I haven’t found yet). I guess the problem is that the characters are escaped in parsing before KaTeX deals with it.

So I’m wondering if there’s a way to fix this problem in theme-level?


Actually there are other issues, like having mulitiple _s may be parsed as italics and then makes KaTeX go error, and I deal with that by adding extra whitespace.

1 Like

Fixing this at theme level is possible with a Tera2 component, although I think the better solution would be to handle math at the Markdown-parser level.

The underlying problem is that, unless math parsing is enabled, pulldown-cmark does not know that $...$ or $$...$$ contains TeX.
The contents are therefore parsed as ordinary Markdown before KaTeX ever sees them.

The relevant rendering pipeline in Zola 0.23.x is roughly:
.md
→ Tera2 component
→ generated text
→ CommonMark parser (pulldown-cmark)
→ HTML
→ KaTeX (JS in the Browser)

This explains the problems with characters such as \, _, and *.

For example, CommonMark treats a backslash followed by ASCII punctuation as a Markdown escape.

Commands such as \frac, \alpha, or \sqrt generally don’t have this particular problem because the character following the backslash is a letter rather than ASCII punctuation.

Likewise, _ and * can participate in Markdown’s emphasis parsing, backticks can form code spans, a backslash immediately before a line ending produces a hard line break, and enabled Markdown extensions introduce some additional syntax.

With Zola 0.23’s Tera2 components, one workaround is to explicitly mark math and encode Markdown-sensitive characters as HTML character references before the result reaches pulldown-cmark.

For example:

{% component katex(inline: bool = false, class = "") -%}
{%- set content = body
    | trim
    | escape_html
    | replace(from=`\\`, to=`\` )
    | replace(from=`_`,  to=`_` )
    | replace(from=`*`,  to=`*` )
    | replace(from="`",  to=``` )
    | replace(from=`~`,  to=`~`)
    | replace(from=`[`,  to=`[` )
    | replace(from=`]`,  to=`]` )
    | replace(from=`|`,  to=`|`)
-%}
{%- if inline -%}
<span class="math math-inline{% if class %} {{ class | escape_html }}{% endif %}" role="math">&#92;({{ content | safe }}&#92;)</span>
{%- else -%}
<div class="math math-display{% if class %} {{ class | escape_html }}{% endif %}" role="math">&#92;[{{ content | safe }}&#92;]</div>
{%- endif %}
{%- endcomponent katex %}

This works, but it is still a workaround: authors have to explicitly wrap their math in a component.

BUT: I think the cleaner solution would be for Zola to expose pulldown-cmark’s native math support.

pulldown-cmark has Options::ENABLE_MATH.

With that option enabled, it recognizes $...$ and $$...$$ as math and produces Event::InlineMath and Event::DisplayMath rather than treating their contents as ordinary Markdown.

That addresses the problem at the correct level: instead of trying to make individual TeX characters survive Markdown parsing, the Markdown parser knows that the region is math in the first place.

Zola could potentially expose this as something like:

[markdown]
math = true

and then enable opts.insert(Options::ENABLE_MATH); in its Markdown options.

I haven’t tested a patched Zola build yet, so there is one important part still to verify: enabling ENABLE_MATH solves the parsing problem, but the resulting InlineMath/DisplayMath events and their HTML representation also need to integrate correctly with Zola’s rendering pipeline and KaTeX auto-render.

If that integration works and is added to Zola, it would be preferable to the component workaround - IMHO.

1 Like

I did some experminents, and these two changes in Zola let me use $...$ and $$...$$ without having to escape TeX characters for Markdown. Now I can use TeX in Markdown like I have done it for ages.

In components/markdown/src/context.rs for MarkdownContext::options I added:

opts.insert(Options::ENABLE_MATH);

In components/markdown/src/markdown.rs for State::process I added this before the // Everything else:

// KaTeX Math
Event::InlineMath(math) => {
    self.push_html(format!(
        r#"<span class="math math-inline" role="math">\({}\)</span>"#,
        escape_html_string(&math)
    ));
}
Event::DisplayMath(math) => {
    self.push_html(format!(
        r#"<span class="math math-display" role="math">\[{}\]</span>"#,
        escape_html_string(&math)
    ));
}

The event handling is renderer-specific: it turns pulldown-cmark’s math events back into \(...\) / \[...\] so that KaTeX auto-render can pick them up. Maybe this solution is too specific to KaTeX, so it might not be something the author of Zola will consider. But it solves this problem.

A more generic solution would be for Zola to expose ENABLE_MATH as a Markdown configuration option and provide a way for themes to control how InlineMath and DisplayMath events are rendered.

2 Likes

I found PR #2708, which I wasn’t aware of when writing my previous replies. This actually helps narrow down waht I was originally trying to solve.

I would separate the preservation of math expressions during Markdown processing from the question of how math is eventually rendered.

For the preservation problem, pulldown-cmark already provides what is needed through Options::ENABLE_MATH. It recognizes $...$ and $$...$$ as math and emits InlineMath and DisplayMath events, so their payload is no longer processed as ordinary Markdown.

The most conservative handling of those events would simply be to serialize them back to where they came from:

Event::InlineMath(math) => {
    self.push_html(format!(
        "${}$",
        escape_html_string(&math)
    ));
}
Event::DisplayMath(math) => {
    self.push_html(format!(
        "$${}$$",
        escape_html_string(&math)
    ));
}

The observable output remains unchanged, but the important difference is that the contents of the expression have passed through the Markdown parser atomically instead of being subject to other Markdown transformations.

This also avoids having to make a decision about client-side rendering at this point. Existing themes using KaTeX/MathJax with $/$$ delimiters can continue doing exactly what they already do. Zola would neither need to introduce a particular <span class="math ..."> convention nor require themes to add Zola-specific JavaScript for these events.

In other words, I would define the scope of math = true quite narrowly:

Enable pulldown-cmark’s native math syntax so math expressions are parsed atomically and their contents survive Markdown processing. By default, serialize them back into the same $/$$ representation.

That would solve the original pass-through/escaping issue while preserving the existing client-side rendering approach.

How Zola might optionally transform or render InlineMath/DisplayMath later (different delimiters, wrappers/classes, server-side KaTeX, etc.) is a separate concern to me and doesn’t need to be decided in order to solve this problem.

One detail: The math payload should still go through escape_html_string() when it is written to the generated HTML. The goal is to preserve the mathematical source from Markdown transformations, not to emit potentially meaningful HTML characters unescaped.