Skip to content

Commit ea16570

Browse files
bluwysarah11918
andauthored
Add MDX optimize option (#7151)
Co-authored-by: Sarah Rainsberger <[email protected]>
1 parent 20a9792 commit ea16570

File tree

15 files changed

+304
-0
lines changed

15 files changed

+304
-0
lines changed

.changeset/dirty-singers-enjoy.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@astrojs/mdx': patch
3+
---
4+
5+
Add `optimize` option for faster builds and rendering

packages/integrations/mdx/README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ You can configure how your MDX is rendered with the following options:
8383
- [Options inherited from Markdown config](#options-inherited-from-markdown-config)
8484
- [`extendMarkdownConfig`](#extendmarkdownconfig)
8585
- [`recmaPlugins`](#recmaplugins)
86+
- [`optimize`](#optimize)
8687

8788
### Options inherited from Markdown config
8889

@@ -183,6 +184,71 @@ These are plugins that modify the output [estree](https://github.com/estree/estr
183184

184185
We suggest [using AST Explorer](https://astexplorer.net/) to play with estree outputs, and trying [`estree-util-visit`](https://unifiedjs.com/explore/package/estree-util-visit/) for searching across JavaScript nodes.
185186

187+
### `optimize`
188+
189+
- **Type:** `boolean | { customComponentNames?: string[] }`
190+
191+
This is an optional configuration setting to optimize the MDX output for faster builds and rendering via an internal rehype plugin. This may be useful if you have many MDX files and notice slow builds. However, this option may generate some unescaped HTML, so make sure your site's interactive parts still work correctly after enabling it.
192+
193+
This is disabled by default. To enable MDX optimization, add the following to your MDX integration configuration:
194+
195+
__`astro.config.mjs`__
196+
197+
```js
198+
import { defineConfig } from 'astro/config';
199+
import mdx from '@astrojs/mdx';
200+
201+
export default defineConfig({
202+
integrations: [
203+
mdx({
204+
optimize: true,
205+
})
206+
]
207+
});
208+
```
209+
210+
#### `customComponentNames`
211+
212+
- **Type:** `string[]`
213+
214+
An optional property of `optimize` to prevent the MDX optimizer from handling any [custom components passed to imported MDX content via the components prop](https://docs.astro.build/en/guides/markdown-content/#custom-components-with-imported-mdx).
215+
216+
You will need to exclude these components from optimization as the optimizer eagerly converts content into a static string, which will break custom components that needs to be dynamically rendered.
217+
218+
For example, the intended MDX output of the following is `<Heading>...</Heading>` in place of every `"<h1>...</h1>"`:
219+
220+
```astro
221+
---
222+
import { Content, components } from '../content.mdx';
223+
import Heading from '../Heading.astro';
224+
---
225+
226+
<Content components={{...components, h1: Heading }} />
227+
```
228+
229+
To configure optimization for this using the `customComponentNames` property, specify an array of HTML element names that should be treated as custom components:
230+
231+
__`astro.config.mjs`__
232+
233+
```js
234+
import { defineConfig } from 'astro/config';
235+
import mdx from '@astrojs/mdx';
236+
237+
export default defineConfig({
238+
integrations: [
239+
mdx({
240+
optimize: {
241+
// Prevent the optimizer from handling `h1` elements
242+
// These will be treated as custom components
243+
customComponentNames: ['h1'],
244+
},
245+
})
246+
]
247+
});
248+
```
249+
250+
Note that if your MDX file [configures custom components using `export const components = { ... }`](https://docs.astro.build/en/guides/markdown-content/#assigning-custom-components-to-html-elements), then you do not need to manually configure this option. The optimizer will automatically detect them.
251+
186252
## Examples
187253

188254
* The [Astro MDX starter template](https://github.com/withastro/astro/tree/latest/examples/with-mdx) shows how to use MDX files in your Astro project.

packages/integrations/mdx/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"estree-util-visit": "^1.2.0",
4343
"github-slugger": "^1.4.0",
4444
"gray-matter": "^4.0.3",
45+
"hast-util-to-html": "^8.0.4",
4546
"kleur": "^4.1.4",
4647
"rehype-raw": "^6.1.1",
4748
"remark-frontmatter": "^4.0.1",

packages/integrations/mdx/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { SourceMapGenerator } from 'source-map';
1111
import { VFile } from 'vfile';
1212
import type { Plugin as VitePlugin } from 'vite';
1313
import { getRehypePlugins, getRemarkPlugins, recmaInjectImportMetaEnvPlugin } from './plugins.js';
14+
import type { OptimizeOptions } from './rehype-optimize-static.js';
1415
import { getFileInfo, ignoreStringPlugins, parseFrontmatter } from './utils.js';
1516

1617
export type MdxOptions = Omit<typeof markdownConfigDefaults, 'remarkPlugins' | 'rehypePlugins'> & {
@@ -21,6 +22,7 @@ export type MdxOptions = Omit<typeof markdownConfigDefaults, 'remarkPlugins' | '
2122
remarkPlugins: PluggableList;
2223
rehypePlugins: PluggableList;
2324
remarkRehype: RemarkRehypeOptions;
25+
optimize: boolean | OptimizeOptions;
2426
};
2527

2628
type SetupHookParams = HookParameters<'astro:config:setup'> & {
@@ -194,6 +196,7 @@ function markdownConfigToMdxOptions(markdownConfig: typeof markdownConfigDefault
194196
remarkPlugins: ignoreStringPlugins(markdownConfig.remarkPlugins),
195197
rehypePlugins: ignoreStringPlugins(markdownConfig.rehypePlugins),
196198
remarkRehype: (markdownConfig.remarkRehype as any) ?? {},
199+
optimize: false,
197200
};
198201
}
199202

@@ -214,6 +217,7 @@ function applyDefaultOptions({
214217
remarkPlugins: options.remarkPlugins ?? defaults.remarkPlugins,
215218
rehypePlugins: options.rehypePlugins ?? defaults.rehypePlugins,
216219
shikiConfig: options.shikiConfig ?? defaults.shikiConfig,
220+
optimize: options.optimize ?? defaults.optimize,
217221
};
218222
}
219223

packages/integrations/mdx/src/plugins.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { VFile } from 'vfile';
1515
import type { MdxOptions } from './index.js';
1616
import { rehypeInjectHeadingsExport } from './rehype-collect-headings.js';
1717
import rehypeMetaString from './rehype-meta-string.js';
18+
import { rehypeOptimizeStatic } from './rehype-optimize-static.js';
1819
import { remarkImageToComponent } from './remark-images-to-component.js';
1920
import remarkPrism from './remark-prism.js';
2021
import remarkShiki from './remark-shiki.js';
@@ -144,6 +145,13 @@ export function getRehypePlugins(mdxOptions: MdxOptions): PluggableList {
144145
// computed from `astro.data.frontmatter` in VFile data
145146
rehypeApplyFrontmatterExport,
146147
];
148+
149+
if (mdxOptions.optimize) {
150+
// Convert user `optimize` option to compatible `rehypeOptimizeStatic` option
151+
const options = mdxOptions.optimize === true ? undefined : mdxOptions.optimize;
152+
rehypePlugins.push([rehypeOptimizeStatic, options]);
153+
}
154+
147155
return rehypePlugins;
148156
}
149157

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { visit } from 'estree-util-visit';
2+
import { toHtml } from 'hast-util-to-html';
3+
4+
// accessing untyped hast and mdx types
5+
type Node = any;
6+
7+
export interface OptimizeOptions {
8+
customComponentNames?: string[];
9+
}
10+
11+
const exportConstComponentsRe = /export\s+const\s+components\s*=/;
12+
13+
/**
14+
* For MDX only, collapse static subtrees of the hast into `set:html`. Subtrees
15+
* do not include any MDX elements.
16+
*
17+
* This optimization reduces the JS output as more content are represented as a
18+
* string instead, which also reduces the AST size that Rollup holds in memory.
19+
*/
20+
export function rehypeOptimizeStatic(options?: OptimizeOptions) {
21+
return (tree: any) => {
22+
// A set of non-static components to avoid collapsing when walking the tree
23+
// as they need to be preserved as JSX to be rendered dynamically.
24+
const customComponentNames = new Set<string>(options?.customComponentNames);
25+
26+
// Find `export const components = { ... }` and get it's object's keys to be
27+
// populated into `customComponentNames`. This configuration is used to render
28+
// some HTML elements as custom components, and we also want to avoid collapsing them.
29+
for (const child of tree.children) {
30+
if (child.type === 'mdxjsEsm' && exportConstComponentsRe.test(child.value)) {
31+
// Try to loosely get the object property nodes
32+
const objectPropertyNodes = child.data.estree.body[0]?.declarations?.[0]?.init?.properties;
33+
if (objectPropertyNodes) {
34+
for (const objectPropertyNode of objectPropertyNodes) {
35+
const componentName = objectPropertyNode.key?.name ?? objectPropertyNode.key?.value;
36+
if (componentName) {
37+
customComponentNames.add(componentName);
38+
}
39+
}
40+
}
41+
}
42+
}
43+
44+
// All possible elements that could be the root of a subtree
45+
const allPossibleElements = new Set<Node>();
46+
// The current collapsible element stack while traversing the tree
47+
const elementStack: Node[] = [];
48+
49+
visit(tree, {
50+
enter(node) {
51+
// @ts-expect-error read tagName naively
52+
const isCustomComponent = node.tagName && customComponentNames.has(node.tagName);
53+
// For nodes that can't be optimized, eliminate all elements in the
54+
// `elementStack` from the `allPossibleElements` set.
55+
if (node.type.startsWith('mdx') || isCustomComponent) {
56+
for (const el of elementStack) {
57+
allPossibleElements.delete(el);
58+
}
59+
// Micro-optimization: While this destroys the meaning of an element
60+
// stack for this node, things will still work but we won't repeatedly
61+
// run the above for other nodes anymore. If this is confusing, you can
62+
// comment out the code below when reading.
63+
elementStack.length = 0;
64+
}
65+
// For possible subtree root nodes, record them in `elementStack` and
66+
// `allPossibleElements` to be used in the "leave" hook below.
67+
if (node.type === 'element' || node.type === 'mdxJsxFlowElement') {
68+
elementStack.push(node);
69+
allPossibleElements.add(node);
70+
}
71+
},
72+
leave(node, _, __, parents) {
73+
// Do the reverse of the if condition above, popping the `elementStack`,
74+
// and consolidating `allPossibleElements` as a subtree root.
75+
if (node.type === 'element' || node.type === 'mdxJsxFlowElement') {
76+
elementStack.pop();
77+
// Many possible elements could be part of a subtree, in order to find
78+
// the root, we check the parent of the element we're popping. If the
79+
// parent exists in `allPossibleElements`, then we're definitely not
80+
// the root, so remove ourselves. This will work retroactively as we
81+
// climb back up the tree.
82+
const parent = parents[parents.length - 1];
83+
if (allPossibleElements.has(parent)) {
84+
allPossibleElements.delete(node);
85+
}
86+
}
87+
},
88+
});
89+
90+
// For all possible subtree roots, collapse them into `set:html` and
91+
// strip of their children
92+
for (const el of allPossibleElements) {
93+
if (el.type === 'mdxJsxFlowElement') {
94+
el.attributes.push({
95+
type: 'mdxJsxAttribute',
96+
name: 'set:html',
97+
value: toHtml(el.children),
98+
});
99+
} else {
100+
el.properties['set:html'] = toHtml(el.children);
101+
}
102+
el.children = [];
103+
}
104+
};
105+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import mdx from '@astrojs/mdx';
2+
3+
export default {
4+
integrations: [mdx({
5+
optimize: {
6+
customComponentNames: ['strong']
7+
}
8+
})]
9+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "@test/mdx-optimize",
3+
"private": true,
4+
"dependencies": {
5+
"@astrojs/mdx": "workspace:*",
6+
"astro": "workspace:*"
7+
}
8+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<blockquote {...Astro.props} class="custom-blockquote">
2+
<slot />
3+
</blockquote>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<strong {...Astro.props} class="custom-strong">
2+
<slot />
3+
</strong>

0 commit comments

Comments
 (0)