Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
9b81609
feat(mdx): Rust MDX via satteri
Princesseuh May 11, 2026
bcce353
feat: make Markdown pluggable
Princesseuh May 12, 2026
f6b7872
fix: clean deps
Princesseuh May 12, 2026
4d0a4c4
fix: tests
Princesseuh May 12, 2026
5e54e36
fix: lint
Princesseuh May 12, 2026
d9a0f66
fix: tests
Princesseuh May 12, 2026
8a4a7ef
fix: don't commit notes
Princesseuh May 12, 2026
838a475
fix: test assert
Princesseuh May 12, 2026
c204fd0
Merge branch 'next' into feat/erika-rust-mdx
Princesseuh May 12, 2026
ac179d1
refactor: move things where they make sense
Princesseuh May 13, 2026
d74c82e
chore: remove unused deps
Princesseuh May 13, 2026
a03e004
feat: update Sätteri
Princesseuh May 18, 2026
857f322
fix: update deps
Princesseuh May 18, 2026
e9e0a6e
fix: don't blow up memory all over
Princesseuh May 19, 2026
d15120a
Merge branch 'next' into feat/erika-rust-mdx
Princesseuh May 19, 2026
a1e593a
chore: changeset
Princesseuh May 19, 2026
a108c43
fix: tests
Princesseuh May 19, 2026
8857e17
fix: update satteri
Princesseuh May 19, 2026
1fbfd6e
feat: deprecate other properties
Princesseuh May 19, 2026
a7fa4ad
Merge remote-tracking branch 'origin/next' into feat/erika-rust-mdx
Princesseuh May 20, 2026
d26ddd4
fix: test
Princesseuh May 20, 2026
f8e5d19
fix: just disable smartpunctuation actually
Princesseuh May 20, 2026
8ab9eac
refactor: and tests
Princesseuh May 21, 2026
74c5832
Merge branch 'next' into feat/erika-rust-mdx
Princesseuh May 21, 2026
7853a2f
chore: clean up
Princesseuh May 21, 2026
209b79b
fix: build
Princesseuh May 21, 2026
7c5521f
chore: update satteri
Princesseuh May 21, 2026
0afe01f
refactor: deduplicate code
Princesseuh May 21, 2026
6827983
fix: format
Princesseuh May 21, 2026
1f15ab6
fix: address comments
Princesseuh May 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions .changeset/seven-emus-knock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
'astro': major
'@astrojs/markdown-remark': major
'@astrojs/markdown-satteri': major
'@astrojs/mdx': major
---

Adds the new `markdown.processor` config option, which uses [satteri](https://github.com/bruits/satteri) (a Rust-based Markdown/MDX compiler) by default. Both `.md` and `.mdx` files are now rendered through satteri unless you opt back into the legacy remark/rehype pipeline with the new `unified()` factory.

The previous `experimental.nativeMarkdown` flag has been removed; the same functionality is now available without an experimental opt-in:

```js
// astro.config.mjs — default, no changes needed
import { defineConfig } from 'astro/config';

export default defineConfig({});
```

To pass satteri plugins or enable additional parser features, use the new `satteri()` factory:

```js
// astro.config.mjs
import { defineConfig, satteri } from 'astro/config';

export default defineConfig({
markdown: {
processor: satteri({
hastPlugins: [myPlugin],
features: { directive: true, definitionList: true },
}),
},
});
```

To keep using your existing remark/rehype plugins, opt into the `unified()` processor from `@astrojs/markdown-remark`:

```js
// astro.config.mjs
import { defineConfig } from 'astro/config';
import { unified } from '@astrojs/markdown-remark';
import remarkToc from 'remark-toc';

export default defineConfig({
markdown: {
processor: unified({ remarkPlugins: [remarkToc] }),
},
});
```

The top-level `markdown.remarkPlugins`, `markdown.rehypePlugins`, and `markdown.remarkRehype` options are deprecated. They continue to work when `@astrojs/markdown-remark` is installed — Astro automatically wraps them in `unified({...})` and prints a deprecation warning — but you should migrate to the new factory form.

The satteri processor (and its shared plugin factories) have been extracted from `@astrojs/markdown-remark` into a new dedicated `@astrojs/markdown-satteri` package, which Astro now ships with by default — no extra install needed.

`@astrojs/markdown-remark` is no longer a transitive dependency of `astro`. If you want the legacy `unified()` pipeline (either directly via `markdown.processor: unified({...})` or indirectly via the deprecated `markdown.remarkPlugins` / `rehypePlugins` / `remarkRehype` fields), install it explicitly:

```sh
pnpm add @astrojs/markdown-remark
```

`@astrojs/mdx` now picks up the processor from `markdown.processor` automatically, so a single satteri or unified configuration drives both `.md` and `.mdx` files. To run `.mdx` files through a different processor (or the same processor with different options) than your `.md` files, pass the `processor` option to the integration:

```js
// astro.config.mjs
import { defineConfig, satteri } from 'astro/config';
import mdx from '@astrojs/mdx';
import { unified } from '@astrojs/markdown-remark';

export default defineConfig({
markdown: { processor: satteri() },
integrations: [mdx({ processor: unified({ remarkPlugins: [/* ... */] }) })],
});
```

Third-party processors can plug into the same pipeline by exporting a factory whose return value implements the `MarkdownProcessorEntry` interface (exported from `astro/markdown`). Implement `createRenderer` for `.md` support, and optionally `createMdxRenderer` to enable `.mdx` rendering.
6 changes: 6 additions & 0 deletions knip.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ export default {
entry: [srcEntry, dtsEntry, testEntry],
// package.json#imports are not resolved at the moment
ignore: ['src/import-plugin-browser.ts', 'src/shiki-engine-workerd.ts'],
ignoreDependencies: [
// Optional peer dep loaded dynamically by createSatteriMarkdownProcessor
'satteri',
// Pulled in indirectly through satteri's browser WASM shim during bundler tests
'@napi-rs/wasm-runtime',
],
},
'packages/upgrade': {
entry: ['src/index.ts', testEntry],
Expand Down
6 changes: 5 additions & 1 deletion packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
},
"exports": {
".": "./dist/index.js",
"./markdown": "./dist/markdown/index.js",
"./env": "./env.d.ts",
"./env/runtime": "./dist/env/runtime.js",
"./env/setup": "./dist/env/setup.js",
Expand Down Expand Up @@ -137,7 +138,7 @@
"dependencies": {
"@astrojs/compiler-rs": "^0.1.10",
"@astrojs/internal-helpers": "workspace:*",
"@astrojs/markdown-remark": "workspace:*",
"@astrojs/markdown-satteri": "workspace:*",
"@astrojs/telemetry": "workspace:*",
"@capsizecss/unpack": "^4.0.0",
"@clack/prompts": "^1.1.0",
Expand Down Expand Up @@ -196,6 +197,8 @@
},
"devDependencies": {
"@astrojs/check": "workspace:*",
"@astrojs/markdown-remark": "workspace:*",
"satteri": "^0.2.7",
"@playwright/test": "1.58.2",
"@types/aria-query": "^5.0.4",
"@types/hast": "^3.0.4",
Expand Down Expand Up @@ -234,6 +237,7 @@
"publishConfig": {
"exports": {
".": "./dist/index.js",
"./markdown": "./dist/markdown/index.js",
"./env": "./env.d.ts",
"./env/runtime": "./dist/env/runtime.js",
"./env/setup": "./dist/env/setup.js",
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/config/entrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export { defineConfig, getViteConfig } from './index.js';
export { sessionDrivers } from '../core/session/drivers.js';
export { svgoOptimizer } from '../assets/svg/svgo.js';
export { logHandlers } from '../core/logger/handlers.js';
export { satteri } from '@astrojs/markdown-satteri';

/**
* Return the configuration needed to use the Sharp-based image service
Expand Down
18 changes: 12 additions & 6 deletions packages/astro/src/content/content-layer.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { existsSync, promises as fs } from 'node:fs';
import {
createMarkdownProcessor,
parseFrontmatter,
type MarkdownProcessor,
} from '@astrojs/markdown-remark';
import type { MarkdownProcessor } from '@astrojs/markdown-satteri';
import { parseFrontmatter } from '../markdown/frontmatter.js';
import PQueue from 'p-queue';
import type { FSWatcher } from 'vite';
import xxhash from 'xxhash-wasm';
Expand Down Expand Up @@ -154,7 +151,16 @@ export class ContentLayer {
content: string,
options?: RenderMarkdownOptions,
): Promise<RenderedContent> {
this.#markdownProcessor ??= await createMarkdownProcessor(this.#settings.config.markdown);
if (!this.#markdownProcessor) {
const { markdown, image } = this.#settings.config;
this.#markdownProcessor = await markdown.processor.createRenderer({
image,
syntaxHighlight: markdown.syntaxHighlight,
shikiConfig: markdown.shikiConfig,
gfm: markdown.gfm,
smartypants: markdown.smartypants,
});
}
const { frontmatter, content: body } = parseFrontmatter(content);
const { code, metadata } = await this.#markdownProcessor.render(body, {
frontmatter,
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/content/data-store.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { MarkdownHeading } from '@astrojs/markdown-remark';
import type { MarkdownHeading } from '@astrojs/markdown-satteri';
import * as devalue from 'devalue';

export interface RenderedContent {
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/content/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { MarkdownHeading } from '@astrojs/markdown-remark';
import type { MarkdownHeading } from '@astrojs/markdown-satteri';
import { escape } from 'html-escaper';
import { Traverse } from 'neotraverse/modern';
import * as z from 'zod/v4';
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/content/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fsMod from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { parseFrontmatter } from '@astrojs/markdown-remark';
import { parseFrontmatter } from '../markdown/frontmatter.js';
import { slug as githubSlug } from 'github-slugger';
import colors from 'piccolore';
import type { RunnableDevEnvironment, Rolldown } from 'vite';
Expand Down
30 changes: 25 additions & 5 deletions packages/astro/src/core/config/schemas/base.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { ShikiConfig, Smartypants as _Smartypants } from '@astrojs/markdown-satteri';
import { satteri, satteriMarkdownDefaults, syntaxHighlightDefaults } from '@astrojs/markdown-satteri';
import type { MarkdownProcessorEntry } from '../../../markdown/index.js';
import type {
RehypePlugin as _RehypePlugin,
RemarkPlugin as _RemarkPlugin,
RemarkRehype as _RemarkRehype,
Smartypants as _Smartypants,
ShikiConfig,
} from '@astrojs/markdown-remark';
import { markdownConfigDefaults, syntaxHighlightDefaults } from '@astrojs/markdown-remark';
} from '../../../types/public/config.js';
import type { OutgoingHttpHeaders } from 'node:http';
import { type BuiltinTheme, bundledThemes } from 'shiki';
import * as z from 'zod/v4';
Expand Down Expand Up @@ -88,7 +88,12 @@ export const ASTRO_CONFIG_DEFAULTS = {
allowedHosts: [],
},
integrations: [],
markdown: markdownConfigDefaults,
markdown: {
...satteriMarkdownDefaults,
remarkPlugins: [] as RemarkPlugin[],
rehypePlugins: [] as RehypePlugin[],
remarkRehype: {} as RemarkRehype,
},
vite: {},
legacy: {
collectionsBackwardsCompat: false,
Expand Down Expand Up @@ -425,6 +430,21 @@ export const AstroConfigSchema = z.object({
return val;
})
.prefault(ASTRO_CONFIG_DEFAULTS.markdown.smartypants),
processor: z
.custom<MarkdownProcessorEntry>(
(value): value is MarkdownProcessorEntry =>
typeof value === 'object' &&
value !== null &&
'name' in value &&
typeof value.name === 'string' &&
'createRenderer' in value &&
typeof value.createRenderer === 'function',
{
error:
'markdown.processor must be the return value of a markdown processor factory (e.g. satteri() or unified())',
},
)
.default(() => satteri()),
})
.prefault({}),
vite: z
Expand Down
42 changes: 42 additions & 0 deletions packages/astro/src/core/config/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export async function validateConfig(
): Promise<AstroConfig> {
const AstroConfigRelativeSchema = createRelativeSchema(cmd, root);

await coerceLegacyMarkdownPlugins(userConfig);

// First-Pass Validation
return await validateConfigRefined(
await AstroConfigRelativeSchema.parseAsync(userConfig, {
Expand All @@ -26,6 +28,46 @@ export async function validateConfig(
);
}

/**
* Wraps legacy `markdown.{remark,rehype}Plugins` / `remarkRehype` into a
* `unified({...})` processor (with a warning). Mutates `userConfig` in place.
*/
async function coerceLegacyMarkdownPlugins(userConfig: any): Promise<void> {
const md = userConfig?.markdown;
if (!md || typeof md !== 'object' || Array.isArray(md)) return;
if (md.processor) return;

const hasRemarkPlugins = Array.isArray(md.remarkPlugins) && md.remarkPlugins.length > 0;
const hasRehypePlugins = Array.isArray(md.rehypePlugins) && md.rehypePlugins.length > 0;
const hasRemarkRehype =
md.remarkRehype &&
typeof md.remarkRehype === 'object' &&
!Array.isArray(md.remarkRehype) &&
Object.keys(md.remarkRehype).length > 0;
if (!hasRemarkPlugins && !hasRehypePlugins && !hasRemarkRehype) return;

let unified: typeof import('@astrojs/markdown-remark').unified;
try {
({ unified } = await import('@astrojs/markdown-remark'));
} catch {
throw new Error(
'`markdown.remarkPlugins`, `markdown.rehypePlugins`, and `markdown.remarkRehype` require `@astrojs/markdown-remark`. Install it with:\n pnpm add @astrojs/markdown-remark\n\nThen migrate to the new processor API:\n import { unified } from \'@astrojs/markdown-remark\';\n markdown: { processor: unified({ remarkPlugins: [...] }) }',
);
}

console.warn(
'[astro] `markdown.remarkPlugins`, `markdown.rehypePlugins`, and `markdown.remarkRehype` are deprecated. Use `markdown.processor: unified({...})` from `@astrojs/markdown-remark` instead.',
);
md.processor = unified({
remarkPlugins: md.remarkPlugins ?? [],
rehypePlugins: md.rehypePlugins ?? [],
remarkRehype: md.remarkRehype ?? {},
});
delete md.remarkPlugins;
delete md.rehypePlugins;
delete md.remarkRehype;
}

/**
* Used twice:
* - To validate the user config
Expand Down
40 changes: 34 additions & 6 deletions packages/astro/src/jsx/rehype.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { RehypePlugin } from '@astrojs/markdown-remark';
import type { RootContent } from 'hast';
import type { RootContent, Root as HastRoot } from 'hast';
import type { Plugin } from 'unified';
import type {} from 'mdast-util-mdx';
import type {
MdxJsxAttribute,
Expand All @@ -16,16 +16,21 @@ import type { PluginMetadata } from '../vite-plugin-astro/types.js';

const ClientOnlyPlaceholder = 'astro-client-only';

export const rehypeAnalyzeAstroMetadata: RehypePlugin = () => {
export const rehypeAnalyzeAstroMetadata: Plugin<[], HastRoot> = () => {
return (tree, file) => {
// Initial metadata for this MDX file, it will be mutated as we traverse the tree
const metadata = createDefaultAstroMetadata();

// Parse imports in this file. This is used to match components with their import source
const imports = parseImports(tree.children);

visit(tree, (node) => {
if (node.type !== 'mdxJsxFlowElement' && node.type !== 'mdxJsxTextElement') return;
visit(tree, (visitedNode) => {
if (visitedNode.type !== 'mdxJsxFlowElement' && visitedNode.type !== 'mdxJsxTextElement')
return;

// `visit` returns nodes typed against widened hast augmentations (e.g. from satteri).
// Narrow back to the mdast-util-mdx-jsx types our helpers expect.
const node = visitedNode as unknown as MdxJsxFlowElementHast | MdxJsxTextElementHast;

const tagName = node.name;
if (
Expand Down Expand Up @@ -106,6 +111,27 @@ export function getAstroMetadata(file: VFile) {

type ImportSpecifier = { local: string; imported: string };

// Minimal ESTree shapes we need — avoids a direct dependency on @types/estree.
// `@types/estree` is present transitively but not in our direct deps, and satteri's
// hast augmentations widen `mdxjsEsm.data.estree` to `{}`, erasing its Program typing.
type EstreeId = { type: 'Identifier'; name: string };
type EstreeLiteral = { type: 'Literal'; value: unknown };
type EstreeImportSpecifier =
| { type: 'ImportDefaultSpecifier'; local: EstreeId }
| { type: 'ImportNamespaceSpecifier'; local: EstreeId }
| {
type: 'ImportSpecifier';
local: EstreeId;
imported: EstreeId | EstreeLiteral;
};
type EstreeImportDeclaration = {
type: 'ImportDeclaration';
source: { value: string };
specifiers: EstreeImportSpecifier[];
};
type EstreeStatement = EstreeImportDeclaration | { type: 'OtherStatement' };
type EstreeProgram = { body: EstreeStatement[] };

/**
* ```
* import Foo from './Foo.jsx'
Expand All @@ -130,7 +156,9 @@ function parseImports(children: RootContent[]) {
for (const child of children) {
if (child.type !== 'mdxjsEsm') continue;

const body = child.data?.estree?.body;
// `child.data?.estree` is widened by the hast augmentations; narrow it back to the shape
// we care about (an ESTree Program with ImportDeclaration bodies).
const body = (child.data?.estree as EstreeProgram | undefined)?.body;
if (!body) continue;

for (const ast of body) {
Expand Down
Loading
Loading