cd /news/developer-tools/your-llm-writes-x-your-markdown-pars… · home topics developer-tools article
[ARTICLE · art-81533] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Your LLM Writes \(x\), Your Markdown Parser Wants $x$

A developer built two open-source packages, micromark-extension-math-extended and remark-math-extended, to let Markdown parsers handle TeX-style math delimiters (\(...\) and \[...\]) commonly emitted by LLMs. The project adds these delimiters directly to the micromark tokenizer, avoiding fragile regex preprocessing and gracefully handling malformed input during streaming.

read4 min views1 publishedJul 31, 2026

TL;DR — LLMs love \(...\)

and \[...\]

. Most Markdown math plugins only speak $...$

and $$...$$

. I tried to bridge that with a regex, failed in interesting ways, and ended up teaching the tokenizer instead. Two packages came out of it: micromark-extension-math-extended and

remark-math-extended

Sometimes the hard part of rendering math isn't the equation. It's agreeing on where the equation starts and stops.

I was piping Markdown from some OpenAI models into a rendering pipeline, and the output kept looking like this:

The lift coefficient is \(C_L\).

\[
L = \frac{1}{2} \rho v^2 S C_L
\]

Nothing wrong here. This is textbook TeX:

\( ... \)

→ inline math\[ ... \]

→ display mathBut every Markdown math package in my toolchain wanted dollars:

The lift coefficient is $C_L$.

$$
L = \frac{1}{2} \rho v^2 S C_L
$$

Two characters of difference. Weeks of my life. Let's go.

The obvious move is to preprocess the model output before it hits the parser:

\(x\)  ->  $x$
\[x\]  ->  $$x$$

A regex handles the happy path. Real Markdown is not the happy path.

Your converter now has to not touch delimiters that live inside:

That last one bites hard:

\begin{cases}
x \\[1em]
y
\end{cases}

\\[1em]

is a TeX line break with optional spacing. That [

is not the start of a display equation. Your regex does not know this. Your regex has never known anything.

And then there's the genuinely dangerous case:

\[
not closed

If the model forgets the closing \]

— which happens constantly when you're streaming — a naive converter swallows the rest of the document into one enormous equation.

At the point where you've handled all of that, congratulations: you didn't write a preprocessor. You wrote a second Markdown parser, and now you maintain two.

So instead of rewriting the input before parsing, I added the TeX-style delimiters directly to the micromark tokenizer. Parse it properly once, and all of the above stops being your problem.

That turned into two packages.

micromark-extension-math-extended

The low-level one, for projects using micromark directly.

npm install micromark-extension-math-extended
js
import {micromark} from 'micromark'
import {math, mathHtml} from 'micromark-extension-math-extended'

const markdown = String.raw`
The lift coefficient is \(C_L\).

\[
L = \frac{1}{2} \rho v^2 S C_L
\]
`

const html = micromark(markdown, {
  extensions: [math()],
  htmlExtensions: [mathHtml()]
})

console.log(html)

All three forms work, and they keep the meaning you'd expect:

Syntax Renders as
$C_L$
inline
\(C_L\)
inline
$$C_L$$
display
\[C_L\]
display

remark-math-extended

The higher-level one, for remark / unified. Drop-in replacement for remark-math

:

npm install remark-math-extended
python
import rehypeKatex from 'rehype-katex'
import rehypeStringify from 'rehype-stringify'
import remarkMath from 'remark-math-extended'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import {unified} from 'unified'

const markdown = String.raw`
The lift coefficient is \(C_L\).

\[
L = \frac{1}{2} \rho v^2 S C_L
\]
`

const file = await unified()
  .use(remarkParse)
  .use(remarkMath)
  .use(remarkRehype)
  .use(rehypeKatex)
  .use(rehypeStringify)
  .process(markdown)

console.log(String(file))

This is the part I care about most, so it gets its own heading.

Rule: [ must have a matching ]. When it doesn't, the parser falls back to ordinary Markdown instead of eating the remainder of your document.

If you're rendering token-by-token from an LLM, every single frame is technically malformed input. A response that's half-arrived shouldn't make your UI explode into one giant KaTeX block and then unexplode a second later.

The tokenizer also tells a real nested opener apart from legitimate TeX like \\[1em]

.

These two behaviors are the entire reason I went parser-level instead of regex-level.

One intentional incompatibility: in standard CommonMark, a backslash escapes punctuation, so \(

and \[

mean literal (

and [

. Turning on TeX-style delimiters changes that.

If you need the original behavior back:

math({backslashDelimiters: false})

or with remark:

unified().use(remarkMath, {
  backslashDelimiters: false
})

Dollar-delimited math keeps working either way.

remark-math-extended

reuses the existing mdast-util-math

tree and serializer. The math value and its inline/display meaning survive the round trip, but serialization normalizes delimiters back to dollars:

\(x\)  ->  $x$
\[x\]  ->  $$x$$

Fine for most rendering pipelines. Not fine if you need byte-for-byte delimiter preservation. Open an issue if that's you — I'd like to know how common it is.

If you work with LLM-generated Markdown, scientific writing, or anything that mixes Markdown and TeX: what edge cases have you hit? I'm collecting them. The \\[1em]

one took me embarrassingly long to find, and I'm sure it's not the last.

If these saved you from maintaining yet another delimiter-conversion parser, you can buy me a coffee

── more in #developer-tools 4 stories · sorted by recency
── more on @micromark-extension-math-extended 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/your-llm-writes-x-yo…] indexed:0 read:4min 2026-07-31 ·