Porting KaTeX to Astro 7's Rust Markdown Engine (Sätteri)
Published: 2026-07-10(Last updated: 2026-07-12)
TL;DR — Astro 7’s Rust markdown engine, Sätteri, won’t run remark/rehype plugins, so I rewrote my KaTeX rendering as native Sätteri plugins. The bug that cost me an afternoon: the rawHtml escape hatch re-parses its input as Markdown, mangling my LaTeX subscript underscores into <em> tags. A second edge — the syntax highlighter, which runs before my plugins and eats $$ block math — waited for the fix to route around. The fix: convert block math to a plain code block early, render it late. Under eighty lines total.
When I upgraded my blog to Astro 7, the headline feature was speed: the markdown pipeline now runs on Sätteri, a Rust-based engine that replaces the remark/rehype chain. (I benchmarked that afterwards; the engine swap on its own turned out to be a real but modest win — more on that below.) The release notes are upfront about the catch, too — Sätteri runs none of your existing remark or rehype plugins. Not “most.” None. The AST lives in Rust, and JavaScript only gets a read-only view over it. Remark and rehype aren’t gone from Astro, though: you can still opt back into them through the markdown.processor escape hatch. What’s harder to find is the cost of giving that up and committing to Sätteri.
For a lot of sites that costs nothing — Sätteri implements so much natively that you never needed the plugins. My blog wasn’t one of those: it renders math with KaTeX, and math has no native equivalent. I could have kept the classic pipeline through the escape hatch and left the default off — but honestly, getting my hands on the Rust engine was the part I actually wanted. So I ported my KaTeX setup onto Sätteri to see what it takes.
In this article, I’ll walk through what that actually involved — including a bug I first blamed on KaTeX, wrongly, as it turned out.
What Sätteri handles, and what you rewrite
Let’s look at Sätteri’s own type definitions. Its Features interface tells you what the core parser handles without a single plugin:
export interface Features {
gfm?: boolean | GfmOptions; // tables, footnotes, strikethrough, task lists
frontmatter?: boolean;
math?: boolean | MathOptions; // $ and $$ parsing — opt-in, default off
headingAttributes?: boolean;
directive?: boolean;
// ...
}
This list convinced me I could drop several of my dependencies. GFM footnotes are native, so remark-footnotes is gone. Math parsing is native too — flip features.math on and $...$ / $$...$$ become real math nodes. Shiki highlighting and image collection are wired in as well.
The catch is the word parsing. features.math turns $$...$$ into a math node in the tree; it does not turn that node into visual output. That final LaTeX-to-HTML step is what rehype-katex does today, and there is no Sätteri-native equivalent. So the piece I had to write myself was the one the engine won’t do for me: rendering KaTeX. Sätteri handled nearly everything else.
Image collection was the one exception, and it caught me out. Sätteri’s native collector deliberately skips root-absolute URLs (url.startsWith('/')) — a reasonable design choice, just not one that fits my setup, since my blog references every image as /blog/<slug>/.... So the native collection saw none of them, and my OG-image auto-detection silently fell back to the default. I had to port that logic by hand after all — something that only became clear once I read how the collector actually decides what to include. The port itself was small (an mdast visitor that picks the first non-SVG image as the post’s OG image), but it’s real work the engine forced on me, and it sits outside the KaTeX line count I quote at the end.
A caveat on all this source-reading: the Sätteri internals I quote in this post are from satteri@0.9.4 and @astrojs/markdown-satteri@0.3.3, both pre-1.0. They’re internal shapes, not public API, so treat them as a snapshot that may shift in later releases.
A different kind of plugin
If you’ve written a remark or rehype plugin, the shape is muscle memory: a function that takes the whole tree and mutates it in place.
// remark/rehype: mutate the tree directly
export default function plugin() {
return (tree, file) => {
visit(tree, 'element', (node, index, parent) => {
parent.children.splice(index, 1, replacement); // direct mutation
});
};
}
None of that works in Sätteri. Sätteri gives you two places to hook in: mdast plugins, which see the Markdown tree, and hast plugins, which see the HTML tree it becomes. In either one, the nodes handed to your visitor are read-only references into Rust memory — you can’t splice a parent’s children or assign to node.properties, because the object in your hand is a view, not the source of truth. You describe changes through a context object instead, and Sätteri applies them back on the Rust side:
// Sätteri: describe the change through ctx
const katexMdastPlugin = {
name: 'katex-math',
math(node, ctx) {
const html = katex.renderToString(node.value, { displayMode: true, output: 'mathml' });
ctx.replaceNode(node, { rawHtml: html });
}
};
Following Sätteri’s context API turned out to be comfortable — ctx.replaceNode, ctx.textContent(), and katex.renderToString() reused as-is are all it takes; the unfamiliar mental model is the only real cost, and a small one. The plugin above looks completely reasonable, and it mostly works — but one real equation was enough to break it, and to surface two separate edges of the engine I had to get past.
Testing in isolation, without a full build
Before wiring anything into astro.config.mjs, I drove Sätteri’s own entry points directly from throwaway .mjs scripts — markdownToHtml for a bare compile, and createSatteriMarkdownProcessor for the real Astro pipeline (Shiki, image markers, heading IDs) without a full astro build in the loop. Checking an assumption took seconds instead of a full rebuild.
One pnpm wrinkle: Sätteri is a transitive dependency, so a bare import { markdownToHtml } from 'satteri' fails with ERR_MODULE_NOT_FOUND — import it by its full node_modules/.pnpm/... path, or add it as a direct dependency. With that in place, I could feed real equations through the exact processor my config would use — which is how I found the traps before they ever reached a real page.
The traps
The highlighter runs before your plugins. createSatteriMarkdownProcessor always registers its syntax-highlight plugin before any hast plugins you register yourself, and you can’t reorder that from config. The highlighter skips languages in defaultExcludeLanguages — which is ["math"] — so a fenced ```math block, whose code node carries data.lang === "math", sails through untouched.
But native $$...$$ block math lowers to a code node that carries the language-math class yet no data.lang of "math" — nothing that flags it as math to skip. The highlighter keys its exclusion list off data.lang, not the class, so it doesn’t skip the node. It highlights the math as a plaintext code block, stripping that language-math class before any hast plugin can match on it.
That doesn’t touch the rawHtml plugin above — that one consumes the math node before hast, so it never meets the highlighter — but it’s the edge waiting for the code-node approach the fix turns to.
The rawHtml escape hatch re-parses your HTML as Markdown. This is the one that cost me an afternoon. In my local preview, the equation — a chemistry formula, the from a photosynthesis reaction — rendered correctly for its first few symbols and then broke apart, with a run of raw LaTeX spilling out as plain text right after it. Something was cutting the math off partway through.
The confusing part: calling katex.renderToString() on the equation directly returned perfectly well-formed MathML. The damage only appeared after I handed that clean HTML to ctx.replaceNode(node, { rawHtml: html }), where \text{C}_6\text{H}_{12} came back out as \text{C}<em>6\text{H}</em>{12}, with <em> tags spliced into the middle of the LaTeX. (Those _ characters are LaTeX subscript markers: C_6 means C with a subscript 6.)
KaTeX was fine. Sätteri’s parser was fine. The corruption appeared between them, and Sätteri’s own source told me why:
/** True for the `{raw}` / `{rawHtml}` escape hatches — re-parsed by Rust
* rather than compiled to an op-stream ... */
That comment’s op-stream is Sätteri’s internal compiled form — the fast path it normally lowers nodes into, and the one the raw/rawHtml hatches deliberately skip.
So content passed via mdast rawHtml is not treated as opaque HTML. It’s fed back through Sätteri’s own Markdown parser — a fork of the Rust crate pulldown-cmark, which implements CommonMark, the specification that pins down exactly how Markdown is parsed.
That re-parse is what corrupted the LaTeX. My underscores sat right after } characters — ..._6\text{H}_... — and CommonMark counts those as valid positions to open and close emphasis, so it read them as <em>. An <em> spliced into the middle of a MathML <annotation> is invalid nesting, which knocked the browser’s HTML parser clean out of the <math> subtree and let the rest of the raw LaTeX leak onto the page as visible text.
The fix that stuck
The lesson from both traps is the same: because mdast rawHtml gets re-parsed as Markdown, it is unsafe for any string containing characters Markdown treats as syntax — and LaTeX is full of them, starting with underscores. So I stopped using it there. Instead I split the work across two levels:
// mdast: recast block math to a plain code node — NOT rawHtml
const katexMdastPlugin = {
name: 'katex-block',
math(node, ctx) {
ctx.replaceNode(node, { type: 'code', lang: 'math', value: node.value });
}
};
A code node is an ordinary node Sätteri knows how to render, not a raw escape hatch, so it is never re-parsed — the underscores survive untouched. It becomes <pre><code class="language-math" data-lang="math">, and that data-lang defuses the first trap: it’s the attribute the highlighter checks against its ["math"] exclusion list, so the highlighter now skips the block instead of mangling it. One change, both traps handled. (My first attempt deleted the mdast plugin and relied on Sätteri’s default conversion. That produced the same markup without data.lang, so the highlighter ate it after all.)
The actual katex.renderToString() call happens one level down, in a hast plugin. The two hooks dispatch differently: an mdast plugin is a method named for the node type it handles (math), and mutates through ctx; a hast plugin registers an element list with a filter, and can mutate by simply returning a replacement node. This one filters on the language-math class and returns the render as a { type: 'raw' } node — hast’s way of saying “emit this string as HTML, verbatim, without escaping it”:
// hast: the actual KaTeX render, after all Markdown parsing is done
const katexHastPlugin = {
name: 'katex-hast',
element: [
{
filter: ['pre'], // block math: <pre><code class="language-math">
visit(node, ctx) {
const code = node.children.find((c) => c.tagName === 'code');
if (!code?.properties?.className?.includes('language-math')) return;
const tex = ctx.textContent(code);
const opts = { displayMode: true, output: 'mathml', throwOnError: false };
return { type: 'raw', value: katex.renderToString(tex, opts) };
}
},
{
filter: ['code'], // inline math: <code class="language-math math-inline">
visit(node, ctx) {
const cls = node.properties?.className ?? [];
if (!cls.includes('language-math') || !cls.includes('math-inline')) return;
const tex = ctx.textContent(node);
const opts = { displayMode: false, output: 'mathml', throwOnError: false };
return { type: 'raw', value: katex.renderToString(tex, opts) };
}
}
]
};
The two filters exist because block and inline math arrive by different routes. Block $$ (via my mdast recast) and ```math fences both land as <pre><code class="language-math">, so the pre filter renders them in display mode.
Inline $...$ never reaches my mdast plugin at all: Sätteri parses it into a separate node type — inlineMath, distinct from the math node my handler is keyed to — and lowers it natively to <code class="language-math math-inline">, which the code filter catches and renders inline.
Trap 1 leaves it alone, too: the highlighter only rewrites block <pre><code> fences, never inline <code> spans, so the language-math class the filter needs survives untouched. And its underscores stay as literal as the block ones, for the same reason: they sit inside a <code> node that never gets re-parsed.
That word raw deserves a second look, though, because the source comment above flagged {raw} — not just {rawHtml} — as re-parsed. The catch is that both of those are mdast escape hatches: nodes in the Markdown tree, re-parsed as Markdown when Sätteri compiles them, underscores and all.
A hast { type: 'raw' } node is the same word one level down — it lives in the HTML tree that Markdown parsing has already produced, and is emitted verbatim when that tree is serialized. Nothing re-parses it. Same spelling, two different AST levels; only the mdast one bites.
I didn’t take that on faith, either: I fed a hast raw node full of underscores straight through the processor — and did the same with an inline $x_2$ — and both came back with their underscores intact.
Two render options earn their place in there: output: 'mathml' keeps the result to a single MathML subtree, and throwOnError: false means a malformed equation renders as a visible error node instead of throwing and failing the whole static build. Wiring the plugins into astro.config.mjs is the last piece — the mdast plugin runs in the Markdown phase, so it recasts the math node before Sätteri’s own mdast→hast lowering would reach it; the hast plugin then runs on the HTML that produces:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import { satteri } from '@astrojs/markdown-satteri';
export default defineConfig({
markdown: {
processor: satteri({
features: { math: true },
mdastPlugins: [katexMdastPlugin],
hastPlugins: [katexHastPlugin]
})
}
});
Two plugins and a features flag, and the math renders.
I’d met this underscore before
I’d seen this failure before. Back on the SvelteKit build I once tried rewriting that same photosynthesis equation — it lives in my prompt-engineering post — from a fenced ```math block into $$, and mdsvex’s bundled micromark read the underscores as emphasis and crashed the Svelte compiler. I reverted it and filed the rewrite away as blocked, with a note: “safe once SvelteKit is gone.”
SvelteKit is gone and the rewrite finally went through — but the same failure found me anyway, in a Rust engine that shares no code with mdsvex. The note was wrong: “a raw-HTML insertion path secretly re-parses as Markdown” is a property you re-verify for each pipeline, not one you fix once and cross off forever.
Making sure it held
Two last checks. To confirm the engine swap hadn’t changed anything else, I built the site on both pipelines — each pinned to render KaTeX with output: 'mathml', so the two were rendering the same target — and ran a byte-level diff across all 15 posts. The only differences were cosmetic — & versus &, attribute ordering — with no structural or content regressions.
With both pipelines built side by side, I timed them, too. On the same 15 posts, across three cold runs each with caches wiped between builds, the classic remark/rehype build came in around 2.5s and the Sätteri build around 2.2s — a modest but consistent gap of roughly 13%, about a third of a second. The dramatic number in this blog’s history was never the engine; it was leaving SvelteKit for Astro a few phases earlier, which took a ~5.5-second build down to ~2.5. The engine swap then shaved that to ~2.2 — a small, honest increment on a build that was already fast.

And one final trap: right after landing the fix, pnpm build still showed the corrupted <em>. Astro’s content-layer cache (.astro/data-store.json) had held onto the broken render; my change was plugin-only, with no source edit, so nothing prompted it to invalidate — a rm -rf .astro node_modules/.astro build and one more build finally produced clean math. There’s now an underscore-containing equation in the regression tests, guarding this for the next person — probably future me.
Wrapping up
Porting a KaTeX pipeline onto Sätteri came down to under eighty lines: one mdast plugin that recasts block math to a code node, one hast plugin that renders it. Most of the time went not into those lines but into learning the engine’s edges — the highlighter that runs before your plugins, the rawHtml hatch that re-parses its input. Both are behavior you learn from the source rather than the docs, not flaws in the design; the plugin API itself was a pleasure to work against. What remark and rehype still have over it is a decade of ecosystem — every plugin you’d otherwise reach for. For a pre-1.0 engine, Sätteri is off to a remarkably strong start.
Further Reading
- Sätteri plugin docs — the official reference for the
ctx-based mdast/hast plugin API, includingreplaceNodeand the raw escape hatches. - Astro 7.0 release notes — covers the Rust compiler, Vite 8, and Sätteri becoming the default markdown and MDX processor.
- rehype-katex — the classic-pipeline plugin this port replaces; still available through
@astrojs/markdown-remark’sunified()escape hatch if you’d rather not port anything.