Reference

Plugin API

See Plugins for a walkthrough.

Link to this headingPlugin definition

Wrap a plugin with defineMdastPlugin or defineHastPlugin for type inference on its visitors. Both return the plugin unchanged.

A plugin is an object with a name and one visitor per node type you want to handle:

const plugin = defineMdastPlugin({
  name: "my-plugin",
  heading(node, ctx) {
    /* ... */
  },
  link(node, ctx) {
    /* ... */
  },
});

Link to this headingPassing plugins

mdastPlugins and hastPlugins take a list of entries. An entry is a definition, a factory returning one, a bundle of entries, or a skip value:

type MdastPluginEntry = MdastPluginDefinition | ((ctx: PluginFactoryContext) => MdastPluginEntry) | readonly MdastPluginEntry[] | null | undefined | false;
type MdastPluginList = readonly MdastPluginEntry[];

HastPluginEntry and HastPluginList are the HAST-side equivalents. The shapes compose: a factory may return a bundle, a bundle may contain factories, and a skip value is accepted wherever an entry is.

A definition is used as-is for every document. Reach for one whenever the plugin keeps no state between compiles.

A factory is a function called once per compile, so anything it closes over resets for each document. It may return a bundle as well as a single plugin, which is how a preset gives its plugins state that they share with each other but that still resets per document:

const headingAnchors = () => {
  const slugs = new Set(); // one set per compile, shared by both plugins below
  return [collectSlugs(slugs), linkSlugs(slugs)];
};

markdownToHtml(source, { mdastPlugins: [headingAnchors] });

A bundle is an array of entries, nested as deeply as you like, so a package can export a group of plugins that is passed without spreading:

import { typography } from "some-package"; // an array of plugins

markdownToHtml(source, { mdastPlugins: [typography, myPlugin] });

The bundle's plugins run in their own order, at the bundle's position, so the list above is equivalent to spreading typography in place.

A skip value (null, undefined or false) leaves that entry out, so a condition can go straight in the list:

markdownToHtml(source, { mdastPlugins: [isDev && debugPlugin, myPlugin] });

Only those three are skipped. Any other value that is not a plugin, a factory or a list is rejected with an error naming the option, so a condition that yields 0 or "" is caught rather than silently dropped.

A factory can return a skip value too, which is how one plugin runs on some documents and not others. Every factory is handed a context describing the document about to be compiled:

interface PluginFactoryContext {
  readonly fileURL: URL | undefined;
  readonly sourceFormat: "markdown" | "mdx";
  readonly source: string;
  readonly data: Data;
}
const onlyChangelogs = (ctx) => (ctx.fileURL?.pathname.endsWith("/CHANGELOG.md") ? rewriteVersions : null);

markdownToHtml(source, { mdastPlugins: [onlyChangelogs, myPlugin] });

Only what is known before parsing is available: there is no tree and no frontmatter yet. source is the unparsed document, meant for cheap checks such as "does this contain a code fence at all", not for parsing Markdown by hand. data is the same bag the visitors later read and write through ctx.data, so a factory can seed it for the plugins that run after it.

Skipping here rather than returning early inside a visitor is what makes it worth doing: a plugin that is never added registers no visitors, and the pipeline picks its parsing and rendering strategy from the plugins that remain. A document that skips every plugin is compiled by the same fast path as one that was passed no plugins, and position tracking is skipped unless a plugin that actually runs asks for it. A factory returning a skip value drops the whole bundle it would otherwise have returned, so a preset can enable or disable itself as a unit. Factories still run once per compile even when they skip, so keep them cheap.

Link to this headingSource positions

Visitors read node.position (the { start, end } source range) only when the plugin opts in with options: { position: true }. Tracking positions adds a measurable parsing cost (~15% of parse), so it is off by default. node.position is undefined unless some plugin in the pipeline requests it.

const plugin = defineMdastPlugin({
  name: "needs-source-range",
  options: { position: true },
  heading(node) {
    console.log(node.position); // { start, end } instead of undefined
  },
});

A single opted-in plugin enables positions for the whole pipeline, so a later plugin sees them too.

Link to this headingMDAST visitors

An MDAST plugin maps node types to visitor functions. Each visitor receives the node (as Readonly) and a ctx object.

type MdastVisitor<N> = (node: Readonly<N>, ctx: MdastVisitorContext) => MdastVisitorResult | Promise<MdastVisitorResult>;

type MdastVisitorResult =
  | MdastNode // replace with this node
  | { raw: string; mdxExpressions?: boolean } // splice in a string, re-parsed as Markdown
  | { rawHtml: string } // deprecated — see below
  | undefined
  | null
  | void; // keep node, apply ctx mutations

To inject HTML, return { raw: "<span>…</span>", mdxExpressions: false } rather than an mdast html node ({ type: "html", value }) — the latter renders under markdownToHtml but throws under mdxToJs. Under markdownToJs neither form survives: HTML has no JSX representation there, so it is dropped along with any HTML the document itself contains. Enable features: { rawHtml: true } to have injected HTML parsed into real elements. See Return value semantics.

Link to this headingSupported visitor keys

Keys without a feature note are always available. Feature-gated keys only fire when the corresponding flag is enabled in features.

KeyFeature
paragraph
heading
thematicBreak
blockquote
list
listItem
html
code
definition
text
emphasis
strong
inlineCode
break
link
image
linkReference
imageReference
custom
tablegfm
tableRowgfm
tableCellgfm
deletegfm
footnoteDefinitiongfm
footnoteReferencegfm
mathmath
inlineMathmath
yamlfrontmatter
tomlfrontmatter
containerDirectivedirective
leafDirectivedirective
textDirectivedirective
superscriptsuperscript
subscriptsubscript
descriptionListdefinitionList
descriptionTermdefinitionList
descriptionDetailsdefinitionList
mdxJsxFlowElementMDX entry
mdxJsxTextElementMDX entry
mdxFlowExpressionMDX entry
mdxTextExpressionMDX entry
mdxjsEsmMDX entry

MDX visitor keys only fire when the document is compiled via the MDX entry point (mdxToJs or .mdx imports), not from markdownToHtml or markdownToJs — those parse MDX syntax as ordinary Markdown text, so the nodes never exist.

Link to this headingCustom nodes

Plugins aren't limited to the built-in node types. Return a node whose type is any string not in the table above and it becomes a custom node: it round-trips through the pipeline, later plugins still visit its content, and it renders to HTML as a real element. A container directive is the alternative, but it renders nothing at all unless a plugin gives it an hName, which takes its whole subtree with it.

A custom node renders as either shape:

  • a parent (with children) becomes an element through data.hName (defaulting to <div>), with data.hProperties merged onto it and its children rendered;
  • a leaf (with a non-empty value, and no children or data.h*) becomes an HTML text node.
// A sectionizing wrapper: swap a block for a <section> holding its children.
paragraph(node, ctx) {
  ctx.replaceNode(node, {
    type: "section",
    data: { hName: "section", hProperties: { className: ["note"] } },
    children: node.children,
  });
}

A node carrying both children and a value renders as a parent; the value is ignored. Only children, value, data and position survive the round trip, so per-node metadata belongs in data:

ctx.replaceNode(node, {
  type: "section",
  data: { hName: "section", depth: 2 }, // `depth` on the node itself would be dropped
  children: node.children,
});

The custom visitor key fires for every user-defined node, whatever its type; discriminate on node.type inside the visitor.

const inspect = defineMdastPlugin({
  name: "inspect",
  custom(node) {
    if (node.type === "section") {
      /* ... */
    }
  },
});

There is no per-type visitor key: a section(node) {} key is not a subscription and never fires. And because any string is a valid custom type, a misspelled built-in (paragrph) is a custom node rather than an error — it renders as a <div> instead of failing.

Link to this headingHAST visitors

HAST plugins come in two shapes depending on the node type.

Link to this headingFiltered visitors

element and MDX JSX nodes carry a tag/component name, so their visitors take an explicit filter and only run for matching nodes.

type HastFilteredVisitor<N> = {
  filter: string[];
  visit(node: Readonly<N>, ctx: HastVisitorContext): HastNode | void | Promise<HastNode | void>;
};

filter is required. The filter is matched against element.tagName for element and against name for MDX JSX nodes (case-sensitive). An empty filter (filter: []) matches every node of that type — handy for sweeping passes, but it can get expensive on large documents, so name tags when you can.

To register multiple filtered visitors for the same node type, pass an array:

const plugin = defineHastPlugin({
  name: "headings-and-links",
  element: [
    {
      filter: ["h1", "h2", "h3"],
      visit(node, ctx) {
        /* headings */
      },
    },
    {
      filter: ["a"],
      visit(node, ctx) {
        /* links */
      },
    },
  ],
});
KeyFiltered on
elementtagName
mdxJsxFlowElementname (JSX)
mdxJsxTextElementname (JSX)

Link to this headingBare visitors

Leaf and value nodes don't carry a name, so they take a plain function that fires for every node of that type.

type HastVisitor<N> = (node: Readonly<N>, ctx: HastVisitorContext) => HastNode | void | Promise<HastNode | void>;
KeyNotes
text
comment
rawPass-through HTML chunks
doctype
mdxFlowExpressionHas .parseExpression() helper
mdxTextExpressionHas .parseExpression() helper
mdxjsEsmHas .parseExpression() helper

Link to this headingMDX expression helper

MDX expression and ESM nodes get a parseExpression() method attached that returns the value parsed as an ESTree Program, or null if the value is missing.

mdxFlowExpression(node) {
  const tree = node.parseExpression();
  // tree is an ESTree Program
},

Link to this headingLifecycle hooks

Besides visitors, both plugin kinds accept two lifecycle hooks. Each runs exactly once per document, whether or not any of the plugin's visitors match, and receives the document root plus the usual ctx:

  • before(root, ctx) runs before any of the plugin's visitors, to seed ctx.data or closure state they read, or to reshape the tree they are about to walk.
  • after(root, ctx) runs after all of the plugin's visitors have settled (async ones included), so it can emit output built from state they collected, against the tree they left behind.

after is the place for per-document work that must not depend on any particular node existing, such as injecting an ESM export:

// A factory, so the collected headings reset for each document.
const toc = () => {
  const headings = [];
  return defineMdastPlugin({
    name: "toc",
    heading(node, ctx) {
      headings.push(ctx.textContent(node));
    },
    after(root, ctx) {
      ctx.appendChild(root, {
        type: "mdxjsEsm",
        value: `export const toc = ${JSON.stringify(headings)};`,
      });
    },
  });
};

The child operations (appendChild, prependChild, insertChildAt, removeChildAt) work on the root as they do on any node, as do removeNode, setProperty and wrapNode. The sibling ones do not: the root has no siblings, so insertBefore and insertAfter throw on it.

replaceNode works on the root too, and it is how a hook swaps the whole document for a tree it built itself. The root is the one place a root node is accepted as content. That and the { raw } escape hatch, which parses to a root of its own, are all it accepts: a document headed by anything else stops firing hooks. Children taken from the old root are reused as they are, rather than rebuilt:

after(root, ctx) {
  ctx.replaceNode(root, {
    type: "root",
    children: [{ type: "mdxjsEsm", value: "export const toc = [];" }, ...root.children],
  });
},

Hooks are procedures, not transformers: their return values are ignored (an async hook is awaited), so mutate via ctx. Each hook is its own pass, applied before the next one starts, so the ordering is the one the names imply: whatever before queues is already in the tree the plugin's visitors walk, and root.children in after reflects what those visitors did.

Link to this headingNode lifetime

In order to avoid very expensive serialization costs between Rust and JS, Sätteri keeps both mdast and hast trees exclusively in Rust, exposing nodes to JavaScript plugins only as thin references when possible.

This means that ergonomics are slightly different than one might expect from a plain JavaScript tree, and understanding of reference vs copy semantics is important to avoid bugs.

A node kept past its visitor pass reads as the tree looked during that pass: later plugins' mutations are never reflected in it. Reads on a retained node keep working as long as its pass's snapshot is recoverable, which is the case when node content was resolved from the tree during the pass (this pins the pass snapshot for every node handed out in it), or when nothing has mutated or freed the tree yet by the time of the first read. Resolving means reading a child node's field or calling ctx.parent()/ctx.indexOf(); eagerly decoded fields such as an element's tagName or properties come with the node and do not pin anything. The one unrecoverable case throws: a node whose content was never resolved in-pass, first read after the tree has changed or the pipeline has ended. The error says exactly that.

Node objects are shared, not copied: the same underlying node reached through any path (children, ctx.parent(), a later pass over an unchanged tree) is the same JavaScript object, and it is frozen: assigning to its fields, position, properties, or attributes throws a TypeError rather than corrupting what later plugins see. Go through the context methods for changes, or copy first (structuredClone(node)) and edit the copy.

Retaining a node keeps its whole pass snapshot alive in memory until the node is garbage collected. To keep just a node's data beyond the visit, prefer an explicit copy of it and its subtree. For example, to collect all headings in a document:

const headings = [];

defineHastPlugin({
  name: "collect-headings",
  element: {
    filter: ["h1", "h2"],
    visit(node) {
      headings.push(structuredClone(node));
    },
  },
});

Use structuredClone(node) for a deep, fully independent copy of the node and its subtree, or { ...node } for a cheaper shallow copy when you only need this node's own fields.

To get a plain JavaScript tree of the whole document, use markdownToMdast or markdownToHast:

import { markdownToMdast } from "satteri";

const tree = markdownToMdast(source); // plain objects, yours to keep

Note that keeping nodes in Rust is one of Sätteri's main performance advantages: the more data you copy into JavaScript, the more expensive your plugin becomes.

Link to this headingMutation context

MDAST and HAST contexts share the same shape (with small differences in setProperty and textContent). Mutations are buffered and applied after the visit completes, so it's safe to mutate while iterating.

Mutate through the context, not the node. A node is a read-only view over the Rust-side tree, so a direct write like node.depth = 2 has no effect (and is a TypeScript error). Go through the context instead:

heading(node, ctx) {
  // node.depth = 2;                 // ignored
  ctx.setProperty(node, "depth", 2); // do this
}

Link to this headingProperties

PropertyTypeNotes
sourcestringOriginal markdown source.
fileURLURL | undefinedURL of the document being processed, or undefined when none given.
dataDataDocument-scoped data bag shared across every plugin in the pipeline. Survives the mdast→hast boundary. Returned to the caller as result.data. Kept on the JS side, so any value is allowed (functions, class instances, etc.).
sourceFormat"markdown" | "mdx"Which kind of file the plugin is currently running on.

Keys on data are typed as unknown by default. Register a key's type by augmenting DataMap:

declare module "satteri" {
  interface DataMap {
    headings: string[];
  }
}

Link to this headingTree mutation

MethodEffect
removeNode(node)Drop the node from its parent
replaceNode(node, newNode)Swap the node for a different one, or for several
insertBefore(node, newNode)Insert a sibling before the node
insertAfter(node, newNode)Insert a sibling after the node
wrapNode(node, parentNode)Wrap the node in parentNode (becomes its first child)
prependChild(node, childNode)Insert childNode as the first child of node
appendChild(node, childNode)Insert childNode as the last child of node
insertChildAt(node, index, childNode)Insert childNode as the index-th child of node
removeChildAt(node, index)Remove the index-th child of node
setProperty(node, key, value)Replace one field on the node

wrapNode places the wrapped node as parentNode's first child. If parentNode declares its own children, they are kept after it. Wrapping a heading in a <div> that holds an anchor link yields <div><h2>…</h2><a>…</a></div>. To put the node at an arbitrary position instead, return a replacement from the visitor.

parentNode must be a node type that can hold children — a HAST element, an MDX JSX element, an MDAST container like blockquote, or a custom node declaring a children array. Leaf nodes (html, text, …) and leaf-shaped custom nodes have no slot for the wrapped node, so wrapNode rejects them. A void HAST element (img, br, …) is rejected for the same reason: it renders as a lone tag, so its children would never reach the output.

wrapNode also takes the { raw } string shape the other mutations take. The string is parsed at apply time and must yield exactly one wrapper: an HTML fragment holding one non-void element in a HAST plugin, one block that can hold children in an MDAST plugin. Its own children are kept after the wrapped node, and anything else (no block, several blocks, a leaf) fails the compile.

ctx.wrapNode(node, { raw: '<div class="callout"></div>' }); // hast
ctx.wrapNode(node, { raw: "> " }); // mdast: a blockquote

The deprecated { rawHtml } still works and behaves like { raw, mdxExpressions: false }. No MDAST block wraps a node in a pair of raw HTML tags (<div></div> parses to a leaf html node), so surround the node with the tag halves instead:

ctx.replaceNode(node, [{ type: "html", value: "<div>" }, node, { type: "html", value: "</div>" }]);

replaceNode, insertBefore, insertAfter, prependChild, appendChild, and insertChildAt each accept either a single node or an array of nodes. An array is inserted in order at the target position, so replaceNode(node, [a, b]) leaves a and b where node was. Passing replaceNode an empty array removes the node.

For MDAST, key must be a field of the node type and value must match that field's type. For HAST, key is a string and value is unknown.

For HAST elements, setProperty takes a HAST property key (e.g. "className", "href"). For MDX JSX nodes (mdxJsxFlowElement / mdxJsxTextElement), it sets the named JSX attribute on the attributes array.

Link to this headingInspection

MethodEffect
textContent(node, options?) (MDAST)Concatenated text of the subtree. Options: { includeImageAlt?: boolean, includeHtml?: boolean }.
textContent(node) (HAST)Concatenated text of the subtree. Mirrors DOM textContent.
parent(node)The node's parent, or undefined at the root.
indexOf(node)Index of the node in its parent's children, or undefined at the root.

Link to this headingDiagnostics

MethodEffect
report({ message, node?, severity? })Push a diagnostic. severity defaults to "error"; allowed values are "error" | "warning" | "info".
getDiagnostics()Return all diagnostics collected so far.

report doesn't abort the plugin; diagnostics are collected and returned with the compile result.

Link to this headingReturn value semantics

ReturnedMDASTHAST
undefined / null / voidKeep node, apply ctx mutationsSame
The same node objectSame (no-op replace)Same
A different nodeReplace the visited nodeReplace
{ raw: string }Splice a string, re-parsed as MarkdownN/A
{ raw: string, mdxExpressions: false }Same, but keep MDX {…} literalN/A

{ raw } takes a string and re-parses it as Markdown, splicing the result in place of the node.

The mdxExpressions option (default true) controls how MDX curly braces in the string are treated. With the default, {…} is a live MDX expression. Set mdxExpressions: false to keep { and } as literal text — necessary when you inject generated HTML whose braces are not expressions, e.g. a Mermaid decision node C{JWT valid?} or KaTeX/Shiki output. When the source is plain Markdown (markdownToHtml, markdownToJs) the option has no effect — the spliced string re-parses as Markdown, where there are no MDX expressions — so { raw } and { raw, mdxExpressions: false } are identical there.

Link to this headingAsync plugins

Any visitor may return a Promise. Sync and async visitors can be mixed freely. If any visitor in the pipeline is async, markdownToHtml, mdxToJs, and markdownToJs return a Promise; otherwise they return synchronously.

The return type is decided from the plugins the types can see, so a factory that may return an async plugin types the compile as a Promise even on a document where it skips and the result comes back synchronously. Use await on the result rather than calling .then() on it.

For performance, prefer sync visitors where you can: awaiting per match adds up, especially for a visitor that matches many nodes.

Link to this headingExecution order

Plugins run in array order. MDAST plugins run first against the parsed MDAST tree. Sätteri then converts to HAST and runs the HAST plugins. Each plugin sees the tree as left by the previous one.

To share state across visits within a document, close over a variable in the surrounding scope. To reset that state between documents, pass a factory instead of a definition.

Link to this headingHow transforms compose

Each Sätteri plugin walks the tree once — there is no re-walking until the tree stops changing. Within that single pass:

  • Passed-through children keep their identity. When a visitor returns a replacement that reuses the original children (e.g. { ...node, children: [...node.children] }), those children are spliced back unchanged, so a transform queued on a nested one in the same pass still applies. This is what lets a single containerDirective visitor turn both an outer :::note and a nested :::tip into asides in one go.
  • A plugin's own freshly-built nodes are not re-walked by that plugin. A brand-new node a visitor returns isn't visited again by the same plugin. Produce its final shape directly, or hand it to a later plugin — every plugin runs over the fully materialized output of the ones before it. A before hook is the exception: it lands before the walk, so nodes it builds are visited.
  • Dropping a subtree drops the transforms queued inside it. If one visitor removes or replaces a node while another queued a transform on something inside that subtree, the orphaned transform is dropped and a warning is logged. Usually that's intended; the warning catches the cases where it isn't.
  • Nodes from another document throw. Handing a context method a node kept from a previous compile — or an mdast node inside a hast plugin — fails the compile. Keep nodes around within a document freely; don't carry them across.
  • A few contradictory combinations throw. Replacing a node with new content that reuses that same node while another plugin edits something inside it in the same pass, two replacements that each reuse the other's node, and inserting a sibling next to the root. Replacing, removing, or wrapping the root itself — say, via ctx.parent() on a top-level node — works fine.