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.

Diagram of Astro’s Satteri HAST plugin pipeline as four boxes in a row: syntax highlighter, your hastPlugins, image marker, heading ids. The first, third and fourth are labelled ASTRO; the second is labelled YOURS and highlighted, showing that your plugins run in the middle. Below are the three consequences: maths becomes a code block because the highlighter claims the display block first, anchors vanish because heading ids arrive after your plugins looked for them, and options are dropped because satteri() keeps only three fields.

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:

  1. One pass. A plugin never re-walks nodes it created. No visit-until-stable idioms.
  2. 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.
  3. 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.
  4. No root fragments. You cannot return { type: "root", children: [...] } to splice in siblings. Use ctx.insertBefore / ctx.insertAfter.

The packages

I ported the three I needed, tested against the originals’ actual output:

  • satteri-slug replaces rehype-slug. Same github-slugger, so ids match what you had.
  • satteri-autolink-headings replaces rehype-autolink-headings. All five behaviours, content, properties, group, test.
  • satteri-katex replaces rehype-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:

  1. 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.
  2. Failing test first, asserting that captured HTML.
  3. Implement.
  4. Mutation-test. Break the implementation deliberately; confirm a test catches it.
  5. 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.