Astro 7 ships Sätteri, a Markdown and MDX engine written in Rust. It is genuinely fast. The Astro team measured over a minute off their own docs build.
It also does not run your remark or rehype plugins. Not “runs them slower”. It has its own AST, its own parser, its own serializer. The unified ecosystem does not apply.
Most migration guides stop there. What they don’t tell you is that Astro’s Sätteri integration inserts plugins of its own around yours, and the resulting order breaks three things without raising a single error. Your build goes green. Your output is wrong.
I found all three the hard way, porting three plugins and then building a real site with them.
First, what you don’t need to port
Before hunting for replacements, check this list. A lot of the unified stack is now a parser flag:
| Plugin | Replacement in Sätteri |
|---|---|
remark-gfm |
features.gfm, on by default |
remark-frontmatter |
features.frontmatter, on by default |
remark-math |
features.math (parsing only, see below) |
remark-directive |
features.directive |
remark-smartypants |
features.smartPunctuation |
remark-wiki-link |
features.wikilinks |
remark-sup / remark-sub |
features.superscript / features.subscript |
remark-parse, remark-rehype, rehype-stringify |
the pipeline itself |
That is a real chunk of the average Astro config deleted outright.
Now the part that bites.
The pipeline
Astro’s @astrojs/markdown-satteri builds the HAST plugin list like this:
const hastPlugins = [];
if (highlightFn) hastPlugins.push(createHighlightPlugin(...));
hastPlugins.push(...userHastPlugins);
hastPlugins.push(createImageMarkerPlugin());
hastPlugins.push(createHeadingIdsPlugin());
So:
[ syntax highlighter ] → [ your hastPlugins ] → [ image marker ] → [ heading ids ]
Two of yours are in the middle. Everything below follows from that.

Gotcha 1: display maths becomes a plaintext code block
Enable maths and write the obvious thing:
Inline $a^2 + b^2 = c^2$ works.
$$
\int_{-\infty}^{\infty} e^{-x^2} \, dx = \sqrt{\pi}
$$
Inline maths renders. Display maths comes out as a syntax-highlighted code block:
<pre class="astro-code github-dark" data-language="plaintext">
<code><span class="line"><span>\int_{-\infty}^{\infty} e^{-x^2} \, dx = \sqrt{\pi}</span></span></code>
</pre>
Here’s why. Sätteri parses maths into this HAST:
<!-- inline --> <code class="language-math math-inline">a^2 + b^2 = c^2</code>
<!-- display --> <pre><code class="language-math math-display">\int ...</code></pre>
Astro’s highlighter filters on pre, reads codeChild.data?.lang ?? "plaintext", and highlights.
Display maths is a pre > code. It gets claimed before any HAST plugin of yours runs. Inline maths
is a bare code, which the highlighter ignores. That is exactly why it looks half-working and
sends you hunting in the wrong place.
The fix: render maths on MDAST, not HAST. MDAST math / inlineMath nodes exist before any
HAST plugin, so ordering cannot touch them:
import { satteriKatex } from "satteri-katex";
export default defineConfig({
markdown: {
processor: satteri({
features: { math: true },
mdastPlugins: [satteriKatex()], // NOT hastPlugins
}),
},
});
Worth internalising the general lesson: on Sätteri, prefer MDAST when the node type exists there.
It is earlier, cheaper, and out of reach of whatever else is fighting over your pre elements.
Gotcha 2: heading anchors silently vanish
Standard docs-site setup: ids on headings, anchor links beside them. Under unified that is
rehype-slug + rehype-autolink-headings. So you reach for the equivalents, and you notice Astro
already adds heading ids, so you skip the slug plugin.
Result: no anchors at all. No error.
Astro’s createHeadingIdsPlugin() is appended after your plugins. At the moment your autolink
plugin runs, the headings have no id yet, so it correctly skips every one of them.
I measured it on a real page: 10 anchors with the slug plugin, 1 without.
hastPlugins: [
satteriSlug(), // must come first: assigns the ids
satteriAutolinkHeadings(), // reads them
]
Astro’s own plugin keeps an existing id rather than overwriting, so nothing conflicts, and
Astro.props.headings still works. You are just moving the id assignment earlier.
Also note: outside Astro (raw satteri, vite-plugin-satteri, your own pipeline), nothing
assigns heading ids. Sätteri does not do it. Astro does.
Gotcha 3: satteri() throws away options you pass it
I wanted dual-theme code blocks, so:
processor: satteri({
shikiConfig: { themes: { light: "github-light", dark: "github-dark" } }, // ignored
features: { math: true },
})
Nothing happened. No warning, no type error. The wrapper is this:
function satteri(opts = {}) {
return {
name: "satteri",
options: {
mdastPlugins: [...opts.mdastPlugins ?? []],
hastPlugins: [...opts.hastPlugins ?? []],
features: { ...opts.features },
},
// ...
};
}
Three fields. Everything else is dropped on the floor. shikiConfig, gfm, smartypants belong one
level up, next to processor:
markdown: {
shikiConfig: { themes: { light: "github-light", dark: "github-dark" } },
processor: satteri({ features: { math: true }, mdastPlugins: [...] }),
}
Writing your own Sätteri plugin
The API is small and rather nice. A plugin declares which node types it wants, and the engine only calls you for those, and that is where the speed comes from, versus unified walking the whole tree per plugin.
import { defineHastPlugin } from "satteri";
const externalLinks = defineHastPlugin({
name: "external-links",
element: {
filter: ["a"], // required for element visitors
visit(node, ctx) {
const href = node.properties?.href;
if (typeof href === "string" && /^https?:/.test(href)) {
ctx.setProperty(node, "target", "_blank");
ctx.setProperty(node, "rel", ["nofollow", "noopener"]);
}
},
},
});
Four things that differ from unified and will catch you out:
- One pass. A plugin never re-walks nodes it created. No
visit-until-stable idioms. - Per-document state needs a factory. Pass
() => definition; the engine resets it per file. Anything with a counter, such as slug dedupe or footnote numbering, must do this or state leaks between pages. - Node handles are not plain objects. Remove a child and you cannot re-insert it. To restructure children, return a replacement node instead of mutating.
- No root fragments. You cannot return
{ type: "root", children: [...] }to splice in siblings. Usectx.insertBefore/ctx.insertAfter.
The packages
I ported the three I needed, tested against the originals’ actual output:
satteri-slugreplacesrehype-slug. Samegithub-slugger, so ids match what you had.satteri-autolink-headingsreplacesrehype-autolink-headings. All five behaviours,content,properties,group,test.satteri-katexreplacesrehype-katex. Renders on MDAST, per gotcha 1.
To be clear about the landscape: the Sätteri plugin ecosystem is not empty. There are around 45 community plugins already, and if you need external links, emoji, callouts, mermaid or a TOC, those exist. It is fragmented rather than absent. These three filled gaps that were genuinely unfilled.
How they were built, since ports are easy to get subtly wrong
A port that “looks right” is worthless. You find out it diverges six months later, in production. So each one went:
- Characterise the original. Run the real remark/rehype plugin over a fixture corpus, capture its HTML. That output is the spec, not the original’s source and not its README.
- Failing test first, asserting that captured HTML.
- Implement.
- Mutation-test. Break the implementation deliberately; confirm a test catches it.
- Verify in a real Astro build, asserting on the HTML on disk.
Step 4 earned its keep immediately. My KaTeX XSS test asserted that $<img src=x onerror=alert(1)>$
did not emit <img. It passed. It was worthless, because KaTeX renders that input successfully and
escapes it itself, so the test never reached the error path it claimed to cover. The real hazard is
input that both fails to parse and contains markup, like $<img src=x>\frac{a}$, whose source
gets echoed back into the page. A mutant that deleted my escaping survived, which is how I found out.
Step 5 earned its keep too: all three Astro gotchas above were found there, with every unit test already green.
Try it
Live page, built by Astro 7 in CI with all three plugins, asserted on in the test suite: https://ashish-codejourney.github.io/satteri-plugins
Source, plus a full “what replaces my plugin?” table: https://github.com/Ashish-CodeJourney/satteri-plugins
If you are porting a plugin yourself, CONTRIBUTING.md documents the method above in detail, and
PRs are welcome. rehype-sanitize, rehype-highlight and a unified compatibility shim are still
unclaimed.
Credit where it is due: these are ports. The behaviour, the option names and most of the edge cases are the work of Titus Wormer and the remark/rehype contributors. Sätteri is by the bruits team.
Originally published on dev.to.
