Skip to content

Commit 9b81609

Browse files
committed
feat(mdx): Rust MDX via satteri
1 parent 7c6c96d commit 9b81609

19 files changed

Lines changed: 1226 additions & 79 deletions

File tree

.changeset/seven-emus-knock.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'astro': patch
3+
'@astrojs/markdown-remark': patch
4+
'@astrojs/mdx': patch
5+
---
6+
7+
temp changeset for preview release

knip.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ export default {
8383
entry: [srcEntry, dtsEntry, testEntry],
8484
// package.json#imports are not resolved at the moment
8585
ignore: ['src/import-plugin-browser.ts', 'src/shiki-engine-workerd.ts'],
86+
ignoreDependencies: [
87+
// Optional peer dep loaded dynamically by createSatteriMarkdownProcessor
88+
'satteri',
89+
// Pulled in indirectly through satteri's browser WASM shim during bundler tests
90+
'@napi-rs/wasm-runtime',
91+
],
8692
},
8793
'packages/upgrade': {
8894
entry: ['src/index.ts', testEntry],

packages/astro/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@
196196
},
197197
"devDependencies": {
198198
"@astrojs/check": "workspace:*",
199+
"satteri": "^0.2.7",
199200
"@playwright/test": "1.58.2",
200201
"@types/aria-query": "^5.0.4",
201202
"@types/hast": "^3.0.4",

packages/astro/src/core/config/schemas/base.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export const ASTRO_CONFIG_DEFAULTS = {
111111
clientPrerender: false,
112112
contentIntellisense: false,
113113
chromeDevtoolsWorkspace: false,
114+
nativeMarkdown: false,
114115
queuedRendering: {
115116
enabled: false,
116117
},
@@ -550,6 +551,23 @@ export const AstroConfigSchema = z.object({
550551
svgOptimizer: SvgOptimizerSchema.optional(),
551552
cache: CacheSchema.optional(),
552553
routeRules: RouteRulesSchema.optional(),
554+
nativeMarkdown: z
555+
.union([
556+
z.boolean(),
557+
z.object({
558+
mdastPlugins: z
559+
.array(z.custom<import('satteri').MdastPluginDefinition>())
560+
.optional()
561+
.default([]),
562+
hastPlugins: z
563+
.array(z.custom<import('satteri').HastPluginDefinition>())
564+
.optional()
565+
.default([]),
566+
features: z.custom<import('satteri').Features>().optional(),
567+
}),
568+
])
569+
.optional()
570+
.default(ASTRO_CONFIG_DEFAULTS.experimental.nativeMarkdown),
553571
queuedRendering: z
554572
.object({
555573
enabled: z.boolean().optional().prefault(false),

packages/astro/src/jsx/rehype.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,13 @@ export const rehypeAnalyzeAstroMetadata: RehypePlugin = () => {
2424
// Parse imports in this file. This is used to match components with their import source
2525
const imports = parseImports(tree.children);
2626

27-
visit(tree, (node) => {
28-
if (node.type !== 'mdxJsxFlowElement' && node.type !== 'mdxJsxTextElement') return;
27+
visit(tree, (visitedNode) => {
28+
if (visitedNode.type !== 'mdxJsxFlowElement' && visitedNode.type !== 'mdxJsxTextElement')
29+
return;
30+
31+
// `visit` returns nodes typed against widened hast augmentations (e.g. from satteri).
32+
// Narrow back to the mdast-util-mdx-jsx types our helpers expect.
33+
const node = visitedNode as unknown as MdxJsxFlowElementHast | MdxJsxTextElementHast;
2934

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

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

114+
// Minimal ESTree shapes we need — avoids a direct dependency on @types/estree.
115+
// `@types/estree` is present transitively but not in our direct deps, and satteri's
116+
// hast augmentations widen `mdxjsEsm.data.estree` to `{}`, erasing its Program typing.
117+
type EstreeId = { type: 'Identifier'; name: string };
118+
type EstreeLiteral = { type: 'Literal'; value: unknown };
119+
type EstreeImportSpecifier =
120+
| { type: 'ImportDefaultSpecifier'; local: EstreeId }
121+
| { type: 'ImportNamespaceSpecifier'; local: EstreeId }
122+
| {
123+
type: 'ImportSpecifier';
124+
local: EstreeId;
125+
imported: EstreeId | EstreeLiteral;
126+
};
127+
type EstreeImportDeclaration = {
128+
type: 'ImportDeclaration';
129+
source: { value: string };
130+
specifiers: EstreeImportSpecifier[];
131+
};
132+
type EstreeStatement = EstreeImportDeclaration | { type: 'OtherStatement' };
133+
type EstreeProgram = { body: EstreeStatement[] };
134+
109135
/**
110136
* ```
111137
* import Foo from './Foo.jsx'
@@ -130,7 +156,9 @@ function parseImports(children: RootContent[]) {
130156
for (const child of children) {
131157
if (child.type !== 'mdxjsEsm') continue;
132158

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

136164
for (const ast of body) {

packages/astro/src/types/public/config.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2997,6 +2997,56 @@ export interface AstroUserConfig<
29972997
*/
29982998
routeRules?: RouteRules;
29992999

3000+
/**
3001+
* @name experimental.nativeMarkdown
3002+
* @type {boolean | { mdastPlugins?: import('satteri').MdastPluginDefinition[]; hastPlugins?: import('satteri').HastPluginDefinition[]; features?: import('satteri').Features }}
3003+
* @default `false`
3004+
* @version 6.0.0
3005+
* @description
3006+
*
3007+
* Enables the experimental native Markdown compiler (satteri) as a replacement for the default remark/rehype pipeline.
3008+
* When enabled, both Markdown and MDX files are processed using satteri, a Rust-based compiler that offers improved performance.
3009+
*
3010+
* This option requires installing the `satteri` package manually in your project.
3011+
* When enabled, `remarkPlugins` and `rehypePlugins` from the markdown config are ignored.
3012+
* Use `mdastPlugins` and `hastPlugins` (satteri plugin types) instead.
3013+
*
3014+
* ```js
3015+
* // astro.config.mjs
3016+
* import { defineConfig } from 'astro/config';
3017+
*
3018+
* export default defineConfig({
3019+
* experimental: {
3020+
* nativeMarkdown: true,
3021+
* },
3022+
* });
3023+
* ```
3024+
*
3025+
* You can also pass satteri plugins and enable additional parser features:
3026+
*
3027+
* ```js
3028+
* // astro.config.mjs
3029+
* import { defineConfig } from 'astro/config';
3030+
*
3031+
* export default defineConfig({
3032+
* experimental: {
3033+
* nativeMarkdown: {
3034+
* hastPlugins: [myPlugin],
3035+
* features: {
3036+
* directive: true,
3037+
* definitionList: true,
3038+
* },
3039+
* },
3040+
* },
3041+
* });
3042+
* ```
3043+
*/
3044+
nativeMarkdown?: boolean | {
3045+
mdastPlugins?: import('satteri').MdastPluginDefinition[];
3046+
hastPlugins?: import('satteri').HastPluginDefinition[];
3047+
features?: import('satteri').Features;
3048+
};
3049+
30003050
/**
30013051
* @name experimental.queuedRendering
30023052
* @type {boolean | { poolSize?: number; cache?: boolean }}

packages/astro/src/vite-plugin-markdown/content-entry-type.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { fileURLToPath, pathToFileURL } from 'node:url';
2-
import { createMarkdownProcessor } from '@astrojs/markdown-remark';
2+
import { createMarkdownProcessor, createSatteriMarkdownProcessor } from '@astrojs/markdown-remark';
33
import { safeParseFrontmatter } from '../content/utils.js';
44
import type { ContentEntryType } from '../types/public/content.js';
55

@@ -18,10 +18,23 @@ export const markdownContentEntryType: ContentEntryType = {
1818
handlePropagation: true,
1919

2020
async getRenderFunction(config) {
21-
const processor = await createMarkdownProcessor({
22-
image: config.image,
23-
...config.markdown,
24-
});
21+
const nativeMd = config.experimental.nativeMarkdown;
22+
let processor;
23+
if (nativeMd) {
24+
const nativeOpts = typeof nativeMd === 'object' ? nativeMd : undefined;
25+
processor = await createSatteriMarkdownProcessor({
26+
image: config.image,
27+
...config.markdown,
28+
mdastPlugins: nativeOpts?.mdastPlugins,
29+
hastPlugins: nativeOpts?.hastPlugins,
30+
features: nativeOpts?.features,
31+
});
32+
} else {
33+
processor = await createMarkdownProcessor({
34+
image: config.image,
35+
...config.markdown,
36+
});
37+
}
2538
return async function renderToString(entry) {
2639
// Process markdown even if it's empty as remark/rehype plugins may add content or frontmatter dynamically
2740
const result = await processor.render(entry.body ?? '', {

packages/astro/src/vite-plugin-markdown/index.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from 'node:fs';
22
import { fileURLToPath, pathToFileURL } from 'node:url';
33
import {
44
createMarkdownProcessor,
5+
createSatteriMarkdownProcessor,
56
isFrontmatterValid,
67
type MarkdownProcessor,
78
} from '@astrojs/markdown-remark';
@@ -81,10 +82,22 @@ export default function markdown({ settings, logger }: AstroPluginOptions): Plug
8182

8283
// Lazily initialize the Markdown processor
8384
if (!processor) {
84-
processor = createMarkdownProcessor({
85-
image: settings.config.image,
86-
...settings.config.markdown,
87-
});
85+
const nativeMd = settings.config.experimental.nativeMarkdown;
86+
if (nativeMd) {
87+
const nativeOpts = typeof nativeMd === 'object' ? nativeMd : undefined;
88+
processor = createSatteriMarkdownProcessor({
89+
image: settings.config.image,
90+
...settings.config.markdown,
91+
mdastPlugins: nativeOpts?.mdastPlugins,
92+
hastPlugins: nativeOpts?.hastPlugins,
93+
features: nativeOpts?.features,
94+
});
95+
} else {
96+
processor = createMarkdownProcessor({
97+
image: settings.config.image,
98+
...settings.config.markdown,
99+
});
100+
}
88101
}
89102

90103
const renderResult = await (await processor).render(raw.content, {

packages/integrations/mdx/package.json

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,13 @@
3939
"acorn": "^8.16.0",
4040
"es-module-lexer": "^2.0.0",
4141
"estree-util-visit": "^2.0.0",
42+
"github-slugger": "^2.0.0",
4243
"hast-util-to-html": "^9.0.5",
4344
"piccolore": "^0.1.3",
4445
"rehype-raw": "^7.0.0",
4546
"remark-gfm": "^4.0.1",
4647
"remark-smartypants": "^3.0.2",
48+
"satteri": "^0.2.7",
4749
"source-map": "^0.7.6",
4850
"unist-util-visit": "^5.1.0",
4951
"vfile": "^6.0.3"
@@ -52,8 +54,8 @@
5254
"astro": "^7.0.0-alpha.0"
5355
},
5456
"devDependencies": {
55-
"@shikijs/rehype": "^4.0.2",
56-
"@shikijs/twoslash": "^4.0.2",
57+
"@shikijs/rehype": "^4.0.0",
58+
"@shikijs/twoslash": "^4.0.0",
5759
"@types/estree": "^1.0.8",
5860
"@types/hast": "^3.0.4",
5961
"@types/mdast": "^4.0.4",
@@ -74,8 +76,5 @@
7476
},
7577
"engines": {
7678
"node": ">=22.12.0"
77-
},
78-
"publishConfig": {
79-
"provenance": true
8079
}
8180
}

packages/integrations/mdx/src/index.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type {
1010
} from 'astro';
1111
import type { Options as RemarkRehypeOptions } from 'remark-rehype';
1212
import type { PluggableList } from 'unified';
13-
import type { OptimizeOptions } from './rehype-optimize-static.js';
13+
import type { MdastPluginDefinition, HastPluginDefinition } from './satteri-plugins.js';
1414
import { ignoreStringPlugins, safeParseFrontmatter } from './utils.js';
1515
import { type VitePluginMdxOptions, vitePluginMdx } from './vite-plugin-mdx.js';
1616
import { vitePluginMdxPostprocess } from './vite-plugin-mdx-postprocess.js';
@@ -23,7 +23,10 @@ export type MdxOptions = Omit<typeof markdownConfigDefaults, 'remarkPlugins' | '
2323
remarkPlugins: PluggableList;
2424
rehypePlugins: PluggableList;
2525
remarkRehype: RemarkRehypeOptions;
26-
optimize: boolean | OptimizeOptions;
26+
optimize: boolean | { ignoreElementNames?: string[] };
27+
mdastPlugins: MdastPluginDefinition[];
28+
hastPlugins: HastPluginDefinition[];
29+
features?: import('satteri').Features;
2730
};
2831

2932
type SetupHookParams = HookParameters<'astro:config:setup'> & {
@@ -97,10 +100,32 @@ export default function mdx(partialMdxOptions: Partial<MdxOptions> = {}): AstroI
97100
),
98101
});
99102

103+
const nativeMd = config.experimental.nativeMarkdown;
104+
105+
// When nativeMarkdown is enabled, merge mdastPlugins/hastPlugins/features from the experimental config
106+
if (nativeMd && extendMarkdownConfig) {
107+
const nativeOpts = typeof nativeMd === 'object' ? nativeMd : undefined;
108+
resolvedMdxOptions.mdastPlugins = [
109+
...(nativeOpts?.mdastPlugins ?? []),
110+
...resolvedMdxOptions.mdastPlugins,
111+
];
112+
resolvedMdxOptions.hastPlugins = [
113+
...(nativeOpts?.hastPlugins ?? []),
114+
...resolvedMdxOptions.hastPlugins,
115+
];
116+
if (nativeOpts?.features || resolvedMdxOptions.features) {
117+
resolvedMdxOptions.features = {
118+
...nativeOpts?.features,
119+
...resolvedMdxOptions.features,
120+
};
121+
}
122+
}
123+
100124
// Mutate `mdxOptions` so that `vitePluginMdx` can reference the actual options
101125
Object.assign(vitePluginMdxOptions, {
102126
mdxOptions: resolvedMdxOptions,
103127
srcDir: config.srcDir,
128+
nativeMarkdown: nativeMd,
104129
});
105130
// @ts-expect-error After we assign, we don't need to reference `mdxOptions` in this context anymore.
106131
// Re-assign it so that the garbage can be collected later.
@@ -114,6 +139,8 @@ const defaultMdxOptions = {
114139
extendMarkdownConfig: true,
115140
recmaPlugins: [],
116141
optimize: false,
142+
mdastPlugins: [],
143+
hastPlugins: [],
117144
} satisfies Partial<MdxOptions>;
118145

119146
function markdownConfigToMdxOptions(
@@ -147,5 +174,8 @@ function applyDefaultOptions({
147174
rehypePlugins: options.rehypePlugins ?? defaults.rehypePlugins,
148175
shikiConfig: options.shikiConfig ?? defaults.shikiConfig,
149176
optimize: options.optimize ?? defaults.optimize,
177+
mdastPlugins: options.mdastPlugins ?? defaults.mdastPlugins,
178+
hastPlugins: options.hastPlugins ?? defaults.hastPlugins,
179+
features: options.features ?? defaults.features,
150180
};
151181
}

0 commit comments

Comments
 (0)