Skip to content

Commit 74dc3ed

Browse files
authored
Improve MDX rendering performance (#8533)
1 parent 2e8726f commit 74dc3ed

10 files changed

+175
-156
lines changed

.changeset/thin-starfishes-love.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@astrojs/mdx': patch
3+
---
4+
5+
Improve MDX rendering performance by sharing processor instance

packages/integrations/mdx/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
"remark-rehype": "^10.1.0",
7575
"remark-shiki-twoslash": "^3.1.3",
7676
"remark-toc": "^8.0.1",
77+
"unified": "^10.1.2",
7778
"vite": "^4.4.9"
7879
},
7980
"engines": {

packages/integrations/mdx/src/index.ts

+13-37
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,14 @@
1-
import { markdownConfigDefaults } from '@astrojs/markdown-remark';
2-
import { toRemarkInitializeAstroData } from '@astrojs/markdown-remark/dist/internal.js';
3-
import { compile as mdxCompile, type CompileOptions } from '@mdx-js/mdx';
1+
import { markdownConfigDefaults, setVfileFrontmatter } from '@astrojs/markdown-remark';
42
import type { PluggableList } from '@mdx-js/mdx/lib/core.js';
53
import type { AstroIntegration, ContentEntryType, HookParameters, SSRError } from 'astro';
64
import astroJSXRenderer from 'astro/jsx/renderer.js';
75
import { parse as parseESM } from 'es-module-lexer';
86
import fs from 'node:fs/promises';
97
import { fileURLToPath } from 'node:url';
108
import type { Options as RemarkRehypeOptions } from 'remark-rehype';
11-
import { SourceMapGenerator } from 'source-map';
129
import { VFile } from 'vfile';
1310
import type { Plugin as VitePlugin } from 'vite';
14-
import { getRehypePlugins, getRemarkPlugins, recmaInjectImportMetaEnvPlugin } from './plugins.js';
11+
import { createMdxProcessor } from './plugins.js';
1512
import type { OptimizeOptions } from './rehype-optimize-static.js';
1613
import {
1714
ASTRO_IMAGE_ELEMENT,
@@ -84,21 +81,7 @@ export default function mdx(partialMdxOptions: Partial<MdxOptions> = {}): AstroI
8481
),
8582
});
8683

87-
const mdxPluginOpts: CompileOptions = {
88-
remarkPlugins: await getRemarkPlugins(mdxOptions),
89-
rehypePlugins: getRehypePlugins(mdxOptions),
90-
recmaPlugins: mdxOptions.recmaPlugins,
91-
remarkRehypeOptions: mdxOptions.remarkRehype,
92-
jsx: true,
93-
jsxImportSource: 'astro',
94-
// Note: disable `.md` (and other alternative extensions for markdown files like `.markdown`) support
95-
format: 'mdx',
96-
mdExtensions: [],
97-
};
98-
99-
let importMetaEnv: Record<string, any> = {
100-
SITE: config.site,
101-
};
84+
let processor: ReturnType<typeof createMdxProcessor>;
10285

10386
updateConfig({
10487
vite: {
@@ -107,7 +90,10 @@ export default function mdx(partialMdxOptions: Partial<MdxOptions> = {}): AstroI
10790
name: '@mdx-js/rollup',
10891
enforce: 'pre',
10992
configResolved(resolved) {
110-
importMetaEnv = { ...importMetaEnv, ...resolved.env };
93+
processor = createMdxProcessor(mdxOptions, {
94+
sourcemap: !!resolved.build.sourcemap,
95+
importMetaEnv: { SITE: config.site, ...resolved.env },
96+
});
11197

11298
// HACK: move ourselves before Astro's JSX plugin to transform things in the right order
11399
const jsxPluginIndex = resolved.plugins.findIndex((p) => p.name === 'astro:jsx');
@@ -134,23 +120,13 @@ export default function mdx(partialMdxOptions: Partial<MdxOptions> = {}): AstroI
134120
const code = await fs.readFile(fileId, 'utf-8');
135121

136122
const { data: frontmatter, content: pageContent } = parseFrontmatter(code, id);
123+
124+
const vfile = new VFile({ value: pageContent, path: id });
125+
// Ensure `data.astro` is available to all remark plugins
126+
setVfileFrontmatter(vfile, frontmatter);
127+
137128
try {
138-
const compiled = await mdxCompile(new VFile({ value: pageContent, path: id }), {
139-
...mdxPluginOpts,
140-
elementAttributeNameCase: 'html',
141-
remarkPlugins: [
142-
// Ensure `data.astro` is available to all remark plugins
143-
toRemarkInitializeAstroData({ userFrontmatter: frontmatter }),
144-
...(mdxPluginOpts.remarkPlugins ?? []),
145-
],
146-
recmaPlugins: [
147-
...(mdxPluginOpts.recmaPlugins ?? []),
148-
() => recmaInjectImportMetaEnvPlugin({ importMetaEnv }),
149-
],
150-
SourceMapGenerator: config.vite.build?.sourcemap
151-
? SourceMapGenerator
152-
: undefined,
153-
});
129+
const compiled = await processor.process(vfile);
154130

155131
return {
156132
code: escapeViteEnvReferences(String(compiled.value)),

packages/integrations/mdx/src/plugins.ts

+31-112
Original file line numberDiff line numberDiff line change
@@ -4,101 +4,49 @@ import {
44
remarkPrism,
55
remarkShiki,
66
} from '@astrojs/markdown-remark';
7-
import {
8-
InvalidAstroDataError,
9-
safelyGetAstroData,
10-
} from '@astrojs/markdown-remark/dist/internal.js';
11-
import { nodeTypes } from '@mdx-js/mdx';
7+
import { createProcessor, nodeTypes } from '@mdx-js/mdx';
128
import type { PluggableList } from '@mdx-js/mdx/lib/core.js';
13-
import type { Literal, MemberExpression } from 'estree';
14-
import { visit as estreeVisit } from 'estree-util-visit';
159
import rehypeRaw from 'rehype-raw';
1610
import remarkGfm from 'remark-gfm';
1711
import remarkSmartypants from 'remark-smartypants';
18-
import type { VFile } from 'vfile';
12+
import { SourceMapGenerator } from 'source-map';
13+
import type { Processor } from 'unified';
1914
import type { MdxOptions } from './index.js';
15+
import { recmaInjectImportMetaEnv } from './recma-inject-import-meta-env.js';
16+
import { rehypeApplyFrontmatterExport } from './rehype-apply-frontmatter-export.js';
2017
import { rehypeInjectHeadingsExport } from './rehype-collect-headings.js';
2118
import rehypeMetaString from './rehype-meta-string.js';
2219
import { rehypeOptimizeStatic } from './rehype-optimize-static.js';
2320
import { remarkImageToComponent } from './remark-images-to-component.js';
24-
import { jsToTreeNode } from './utils.js';
2521

2622
// Skip nonessential plugins during performance benchmark runs
2723
const isPerformanceBenchmark = Boolean(process.env.ASTRO_PERFORMANCE_BENCHMARK);
2824

29-
export function recmaInjectImportMetaEnvPlugin({
30-
importMetaEnv,
31-
}: {
25+
interface MdxProcessorExtraOptions {
26+
sourcemap: boolean;
3227
importMetaEnv: Record<string, any>;
33-
}) {
34-
return (tree: any) => {
35-
estreeVisit(tree, (node) => {
36-
if (node.type === 'MemberExpression') {
37-
// attempt to get "import.meta.env" variable name
38-
const envVarName = getImportMetaEnvVariableName(node);
39-
if (typeof envVarName === 'string') {
40-
// clear object keys to replace with envVarLiteral
41-
for (const key in node) {
42-
delete (node as any)[key];
43-
}
44-
const envVarLiteral: Literal = {
45-
type: 'Literal',
46-
value: importMetaEnv[envVarName],
47-
raw: JSON.stringify(importMetaEnv[envVarName]),
48-
};
49-
Object.assign(node, envVarLiteral);
50-
}
51-
}
52-
});
53-
};
5428
}
5529

56-
export function rehypeApplyFrontmatterExport() {
57-
return function (tree: any, vfile: VFile) {
58-
const astroData = safelyGetAstroData(vfile.data);
59-
if (astroData instanceof InvalidAstroDataError)
60-
throw new Error(
61-
// Copied from Astro core `errors-data`
62-
// TODO: find way to import error data from core
63-
'[MDX] A remark or rehype plugin attempted to inject invalid frontmatter. Ensure "astro.frontmatter" is set to a valid JSON object that is not `null` or `undefined`.'
64-
);
65-
const { frontmatter } = astroData;
66-
const exportNodes = [
67-
jsToTreeNode(`export const frontmatter = ${JSON.stringify(frontmatter)};`),
68-
];
69-
if (frontmatter.layout) {
70-
// NOTE(bholmesdev) 08-22-2022
71-
// Using an async layout import (i.e. `const Layout = (await import...)`)
72-
// Preserves the dev server import cache when globbing a large set of MDX files
73-
// Full explanation: 'https://github.com/withastro/astro/pull/4428'
74-
exportNodes.unshift(
75-
jsToTreeNode(
76-
/** @see 'vite-plugin-markdown' for layout props reference */
77-
`import { jsx as layoutJsx } from 'astro/jsx-runtime';
78-
79-
export default async function ({ children }) {
80-
const Layout = (await import(${JSON.stringify(frontmatter.layout)})).default;
81-
const { layout, ...content } = frontmatter;
82-
content.file = file;
83-
content.url = url;
84-
return layoutJsx(Layout, {
85-
file,
86-
url,
87-
content,
88-
frontmatter: content,
89-
headings: getHeadings(),
90-
'server:root': true,
91-
children,
92-
});
93-
};`
94-
)
95-
);
96-
}
97-
tree.children = exportNodes.concat(tree.children);
98-
};
30+
export function createMdxProcessor(
31+
mdxOptions: MdxOptions,
32+
extraOptions: MdxProcessorExtraOptions
33+
): Processor {
34+
return createProcessor({
35+
remarkPlugins: getRemarkPlugins(mdxOptions),
36+
rehypePlugins: getRehypePlugins(mdxOptions),
37+
recmaPlugins: getRecmaPlugins(mdxOptions, extraOptions.importMetaEnv),
38+
remarkRehypeOptions: mdxOptions.remarkRehype,
39+
jsx: true,
40+
jsxImportSource: 'astro',
41+
// Note: disable `.md` (and other alternative extensions for markdown files like `.markdown`) support
42+
format: 'mdx',
43+
mdExtensions: [],
44+
elementAttributeNameCase: 'html',
45+
SourceMapGenerator: extraOptions.sourcemap ? SourceMapGenerator : undefined,
46+
});
9947
}
10048

101-
export async function getRemarkPlugins(mdxOptions: MdxOptions): Promise<PluggableList> {
49+
function getRemarkPlugins(mdxOptions: MdxOptions): PluggableList {
10250
let remarkPlugins: PluggableList = [remarkCollectImages, remarkImageToComponent];
10351

10452
if (!isPerformanceBenchmark) {
@@ -125,7 +73,7 @@ export async function getRemarkPlugins(mdxOptions: MdxOptions): Promise<Pluggabl
12573
return remarkPlugins;
12674
}
12775

128-
export function getRehypePlugins(mdxOptions: MdxOptions): PluggableList {
76+
function getRehypePlugins(mdxOptions: MdxOptions): PluggableList {
12977
let rehypePlugins: PluggableList = [
13078
// ensure `data.meta` is preserved in `properties.metastring` for rehype syntax highlighters
13179
rehypeMetaString,
@@ -152,38 +100,9 @@ export function getRehypePlugins(mdxOptions: MdxOptions): PluggableList {
152100
return rehypePlugins;
153101
}
154102

155-
/**
156-
* Check if estree entry is "import.meta.env.VARIABLE"
157-
* If it is, return the variable name (i.e. "VARIABLE")
158-
*/
159-
function getImportMetaEnvVariableName(node: MemberExpression): string | Error {
160-
try {
161-
// check for ".[ANYTHING]"
162-
if (node.object.type !== 'MemberExpression' || node.property.type !== 'Identifier')
163-
return new Error();
164-
165-
const nestedExpression = node.object;
166-
// check for ".env"
167-
if (nestedExpression.property.type !== 'Identifier' || nestedExpression.property.name !== 'env')
168-
return new Error();
169-
170-
const envExpression = nestedExpression.object;
171-
// check for ".meta"
172-
if (
173-
envExpression.type !== 'MetaProperty' ||
174-
envExpression.property.type !== 'Identifier' ||
175-
envExpression.property.name !== 'meta'
176-
)
177-
return new Error();
178-
179-
// check for "import"
180-
if (envExpression.meta.name !== 'import') return new Error();
181-
182-
return node.property.name;
183-
} catch (e) {
184-
if (e instanceof Error) {
185-
return e;
186-
}
187-
return new Error('Unknown parsing error');
188-
}
103+
function getRecmaPlugins(
104+
mdxOptions: MdxOptions,
105+
importMetaEnv: Record<string, any>
106+
): PluggableList {
107+
return [...(mdxOptions.recmaPlugins ?? []), [recmaInjectImportMetaEnv, { importMetaEnv }]];
189108
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { Literal, MemberExpression } from 'estree';
2+
import { visit as estreeVisit } from 'estree-util-visit';
3+
4+
export function recmaInjectImportMetaEnv({
5+
importMetaEnv,
6+
}: {
7+
importMetaEnv: Record<string, any>;
8+
}) {
9+
return (tree: any) => {
10+
estreeVisit(tree, (node) => {
11+
if (node.type === 'MemberExpression') {
12+
// attempt to get "import.meta.env" variable name
13+
const envVarName = getImportMetaEnvVariableName(node);
14+
if (typeof envVarName === 'string') {
15+
// clear object keys to replace with envVarLiteral
16+
for (const key in node) {
17+
delete (node as any)[key];
18+
}
19+
const envVarLiteral: Literal = {
20+
type: 'Literal',
21+
value: importMetaEnv[envVarName],
22+
raw: JSON.stringify(importMetaEnv[envVarName]),
23+
};
24+
Object.assign(node, envVarLiteral);
25+
}
26+
}
27+
});
28+
};
29+
}
30+
31+
/**
32+
* Check if estree entry is "import.meta.env.VARIABLE"
33+
* If it is, return the variable name (i.e. "VARIABLE")
34+
*/
35+
function getImportMetaEnvVariableName(node: MemberExpression): string | Error {
36+
try {
37+
// check for ".[ANYTHING]"
38+
if (node.object.type !== 'MemberExpression' || node.property.type !== 'Identifier')
39+
return new Error();
40+
41+
const nestedExpression = node.object;
42+
// check for ".env"
43+
if (nestedExpression.property.type !== 'Identifier' || nestedExpression.property.name !== 'env')
44+
return new Error();
45+
46+
const envExpression = nestedExpression.object;
47+
// check for ".meta"
48+
if (
49+
envExpression.type !== 'MetaProperty' ||
50+
envExpression.property.type !== 'Identifier' ||
51+
envExpression.property.name !== 'meta'
52+
)
53+
return new Error();
54+
55+
// check for "import"
56+
if (envExpression.meta.name !== 'import') return new Error();
57+
58+
return node.property.name;
59+
} catch (e) {
60+
if (e instanceof Error) {
61+
return e;
62+
}
63+
return new Error('Unknown parsing error');
64+
}
65+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { InvalidAstroDataError } from '@astrojs/markdown-remark';
2+
import { safelyGetAstroData } from '@astrojs/markdown-remark/dist/internal.js';
3+
import type { VFile } from 'vfile';
4+
import { jsToTreeNode } from './utils.js';
5+
6+
export function rehypeApplyFrontmatterExport() {
7+
return function (tree: any, vfile: VFile) {
8+
const astroData = safelyGetAstroData(vfile.data);
9+
if (astroData instanceof InvalidAstroDataError)
10+
throw new Error(
11+
// Copied from Astro core `errors-data`
12+
// TODO: find way to import error data from core
13+
'[MDX] A remark or rehype plugin attempted to inject invalid frontmatter. Ensure "astro.frontmatter" is set to a valid JSON object that is not `null` or `undefined`.'
14+
);
15+
const { frontmatter } = astroData;
16+
const exportNodes = [
17+
jsToTreeNode(`export const frontmatter = ${JSON.stringify(frontmatter)};`),
18+
];
19+
if (frontmatter.layout) {
20+
// NOTE(bholmesdev) 08-22-2022
21+
// Using an async layout import (i.e. `const Layout = (await import...)`)
22+
// Preserves the dev server import cache when globbing a large set of MDX files
23+
// Full explanation: 'https://github.com/withastro/astro/pull/4428'
24+
exportNodes.unshift(
25+
jsToTreeNode(
26+
/** @see 'vite-plugin-markdown' for layout props reference */
27+
`import { jsx as layoutJsx } from 'astro/jsx-runtime';
28+
29+
export default async function ({ children }) {
30+
const Layout = (await import(${JSON.stringify(frontmatter.layout)})).default;
31+
const { layout, ...content } = frontmatter;
32+
content.file = file;
33+
content.url = url;
34+
return layoutJsx(Layout, {
35+
file,
36+
url,
37+
content,
38+
frontmatter: content,
39+
headings: getHeadings(),
40+
'server:root': true,
41+
children,
42+
});
43+
};`
44+
)
45+
);
46+
}
47+
tree.children = exportNodes.concat(tree.children);
48+
};
49+
}

0 commit comments

Comments
 (0)