Your LLM Writes \(x\), Your Markdown Parser Wants $x$
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. Somet

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 math But 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: inline and fenced code escaped Markdown punctuation math that's already dollar-delimited truncated model output TeX commands that are themselves full of backslashes 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 # Everything after this 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 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 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. 📦 micromark-extension-math-extended 📦 remark-math-extended 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 ☕
Key Takeaways
- •TL;DR — LLMs love \(...\) and \[...\]
- •This story was reported by Dev.to, covering developments in the dev space.
- •AI advancements continue to reshape industries — read the full article on Dev.to for complete coverage.
📖 Continue reading the full article:
Read Full Article on Dev.to →


