Porting KaTeX to Astro 7's Rust Markdown Engine (Sätteri)
Published: 2026-07-10
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. Two traps cost the most time: the highlighter runs before my plugin and swallowed $$ block math, and the rawHtml escape hatch re-parses its input as Markdown, mangling my LaTeX subscript underscores into <em> tags. 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 and cuts build times noticeably. 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 in the first place. My blog wasn’t one of those sites: it renders math with KaTeX, and math rendering has no native equivalent. I could have kept the classic pipeline through that escape hatch and left the default switched 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 only pieces I had to write myself were the two the engine won’t do for me: rendering KaTeX, and setting target/rel attributes on external links. Sätteri already handled 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.
A caveat on all this source-reading: the Sätteri internals I quote in this post are from [email protected] and @astrojs/[email protected], 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 });
ctx.replaceNode(node, { rawHtml: html });
}
};
Following Sätteri’s context API turned out to be comfortable — ctx.replaceNode, ctx.setProperty, and ctx.textContent() are all it takes, and the katex package’s renderToString() is reusable as-is. The unfamiliar mental model is the only real cost, and it’s a small one. The plugin above looks completely reasonable, and it mostly works — but the ways it doesn’t only surfaced once I ran real equations through the engine.
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. Working in small steps like this keeps the feedback loop short: I could check an assumption in seconds, instead of editing real files and rebuilding to find out I was wrong.
One pnpm wrinkle: Sätteri is a transitive dependency, so it isn’t hoisted to the top of node_modules. A bare import { markdownToHtml } from 'satteri' fails with ERR_MODULE_NOT_FOUND — you have to import it by its full node_modules/.pnpm/... path unless you also add it as a direct dependency.
With that in place, I could feed real equations through the exact processor my config would use and read the output immediately. That’s 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 produces a code node with no data.lang at all (it defaults to "plaintext"), so the highlighter doesn’t skip it. It highlights the math as if it were a plaintext code block, stripping the language-math class before my plugin ever sees the node.
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 ... */
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. That’s the broken rendering I’d been staring at.
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 then happens one level down, in a hast plugin that filters on language-math and inserts the result as a { type: 'raw' } node — hast’s way of saying “emit this string as HTML, verbatim, without escaping it.” Inline $...$ math and ```math fences both terminate there too. Inserting raw HTML at this level is safe precisely because it happens after all Markdown parsing is done — there’s nothing left to misread the underscores.
I’d met this underscore before
I’d seen this exact 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 $$ syntax. The build died immediately: mdsvex’s bundled micromark read \text{6 CO}_2 as emphasis, turned _2 into <em>2</em>, and the resulting unbalanced tags crashed the Svelte compiler. Nothing ever shipped broken; I reverted the change, kept the fenced blocks, 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 on the way there, in a Rust engine that shares no code with mdsvex. Same CommonMark emphasis rule, same LaTeX underscores, entirely different pipeline. The note was wrong — not because the mdsvex cause hadn’t been removed, but because “a raw-HTML insertion path secretly re-parses as Markdown” is a property you have to re-verify for each pipeline, not a thing 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 stashed my changes, built the site on the classic pipeline to get a baseline, then ran a byte-level diff of that against the full pnpm build output from the Sätteri branch, across all 15 posts. The only surviving differences were cosmetic — things like & versus & and attribute ordering — with no structural or content regressions.
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 cached the broken render and doesn’t invalidate on a markdown-plugin change, only on source changes — 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 does the actual rendering. Most of the time went not into writing those lines but into learning the engine’s edges — the highlighter that runs before your plugins, and the rawHtml hatch that re-parses its input. Both are undocumented behavior rather than 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, and the speed is worth it.
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.