Intro
Rendering Markdown on a content site is a pretty common requirement now. Compared with heavyweight Markdown editors and renderers like Vditor and markdown-it, using a lighter rendering library such as marked usually leads to a better experience, and it also leaves the site’s styling fully under your control. The catch is that Marked JS only handles basic Markdown-to-HTML rendering. It does not solve syntax highlighting or math rendering out of the box. The official docs already show how to integrate it with highlightJS for code blocks, but when I looked for math support I mostly found a few issues and some fairly strange implementations:
- https://github.com/markedjs/marked/issues/722
- https://github.com/linxiaowu66/marked-kaTex (this one even forks marked directly, and the project has been stale for ages)
- https://gist.github.com/tajpure/47c65cf72c44cb16f3a5df0ebc045f2f (intercepts render output and pre-renders formulas, which leads to some odd behavior)
- https://www.xiaog.info/blog/post/marked_js_katex (a Chinese adaptation of the previous one, slightly improved, but still a bit awkward)
The last two solutions basically use regular expressions to extract math fragments, feed them through KaTeX into HTML, then stuff the result back into marked as raw HTML and let marked render everything again. I tried it, and yes, it technically works. But the behavior is weird. marked still does extra work on already-rendered HTML blocks, such as escaping, and some characters always end up wrong.
At that point it felt better to stop looking at hacks and just write a plugin.
Writing a Marked.js plugin
I decided to integrate KaTeX rather than MathJax. This site is not a dedicated Markdown-rendering product; math support is only there to make articles easier to read. MathJax supports lots of advanced features and multiple output formats, which felt a bit excessive for this use case. KaTeX is lightweight and fits the job just fine.
How marked works
Before writing a plugin, it helps to understand marked’s processing model. The pipeline looks roughly like this:
- the user inputs plain text in Markdown format;
- the
lexerfeeds chunks of that input into differenttokenizers and collects the resultingtokens into a nested tree; - each
tokenizerchecks whether the current chunk matches a specific syntax. If it does, it returns atokencontaining the relevant data. If not, it returns nothing; - the token tree is then walked, and the matching
renderers turn thosetokens into the final HTML output;
Once that is clear, the implementation path is straightforward: all we need is a tokenizer that can recognize math blocks and a renderer that can render them, then wire both into marked’s pipeline.
Relevant APIs
marked already documents the extension APIs, so I will not play translator for the docs here.
Implementing the tokenizer
We need two tokenizers: one for inline formulas such as $f(x)=x+y$, and one for block formulas like this:
$$
f(x) = \frac{1}{x}
$$To match those two cases, two regular expressions are enough: one for single $ and one for $$.
Implementing render
This part is just a one-liner: katex.renderToString(token.text, options).
Code snippet
import katex, { type KatexOptions } from "katex";
import "katex/dist/katex.css";
import type { marked } from "marked";
export default function (options: KatexOptions = {}): marked.MarkedExtension {
return {
extensions: [inlineKatex(options), blockKatex(options)],
};
}
function inlineKatex(
options: KatexOptions,
): marked.TokenizerAndRendererExtension {
return {
name: "inlineKatex",
level: "inline",
start(src: string) {
return src.indexOf("$");
},
tokenizer(src: string, _tokens) {
const match = src.match(/^\$+([^$\n]+?)\$+/);
if (match) {
return {
type: "inlineKatex",
raw: match[0],
text: match[1].trim(),
};
}
},
renderer(token) {
return katex.renderToString(token.text, options);
},
};
}
function blockKatex(
options: KatexOptions,
): marked.TokenizerAndRendererExtension {
return {
name: "blockKatex",
level: "block",
start(src: string) {
return src.indexOf("$$");
},
tokenizer(src: string, _tokens) {
const match = src.match(/^\$\$+\n([^$]+?)\n\$\$/);
if (match) {
return {
type: "blockKatex",
raw: match[0],
text: match[1].trim(),
};
}
},
renderer(token) {
options.displayMode = true;
return `<p>${katex.renderToString(token.text, options)}</p>`;
},
};
}Save it as katex_extension.ts. When using it, all you need to do is import it and call marked.use(KatexExtension({})); the argument is simply the KaTeX options object.
If you want lazy loading, you can also do this:
const katex = await import("@/path/to/katex_extension.ts");
marked.use(katex.default({ strict: false }));In my case, I first render the base content with plain marked and no plugins, then load KaTeX and highlightJS and run a second pass. On slower connections, that ends up feeling better.
