diff --git a/.changeset/blue-geese-visit.md b/.changeset/blue-geese-visit.md
new file mode 100644
index 000000000000..408386d046c3
--- /dev/null
+++ b/.changeset/blue-geese-visit.md
@@ -0,0 +1,5 @@
+---
+"@astrojs/mdx": patch
+---
+
+Simplifies plain MDX components as hast element nodes to further improve HTML string inlining for the `optimize` option
diff --git a/.changeset/chilly-items-help.md b/.changeset/chilly-items-help.md
new file mode 100644
index 000000000000..7e868474e32c
--- /dev/null
+++ b/.changeset/chilly-items-help.md
@@ -0,0 +1,5 @@
+---
+"astro": patch
+---
+
+Improves the error message when failed to render MDX components
diff --git a/.changeset/fresh-masks-agree.md b/.changeset/fresh-masks-agree.md
new file mode 100644
index 000000000000..08fc812c8841
--- /dev/null
+++ b/.changeset/fresh-masks-agree.md
@@ -0,0 +1,5 @@
+---
+"@astrojs/mdx": major
+---
+
+Refactors the MDX transformation to rely only on the unified pipeline. Babel and esbuild transformations are removed, which should result in faster build times. The refactor requires using Astro v4.8.0 but no other changes are necessary.
diff --git a/.changeset/friendly-plants-leave.md b/.changeset/friendly-plants-leave.md
new file mode 100644
index 000000000000..c972fa42c4db
--- /dev/null
+++ b/.changeset/friendly-plants-leave.md
@@ -0,0 +1,5 @@
+---
+"astro": minor
+---
+
+Exports `astro/jsx/rehype.js` with utilities to generate an Astro metadata object
diff --git a/.changeset/large-glasses-jam.md b/.changeset/large-glasses-jam.md
new file mode 100644
index 000000000000..885471d82fba
--- /dev/null
+++ b/.changeset/large-glasses-jam.md
@@ -0,0 +1,5 @@
+---
+"@astrojs/mdx": patch
+---
+
+Allows Vite plugins to transform `.mdx` files before the MDX plugin transforms it
diff --git a/.changeset/slimy-cobras-end.md b/.changeset/slimy-cobras-end.md
new file mode 100644
index 000000000000..58f22ac07c12
--- /dev/null
+++ b/.changeset/slimy-cobras-end.md
@@ -0,0 +1,7 @@
+---
+"@astrojs/mdx": major
+---
+
+Allows integrations after the MDX integration to update `markdown.remarkPlugins` and `markdown.rehypePlugins`, and have the plugins work in MDX too.
+
+If your integration relies on Astro's previous behavior that prevents integrations from adding remark/rehype plugins for MDX, you will now need to configure `@astrojs/mdx` with `extendMarkdownConfig: false` and explicitly specify any `remarkPlugins` and `rehypePlugins` options instead.
diff --git a/.changeset/small-oranges-report.md b/.changeset/small-oranges-report.md
new file mode 100644
index 000000000000..8d0906e0530b
--- /dev/null
+++ b/.changeset/small-oranges-report.md
@@ -0,0 +1,5 @@
+---
+"@astrojs/mdx": major
+---
+
+Renames the `optimize.customComponentNames` option to `optimize.ignoreElementNames` to better reflect its usecase. Its behaviour is not changed and should continue to work as before.
diff --git a/.changeset/smart-rats-mate.md b/.changeset/smart-rats-mate.md
new file mode 100644
index 000000000000..b779a86c8a5b
--- /dev/null
+++ b/.changeset/smart-rats-mate.md
@@ -0,0 +1,5 @@
+---
+"@astrojs/mdx": patch
+---
+
+Updates the `optimize` option to group static sibling nodes as a ``. This reduces the number of AST nodes and simplifies runtime rendering of MDX pages.
diff --git a/.changeset/sweet-goats-own.md b/.changeset/sweet-goats-own.md
new file mode 100644
index 000000000000..6689246c33b3
--- /dev/null
+++ b/.changeset/sweet-goats-own.md
@@ -0,0 +1,5 @@
+---
+"@astrojs/mdx": major
+---
+
+Replaces the internal `remark-images-to-component` plugin with `rehype-images-to-component` to let users use additional rehype plugins for images
diff --git a/.changeset/tame-avocados-relax.md b/.changeset/tame-avocados-relax.md
new file mode 100644
index 000000000000..9b6a36881c03
--- /dev/null
+++ b/.changeset/tame-avocados-relax.md
@@ -0,0 +1,5 @@
+---
+"@astrojs/mdx": patch
+---
+
+Tags the MDX component export for quicker component checks while rendering
diff --git a/.changeset/violet-snails-call.md b/.changeset/violet-snails-call.md
new file mode 100644
index 000000000000..b7f06a7b9321
--- /dev/null
+++ b/.changeset/violet-snails-call.md
@@ -0,0 +1,5 @@
+---
+"@astrojs/mdx": patch
+---
+
+Fixes `export const components` keys detection for the `optimize` option
diff --git a/.changeset/young-chicken-exercise.md b/.changeset/young-chicken-exercise.md
new file mode 100644
index 000000000000..04b7417bbe21
--- /dev/null
+++ b/.changeset/young-chicken-exercise.md
@@ -0,0 +1,5 @@
+---
+"@astrojs/mdx": patch
+---
+
+Improves `optimize` handling for MDX components with attributes and inline MDX components
diff --git a/packages/astro/package.json b/packages/astro/package.json
index 6c3bcfeddbf3..572d5a9863f8 100644
--- a/packages/astro/package.json
+++ b/packages/astro/package.json
@@ -209,6 +209,8 @@
"astro-scripts": "workspace:*",
"cheerio": "1.0.0-rc.12",
"eol": "^0.9.1",
+ "mdast-util-mdx": "^3.0.0",
+ "mdast-util-mdx-jsx": "^3.1.2",
"memfs": "^4.9.1",
"node-mocks-http": "^1.14.1",
"parse-srcset": "^1.0.2",
diff --git a/packages/astro/src/jsx/babel.ts b/packages/astro/src/jsx/babel.ts
index e8f9da87e2e1..d5fc0ccd30b0 100644
--- a/packages/astro/src/jsx/babel.ts
+++ b/packages/astro/src/jsx/babel.ts
@@ -134,6 +134,9 @@ function addClientOnlyMetadata(
}
}
+/**
+ * @deprecated This plugin is no longer used. Remove in Astro 5.0
+ */
export default function astroJSX(): PluginObj {
return {
visitor: {
diff --git a/packages/astro/src/jsx/rehype.ts b/packages/astro/src/jsx/rehype.ts
new file mode 100644
index 000000000000..40a8359cbe5c
--- /dev/null
+++ b/packages/astro/src/jsx/rehype.ts
@@ -0,0 +1,320 @@
+import type { RehypePlugin } from '@astrojs/markdown-remark';
+import type { RootContent } from 'hast';
+import type {
+ MdxJsxAttribute,
+ MdxJsxFlowElementHast,
+ MdxJsxTextElementHast,
+} from 'mdast-util-mdx-jsx';
+import { visit } from 'unist-util-visit';
+import type { VFile } from 'vfile';
+import { AstroError } from '../core/errors/errors.js';
+import { AstroErrorData } from '../core/errors/index.js';
+import { resolvePath } from '../core/util.js';
+import type { PluginMetadata } from '../vite-plugin-astro/types.js';
+
+// This import includes ambient types for hast to include mdx nodes
+import type {} from 'mdast-util-mdx';
+
+const ClientOnlyPlaceholder = 'astro-client-only';
+
+export const rehypeAnalyzeAstroMetadata: RehypePlugin = () => {
+ return (tree, file) => {
+ // Initial metadata for this MDX file, it will be mutated as we traverse the tree
+ const metadata: PluginMetadata['astro'] = {
+ clientOnlyComponents: [],
+ hydratedComponents: [],
+ scripts: [],
+ containsHead: false,
+ propagation: 'none',
+ pageOptions: {},
+ };
+
+ // 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;
+
+ const tagName = node.name;
+ if (!tagName || !isComponent(tagName) || !hasClientDirective(node)) return;
+
+ // From this point onwards, `node` is confirmed to be an island component
+
+ // Match this component with its import source
+ const matchedImport = findMatchingImport(tagName, imports);
+ if (!matchedImport) {
+ throw new AstroError({
+ ...AstroErrorData.NoMatchingImport,
+ message: AstroErrorData.NoMatchingImport.message(node.name!),
+ });
+ }
+
+ // If this is an Astro component, that means the `client:` directive is misused as it doesn't
+ // work on Astro components as it's server-side only. Warn the user about this.
+ if (matchedImport.path.endsWith('.astro')) {
+ const clientAttribute = node.attributes.find(
+ (attr) => attr.type === 'mdxJsxAttribute' && attr.name.startsWith('client:')
+ ) as MdxJsxAttribute | undefined;
+ if (clientAttribute) {
+ // eslint-disable-next-line
+ console.warn(
+ `You are attempting to render <${node.name!} ${
+ clientAttribute.name
+ } />, but ${node.name!} is an Astro component. Astro components do not render in the client and should not have a hydration directive. Please use a framework component for client rendering.`
+ );
+ }
+ }
+
+ const resolvedPath = resolvePath(matchedImport.path, file.path);
+
+ if (hasClientOnlyDirective(node)) {
+ // Add this component to the metadata
+ metadata.clientOnlyComponents.push({
+ exportName: matchedImport.name,
+ specifier: tagName,
+ resolvedPath,
+ });
+ // Mutate node with additional island attributes
+ addClientOnlyMetadata(node, matchedImport, resolvedPath);
+ } else {
+ // Add this component to the metadata
+ metadata.hydratedComponents.push({
+ exportName: '*',
+ specifier: tagName,
+ resolvedPath,
+ });
+ // Mutate node with additional island attributes
+ addClientMetadata(node, matchedImport, resolvedPath);
+ }
+ });
+
+ // Attach final metadata here, which can later be retrieved by `getAstroMetadata`
+ file.data.__astroMetadata = metadata;
+ };
+};
+
+export function getAstroMetadata(file: VFile) {
+ return file.data.__astroMetadata as PluginMetadata['astro'] | undefined;
+}
+
+type ImportSpecifier = { local: string; imported: string };
+
+/**
+ * ```
+ * import Foo from './Foo.jsx'
+ * import { Bar } from './Bar.jsx'
+ * import { Baz as Wiz } from './Bar.jsx'
+ * import * as Waz from './BaWazz.jsx'
+ *
+ * // => Map {
+ * // "./Foo.jsx" => Set { { local: "Foo", imported: "default" } },
+ * // "./Bar.jsx" => Set {
+ * // { local: "Bar", imported: "Bar" }
+ * // { local: "Wiz", imported: "Baz" },
+ * // },
+ * // "./Waz.jsx" => Set { { local: "Waz", imported: "*" } },
+ * // }
+ * ```
+ */
+function parseImports(children: RootContent[]) {
+ // Map of import source to its imported specifiers
+ const imports = new Map>();
+
+ for (const child of children) {
+ if (child.type !== 'mdxjsEsm') continue;
+
+ const body = child.data?.estree?.body;
+ if (!body) continue;
+
+ for (const ast of body) {
+ if (ast.type !== 'ImportDeclaration') continue;
+
+ const source = ast.source.value as string;
+ const specs: ImportSpecifier[] = ast.specifiers.map((spec) => {
+ switch (spec.type) {
+ case 'ImportDefaultSpecifier':
+ return { local: spec.local.name, imported: 'default' };
+ case 'ImportNamespaceSpecifier':
+ return { local: spec.local.name, imported: '*' };
+ case 'ImportSpecifier':
+ return { local: spec.local.name, imported: spec.imported.name };
+ default:
+ throw new Error('Unknown import declaration specifier: ' + spec);
+ }
+ });
+
+ // Get specifiers set from source or initialize a new one
+ let specSet = imports.get(source);
+ if (!specSet) {
+ specSet = new Set();
+ imports.set(source, specSet);
+ }
+
+ for (const spec of specs) {
+ specSet.add(spec);
+ }
+ }
+ }
+
+ return imports;
+}
+
+function isComponent(tagName: string) {
+ return (
+ (tagName[0] && tagName[0].toLowerCase() !== tagName[0]) ||
+ tagName.includes('.') ||
+ /[^a-zA-Z]/.test(tagName[0])
+ );
+}
+
+function hasClientDirective(node: MdxJsxFlowElementHast | MdxJsxTextElementHast) {
+ return node.attributes.some(
+ (attr) => attr.type === 'mdxJsxAttribute' && attr.name.startsWith('client:')
+ );
+}
+
+function hasClientOnlyDirective(node: MdxJsxFlowElementHast | MdxJsxTextElementHast) {
+ return node.attributes.some(
+ (attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'client:only'
+ );
+}
+
+type MatchedImport = { name: string; path: string };
+
+/**
+ * ```
+ * import Button from './Button.jsx'
+ *
+ * // => { name: "default", path: "./Button.jsx" }
+ *
+ * import { Button } from './Button.jsx'
+ *
+ * // => { name: "Button", path: "./Button.jsx" }
+ *
+ * import * as buttons from './Button.jsx'
+ *
+ * // => { name: "Foo.Bar", path: "./Button.jsx" }
+ *
+ * import { buttons } from './Button.jsx'
+ *
+ * // => { name: "buttons.Foo.Bar", path: "./Button.jsx" }
+ *
+ * import buttons from './Button.jsx'
+ *
+ * // => { name: "default.Foo.Bar", path: "./Button.jsx" }
+ * ```
+ */
+function findMatchingImport(
+ tagName: string,
+ imports: Map>
+): MatchedImport | undefined {
+ const tagSpecifier = tagName.split('.')[0];
+ for (const [source, specs] of imports) {
+ for (const { imported, local } of specs) {
+ if (local === tagSpecifier) {
+ // If tagName access properties, we need to make sure the returned `name`
+ // properly access the properties from `path`
+ if (tagSpecifier !== tagName) {
+ switch (imported) {
+ // Namespace import: "" => name: "Foo.Bar"
+ case '*': {
+ const accessPath = tagName.slice(tagSpecifier.length + 1);
+ return { name: accessPath, path: source };
+ }
+ // Default import: "" => name: "default.Foo.Bar"
+ case 'default': {
+ // "buttons.Foo.Bar" => "Foo.Bar"
+ const accessPath = tagName.slice(tagSpecifier.length + 1);
+ return { name: `default.${accessPath}`, path: source };
+ }
+ // Named import: "" => name: "buttons.Foo.Bar"
+ default: {
+ return { name: tagName, path: source };
+ }
+ }
+ }
+
+ return { name: imported, path: source };
+ }
+ }
+ }
+}
+
+function addClientMetadata(
+ node: MdxJsxFlowElementHast | MdxJsxTextElementHast,
+ meta: MatchedImport,
+ resolvedPath: string
+) {
+ const attributeNames = node.attributes
+ .map((attr) => (attr.type === 'mdxJsxAttribute' ? attr.name : null))
+ .filter(Boolean);
+
+ if (!attributeNames.includes('client:component-path')) {
+ node.attributes.push({
+ type: 'mdxJsxAttribute',
+ name: 'client:component-path',
+ value: resolvedPath,
+ });
+ }
+ if (!attributeNames.includes('client:component-export')) {
+ if (meta.name === '*') {
+ meta.name = node.name!.split('.').slice(1).join('.')!;
+ }
+ node.attributes.push({
+ type: 'mdxJsxAttribute',
+ name: 'client:component-export',
+ value: meta.name,
+ });
+ }
+ if (!attributeNames.includes('client:component-hydration')) {
+ node.attributes.push({
+ type: 'mdxJsxAttribute',
+ name: 'client:component-hydration',
+ value: null,
+ });
+ }
+}
+
+function addClientOnlyMetadata(
+ node: MdxJsxFlowElementHast | MdxJsxTextElementHast,
+ meta: { path: string; name: string },
+ resolvedPath: string
+) {
+ const attributeNames = node.attributes
+ .map((attr) => (attr.type === 'mdxJsxAttribute' ? attr.name : null))
+ .filter(Boolean);
+
+ if (!attributeNames.includes('client:display-name')) {
+ node.attributes.push({
+ type: 'mdxJsxAttribute',
+ name: 'client:display-name',
+ value: node.name,
+ });
+ }
+ if (!attributeNames.includes('client:component-hydpathation')) {
+ node.attributes.push({
+ type: 'mdxJsxAttribute',
+ name: 'client:component-path',
+ value: resolvedPath,
+ });
+ }
+ if (!attributeNames.includes('client:component-export')) {
+ if (meta.name === '*') {
+ meta.name = node.name!.split('.').slice(1).join('.')!;
+ }
+ node.attributes.push({
+ type: 'mdxJsxAttribute',
+ name: 'client:component-export',
+ value: meta.name,
+ });
+ }
+ if (!attributeNames.includes('client:component-hydration')) {
+ node.attributes.push({
+ type: 'mdxJsxAttribute',
+ name: 'client:component-hydration',
+ value: null,
+ });
+ }
+
+ node.name = ClientOnlyPlaceholder;
+}
diff --git a/packages/astro/src/jsx/server.ts b/packages/astro/src/jsx/server.ts
index d445ee3a559b..2ed308c37e82 100644
--- a/packages/astro/src/jsx/server.ts
+++ b/packages/astro/src/jsx/server.ts
@@ -4,6 +4,8 @@ import { renderJSX } from '../runtime/server/jsx.js';
const slotName = (str: string) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());
+// NOTE: In practice, MDX components are always tagged with `__astro_tag_component__`, so the right renderer
+// is used directly, and this check is not often used to return true.
export async function check(
Component: any,
props: any,
@@ -19,18 +21,7 @@ export async function check(
const result = await Component({ ...props, ...slots, children });
return result[AstroJSX];
} catch (e) {
- const error = e as Error;
- // if the exception is from an mdx component
- // throw an error
- if (Component[Symbol.for('mdx-component')]) {
- throw new AstroError({
- message: error.message,
- title: error.name,
- hint: `This issue often occurs when your MDX component encounters runtime errors.`,
- name: error.name,
- stack: error.stack,
- });
- }
+ throwEnhancedErrorIfMdxComponent(e as Error, Component);
}
return false;
}
@@ -48,8 +39,27 @@ export async function renderToStaticMarkup(
}
const { result } = this;
- const html = await renderJSX(result, jsx(Component, { ...props, ...slots, children }));
- return { html };
+ try {
+ const html = await renderJSX(result, jsx(Component, { ...props, ...slots, children }));
+ return { html };
+ } catch (e) {
+ throwEnhancedErrorIfMdxComponent(e as Error, Component);
+ throw e;
+ }
+}
+
+function throwEnhancedErrorIfMdxComponent(error: Error, Component: any) {
+ // if the exception is from an mdx component
+ // throw an error
+ if (Component[Symbol.for('mdx-component')]) {
+ throw new AstroError({
+ message: error.message,
+ title: error.name,
+ hint: `This issue often occurs when your MDX component encounters runtime errors.`,
+ name: error.name,
+ stack: error.stack,
+ });
+ }
}
export default {
diff --git a/packages/astro/src/jsx/transform-options.ts b/packages/astro/src/jsx/transform-options.ts
index 4b51d85b8b04..ca1d50a6a131 100644
--- a/packages/astro/src/jsx/transform-options.ts
+++ b/packages/astro/src/jsx/transform-options.ts
@@ -1,5 +1,8 @@
import type { JSXTransformConfig } from '../@types/astro.js';
+/**
+ * @deprecated This function is no longer used. Remove in Astro 5.0
+ */
export async function jsxTransformOptions(): Promise {
// @ts-expect-error types not found
const plugin = await import('@babel/plugin-transform-react-jsx');
diff --git a/packages/astro/src/vite-plugin-mdx/index.ts b/packages/astro/src/vite-plugin-mdx/index.ts
index 7e86aed288f4..1c8e2ced6d81 100644
--- a/packages/astro/src/vite-plugin-mdx/index.ts
+++ b/packages/astro/src/vite-plugin-mdx/index.ts
@@ -9,7 +9,9 @@ const SPECIAL_QUERY_REGEX = new RegExp(
`[?&](?:worker|sharedworker|raw|url|${CONTENT_FLAG}|${PROPAGATED_ASSET_FLAG})\\b`
);
-// TODO: Move this Vite plugin into `@astrojs/mdx` in Astro 5
+/**
+ * @deprecated This plugin is no longer used. Remove in Astro 5.0
+ */
export default function mdxVitePlugin(): Plugin {
return {
name: 'astro:jsx',
diff --git a/packages/astro/src/vite-plugin-mdx/tag.ts b/packages/astro/src/vite-plugin-mdx/tag.ts
index 3b774a0a238d..0bf9722a56f2 100644
--- a/packages/astro/src/vite-plugin-mdx/tag.ts
+++ b/packages/astro/src/vite-plugin-mdx/tag.ts
@@ -11,6 +11,8 @@ const rendererName = astroJsxRenderer.name;
*
* This plugin crawls each export in the file and "tags" each export with a given `rendererName`.
* This allows us to automatically match a component to a renderer and skip the usual `check()` calls.
+ *
+ * @deprecated This plugin is no longer used. Remove in Astro 5.0
*/
export const tagExportsPlugin: PluginObj = {
visitor: {
diff --git a/packages/astro/src/vite-plugin-mdx/transform-jsx.ts b/packages/astro/src/vite-plugin-mdx/transform-jsx.ts
index 07eb87d0465e..e2e8e97bc6b1 100644
--- a/packages/astro/src/vite-plugin-mdx/transform-jsx.ts
+++ b/packages/astro/src/vite-plugin-mdx/transform-jsx.ts
@@ -5,6 +5,9 @@ import { jsxTransformOptions } from '../jsx/transform-options.js';
import type { PluginMetadata } from '../vite-plugin-astro/types.js';
import { tagExportsPlugin } from './tag.js';
+/**
+ * @deprecated This function is no longer used. Remove in Astro 5.0
+ */
export async function transformJSX(
code: string,
id: string,
diff --git a/packages/astro/test/units/dev/collections-renderentry.test.js b/packages/astro/test/units/dev/collections-renderentry.test.js
index 4c3849577629..3fa872289404 100644
--- a/packages/astro/test/units/dev/collections-renderentry.test.js
+++ b/packages/astro/test/units/dev/collections-renderentry.test.js
@@ -84,18 +84,6 @@ _describe('Content Collections - render()', () => {
it('can be used in a layout component', async () => {
const fs = createFsWithFallback(
{
- // Loading the content config with `astro:content` oddly
- // causes this test to fail. Spoof a different src/content entry
- // to ensure `existsSync` checks pass.
- // TODO: revisit after addressing this issue
- // https://github.com/withastro/astro/issues/6121
- '/src/content/blog/promo/launch-week.mdx': `---
-title: Launch Week
-description: Astro is launching this week!
----
-# Launch Week
-- [x] Launch Astro
-- [ ] Celebrate`,
'/src/components/Layout.astro': `
---
import { getCollection } from 'astro:content';
diff --git a/packages/integrations/mdx/package.json b/packages/integrations/mdx/package.json
index 8eb0bce21da1..5ea24d1609a7 100644
--- a/packages/integrations/mdx/package.json
+++ b/packages/integrations/mdx/package.json
@@ -50,10 +50,11 @@
"vfile": "^6.0.1"
},
"peerDependencies": {
- "astro": "^4.0.0"
+ "astro": "^4.8.0"
},
"devDependencies": {
"@types/estree": "^1.0.5",
+ "@types/hast": "^3.0.3",
"@types/mdast": "^4.0.3",
"@types/yargs-parser": "^21.0.3",
"astro": "workspace:*",
@@ -61,6 +62,7 @@
"cheerio": "1.0.0-rc.12",
"linkedom": "^0.16.11",
"mdast-util-mdx": "^3.0.0",
+ "mdast-util-mdx-jsx": "^3.1.2",
"mdast-util-to-string": "^4.0.0",
"reading-time": "^1.5.0",
"rehype-mathjax": "^6.0.0",
diff --git a/packages/integrations/mdx/src/README.md b/packages/integrations/mdx/src/README.md
index bbbc6075c8af..3fc991b77c91 100644
--- a/packages/integrations/mdx/src/README.md
+++ b/packages/integrations/mdx/src/README.md
@@ -30,12 +30,7 @@ After:
```jsx
function _createMdxContent() {
- return (
- <>
-
My MDX Content
-
- >
- );
+ return ;
}
```
@@ -49,15 +44,20 @@ The next section explains the algorithm, which you can follow along by pairing w
### How it works
-Two variables:
+The flow can be divided into a "scan phase" and a "mutation phase". The scan phase searches for nodes that can be optimized, and the mutation phase applies the optimization on the `hast` nodes.
+
+#### Scan phase
+
+Variables:
- `allPossibleElements`: A set of subtree roots where we can add a new `set:html` property with its children as value.
- `elementStack`: The stack of elements (that could be subtree roots) while traversing the `hast` (node ancestors).
+- `elementMetadatas`: A weak map to store the metadata used only by the mutation phase later.
Flow:
1. Walk the `hast` tree.
-2. For each `node` we enter, if the `node` is static (`type` is `element` or `mdxJsxFlowElement`), record in `allPossibleElements` and push to `elementStack`.
+2. For each `node` we enter, if the `node` is static (`type` is `element` or starts with `mdx`), record in `allPossibleElements` and push to `elementStack`. We also record additional metadata in `elementMetadatas` for the mutation phase later.
- Q: Why do we record `mdxJsxFlowElement`, it's MDX?
A: Because we're looking for nodes whose children are static. The node itself doesn't need to be static.
- Q: Are we sure this is the subtree root node in `allPossibleElements`?
@@ -71,8 +71,25 @@ Flow:
- Q: Why before step 2's `node` enter handling?
A: If we find a non-static `node`, the `node` should still be considered in `allPossibleElements` as its children could be static.
5. Walk done. This leaves us with `allPossibleElements` containing only subtree roots that can be optimized.
-6. Add the `set:html` property to the `hast` node, and remove its children.
-7. 🎉 The rest of the MDX pipeline will do its thing and generate the desired JSX like above.
+6. Proceed to the mutation phase.
+
+#### Mutation phase
+
+Inputs:
+
+- `allPossibleElements` from the scan phase.
+- `elementMetadatas` from the scan phase.
+
+Flow:
+
+1. Before we mutate the `hast` tree, each element in `allPossibleElements` may have siblings that can be optimized together. Sibling elements are grouped with the `findElementGroups()` function, which returns an array of element groups (new variable `elementGroups`) and mutates `allPossibleElements` to remove elements that are already part of a group.
+
+ - Q: How does `findElementGroups()` work?
+ A: For each elements in `allPossibleElements` that are non-static, we're able to take the element metadata from `elementMetadatas` and guess the next sibling node. If the next sibling node is static and is an element in `allPossibleElements`, we group them together for optimization. It continues to guess until it hits a non-static node or an element not in `allPossibleElements`, which it'll finalize the group as part of the returned result.
+
+2. For each elements in `allPossibleElements`, we serailize them as HTML and add it to the `set:html` property of the `hast` node, and remove its children.
+3. For each element group in `elementGroups`, we serialize the group children as HTML and add it to a new `` node, and replace the group children with the new `` node.
+4. 🎉 The rest of the MDX pipeline will do its thing and generate the desired JSX like above.
### Extra
@@ -82,7 +99,7 @@ Astro's MDX implementation supports specifying `export const components` in the
#### Further optimizations
-In [How it works](#how-it-works) step 4,
+In [Scan phase](#scan-phase) step 4,
> we remove all the elements in `elementStack` from `allPossibleElements`
diff --git a/packages/integrations/mdx/src/index.ts b/packages/integrations/mdx/src/index.ts
index fc1d92da48ca..3aaed8787585 100644
--- a/packages/integrations/mdx/src/index.ts
+++ b/packages/integrations/mdx/src/index.ts
@@ -29,6 +29,10 @@ type SetupHookParams = HookParameters<'astro:config:setup'> & {
};
export default function mdx(partialMdxOptions: Partial = {}): AstroIntegration {
+ // @ts-expect-error Temporarily assign an empty object here, which will be re-assigned by the
+ // `astro:config:done` hook later. This is so that `vitePluginMdx` can get hold of a reference earlier.
+ let mdxOptions: MdxOptions = {};
+
return {
name: '@astrojs/mdx',
hooks: {
@@ -58,21 +62,30 @@ export default function mdx(partialMdxOptions: Partial = {}): AstroI
handlePropagation: true,
});
+ updateConfig({
+ vite: {
+ plugins: [vitePluginMdx(mdxOptions), vitePluginMdxPostprocess(config)],
+ },
+ });
+ },
+ 'astro:config:done': ({ config }) => {
+ // We resolve the final MDX options here so that other integrations have a chance to modify
+ // `config.markdown` before we access it
const extendMarkdownConfig =
partialMdxOptions.extendMarkdownConfig ?? defaultMdxOptions.extendMarkdownConfig;
- const mdxOptions = applyDefaultOptions({
+ const resolvedMdxOptions = applyDefaultOptions({
options: partialMdxOptions,
defaults: markdownConfigToMdxOptions(
extendMarkdownConfig ? config.markdown : markdownConfigDefaults
),
});
- updateConfig({
- vite: {
- plugins: [vitePluginMdx(config, mdxOptions), vitePluginMdxPostprocess(config)],
- },
- });
+ // Mutate `mdxOptions` so that `vitePluginMdx` can reference the actual options
+ Object.assign(mdxOptions, resolvedMdxOptions);
+ // @ts-expect-error After we assign, we don't need to reference `mdxOptions` in this context anymore.
+ // Re-assign it so that the garbage can be collected later.
+ mdxOptions = {};
},
},
};
@@ -81,7 +94,8 @@ export default function mdx(partialMdxOptions: Partial = {}): AstroI
const defaultMdxOptions = {
extendMarkdownConfig: true,
recmaPlugins: [],
-};
+ optimize: false,
+} satisfies Partial;
function markdownConfigToMdxOptions(markdownConfig: typeof markdownConfigDefaults): MdxOptions {
return {
@@ -90,7 +104,6 @@ function markdownConfigToMdxOptions(markdownConfig: typeof markdownConfigDefault
remarkPlugins: ignoreStringPlugins(markdownConfig.remarkPlugins),
rehypePlugins: ignoreStringPlugins(markdownConfig.rehypePlugins),
remarkRehype: (markdownConfig.remarkRehype as any) ?? {},
- optimize: false,
};
}
diff --git a/packages/integrations/mdx/src/plugins.ts b/packages/integrations/mdx/src/plugins.ts
index 99d0c70b2756..3978e5325435 100644
--- a/packages/integrations/mdx/src/plugins.ts
+++ b/packages/integrations/mdx/src/plugins.ts
@@ -5,6 +5,7 @@ import {
remarkCollectImages,
} from '@astrojs/markdown-remark';
import { createProcessor, nodeTypes } from '@mdx-js/mdx';
+import { rehypeAnalyzeAstroMetadata } from 'astro/jsx/rehype.js';
import rehypeRaw from 'rehype-raw';
import remarkGfm from 'remark-gfm';
import remarkSmartypants from 'remark-smartypants';
@@ -13,9 +14,9 @@ import type { PluggableList } from 'unified';
import type { MdxOptions } from './index.js';
import { rehypeApplyFrontmatterExport } from './rehype-apply-frontmatter-export.js';
import { rehypeInjectHeadingsExport } from './rehype-collect-headings.js';
+import { rehypeImageToComponent } from './rehype-images-to-component.js';
import rehypeMetaString from './rehype-meta-string.js';
import { rehypeOptimizeStatic } from './rehype-optimize-static.js';
-import { remarkImageToComponent } from './remark-images-to-component.js';
// Skip nonessential plugins during performance benchmark runs
const isPerformanceBenchmark = Boolean(process.env.ASTRO_PERFORMANCE_BENCHMARK);
@@ -30,7 +31,6 @@ export function createMdxProcessor(mdxOptions: MdxOptions, extraOptions: MdxProc
rehypePlugins: getRehypePlugins(mdxOptions),
recmaPlugins: mdxOptions.recmaPlugins,
remarkRehypeOptions: mdxOptions.remarkRehype,
- jsx: true,
jsxImportSource: 'astro',
// Note: disable `.md` (and other alternative extensions for markdown files like `.markdown`) support
format: 'mdx',
@@ -52,7 +52,7 @@ function getRemarkPlugins(mdxOptions: MdxOptions): PluggableList {
}
}
- remarkPlugins.push(...mdxOptions.remarkPlugins, remarkCollectImages, remarkImageToComponent);
+ remarkPlugins.push(...mdxOptions.remarkPlugins, remarkCollectImages);
return remarkPlugins;
}
@@ -74,7 +74,7 @@ function getRehypePlugins(mdxOptions: MdxOptions): PluggableList {
}
}
- rehypePlugins.push(...mdxOptions.rehypePlugins);
+ rehypePlugins.push(...mdxOptions.rehypePlugins, rehypeImageToComponent);
if (!isPerformanceBenchmark) {
// getHeadings() is guaranteed by TS, so this must be included.
@@ -82,8 +82,12 @@ function getRehypePlugins(mdxOptions: MdxOptions): PluggableList {
rehypePlugins.push(rehypeHeadingIds, rehypeInjectHeadingsExport);
}
- // computed from `astro.data.frontmatter` in VFile data
- rehypePlugins.push(rehypeApplyFrontmatterExport);
+ rehypePlugins.push(
+ // Render info from `vfile.data.astro.data.frontmatter` as JS
+ rehypeApplyFrontmatterExport,
+ // Analyze MDX nodes and attach to `vfile.data.__astroMetadata`
+ rehypeAnalyzeAstroMetadata
+ );
if (mdxOptions.optimize) {
// Convert user `optimize` option to compatible `rehypeOptimizeStatic` option
diff --git a/packages/integrations/mdx/src/rehype-images-to-component.ts b/packages/integrations/mdx/src/rehype-images-to-component.ts
new file mode 100644
index 000000000000..6c797fda235f
--- /dev/null
+++ b/packages/integrations/mdx/src/rehype-images-to-component.ts
@@ -0,0 +1,166 @@
+import type { MarkdownVFile } from '@astrojs/markdown-remark';
+import type { Properties, Root } from 'hast';
+import type { MdxJsxAttribute, MdxjsEsm } from 'mdast-util-mdx';
+import type { MdxJsxFlowElementHast } from 'mdast-util-mdx-jsx';
+import { visit } from 'unist-util-visit';
+import { jsToTreeNode } from './utils.js';
+
+export const ASTRO_IMAGE_ELEMENT = 'astro-image';
+export const ASTRO_IMAGE_IMPORT = '__AstroImage__';
+export const USES_ASTRO_IMAGE_FLAG = '__usesAstroImage';
+
+function createArrayAttribute(name: string, values: (string | number)[]): MdxJsxAttribute {
+ return {
+ type: 'mdxJsxAttribute',
+ name: name,
+ value: {
+ type: 'mdxJsxAttributeValueExpression',
+ value: name,
+ data: {
+ estree: {
+ type: 'Program',
+ body: [
+ {
+ type: 'ExpressionStatement',
+ expression: {
+ type: 'ArrayExpression',
+ elements: values.map((value) => ({
+ type: 'Literal',
+ value: value,
+ raw: String(value),
+ })),
+ },
+ },
+ ],
+ sourceType: 'module',
+ comments: [],
+ },
+ },
+ },
+ };
+}
+
+/**
+ * Convert the element properties (except `src`) to MDX JSX attributes.
+ *
+ * @param {Properties} props - The element properties
+ * @returns {MdxJsxAttribute[]} The MDX attributes
+ */
+function getImageComponentAttributes(props: Properties): MdxJsxAttribute[] {
+ const attrs: MdxJsxAttribute[] = [];
+
+ for (const [prop, value] of Object.entries(props)) {
+ if (prop === 'src') continue;
+
+ /*
+ * component expects an array for those attributes but the
+ * received properties are sanitized as strings. So we need to convert them
+ * back to an array.
+ */
+ if (prop === 'widths' || prop === 'densities') {
+ attrs.push(createArrayAttribute(prop, String(value).split(' ')));
+ } else {
+ attrs.push({
+ name: prop,
+ type: 'mdxJsxAttribute',
+ value: String(value),
+ });
+ }
+ }
+
+ return attrs;
+}
+
+export function rehypeImageToComponent() {
+ return function (tree: Root, file: MarkdownVFile) {
+ if (!file.data.imagePaths) return;
+
+ const importsStatements: MdxjsEsm[] = [];
+ const importedImages = new Map();
+
+ visit(tree, 'element', (node, index, parent) => {
+ if (!file.data.imagePaths || node.tagName !== 'img' || !node.properties.src) return;
+
+ const src = decodeURI(String(node.properties.src));
+
+ if (!file.data.imagePaths.has(src)) return;
+
+ let importName = importedImages.get(src);
+
+ if (!importName) {
+ importName = `__${importedImages.size}_${src.replace(/\W/g, '_')}__`;
+
+ importsStatements.push({
+ type: 'mdxjsEsm',
+ value: '',
+ data: {
+ estree: {
+ type: 'Program',
+ sourceType: 'module',
+ body: [
+ {
+ type: 'ImportDeclaration',
+ source: {
+ type: 'Literal',
+ value: src,
+ raw: JSON.stringify(src),
+ },
+ specifiers: [
+ {
+ type: 'ImportDefaultSpecifier',
+ local: { type: 'Identifier', name: importName },
+ },
+ ],
+ },
+ ],
+ },
+ },
+ });
+ importedImages.set(src, importName);
+ }
+
+ // Build a component that's equivalent to
+ const componentElement: MdxJsxFlowElementHast = {
+ name: ASTRO_IMAGE_ELEMENT,
+ type: 'mdxJsxFlowElement',
+ attributes: [
+ ...getImageComponentAttributes(node.properties),
+ {
+ name: 'src',
+ type: 'mdxJsxAttribute',
+ value: {
+ type: 'mdxJsxAttributeValueExpression',
+ value: importName,
+ data: {
+ estree: {
+ type: 'Program',
+ sourceType: 'module',
+ comments: [],
+ body: [
+ {
+ type: 'ExpressionStatement',
+ expression: { type: 'Identifier', name: importName },
+ },
+ ],
+ },
+ },
+ },
+ },
+ ],
+ children: [],
+ };
+
+ parent!.children.splice(index!, 1, componentElement);
+ });
+
+ // Add all the import statements to the top of the file for the images
+ tree.children.unshift(...importsStatements);
+
+ tree.children.unshift(
+ jsToTreeNode(`import { Image as ${ASTRO_IMAGE_IMPORT} } from "astro:assets";`)
+ );
+ // Export `__usesAstroImage` to pick up `astro:assets` usage in the module graph.
+ // @see the '@astrojs/mdx-postprocess' plugin
+ tree.children.push(jsToTreeNode(`export const ${USES_ASTRO_IMAGE_FLAG} = true`));
+ };
+}
diff --git a/packages/integrations/mdx/src/rehype-optimize-static.ts b/packages/integrations/mdx/src/rehype-optimize-static.ts
index 573af317e99c..ebedb753e1cf 100644
--- a/packages/integrations/mdx/src/rehype-optimize-static.ts
+++ b/packages/integrations/mdx/src/rehype-optimize-static.ts
@@ -1,11 +1,26 @@
-import { visit } from 'estree-util-visit';
+import type { RehypePlugin } from '@astrojs/markdown-remark';
+import { SKIP, visit } from 'estree-util-visit';
+import type { Element, RootContent, RootContentMap } from 'hast';
import { toHtml } from 'hast-util-to-html';
+import type { MdxJsxFlowElementHast, MdxJsxTextElementHast } from 'mdast-util-mdx-jsx';
-// accessing untyped hast and mdx types
-type Node = any;
+// This import includes ambient types for hast to include mdx nodes
+import type {} from 'mdast-util-mdx';
+
+// Alias as the main hast node
+type Node = RootContent;
+// Nodes that have the `children` property
+type ParentNode = Element | MdxJsxFlowElementHast | MdxJsxTextElementHast;
+// Nodes that can have its children optimized as a single HTML string
+type OptimizableNode = Element | MdxJsxFlowElementHast | MdxJsxTextElementHast;
export interface OptimizeOptions {
- customComponentNames?: string[];
+ ignoreElementNames?: string[];
+}
+
+interface ElementMetadata {
+ parent: ParentNode;
+ index: number;
}
const exportConstComponentsRe = /export\s+const\s+components\s*=/;
@@ -17,44 +32,57 @@ const exportConstComponentsRe = /export\s+const\s+components\s*=/;
* This optimization reduces the JS output as more content are represented as a
* string instead, which also reduces the AST size that Rollup holds in memory.
*/
-export function rehypeOptimizeStatic(options?: OptimizeOptions) {
- return (tree: any) => {
+export const rehypeOptimizeStatic: RehypePlugin<[OptimizeOptions?]> = (options) => {
+ return (tree) => {
// A set of non-static components to avoid collapsing when walking the tree
// as they need to be preserved as JSX to be rendered dynamically.
- const customComponentNames = new Set(options?.customComponentNames);
+ const ignoreElementNames = new Set(options?.ignoreElementNames);
// Find `export const components = { ... }` and get it's object's keys to be
- // populated into `customComponentNames`. This configuration is used to render
+ // populated into `ignoreElementNames`. This configuration is used to render
// some HTML elements as custom components, and we also want to avoid collapsing them.
for (const child of tree.children) {
if (child.type === 'mdxjsEsm' && exportConstComponentsRe.test(child.value)) {
- // Try to loosely get the object property nodes
- const objectPropertyNodes = child.data.estree.body[0]?.declarations?.[0]?.init?.properties;
- if (objectPropertyNodes) {
- for (const objectPropertyNode of objectPropertyNodes) {
- const componentName = objectPropertyNode.key?.name ?? objectPropertyNode.key?.value;
- if (componentName) {
- customComponentNames.add(componentName);
- }
+ const keys = getExportConstComponentObjectKeys(child);
+ if (keys) {
+ for (const key of keys) {
+ ignoreElementNames.add(key);
}
}
+ break;
}
}
// All possible elements that could be the root of a subtree
- const allPossibleElements = new Set();
+ const allPossibleElements = new Set();
// The current collapsible element stack while traversing the tree
const elementStack: Node[] = [];
+ // Metadata used by `findElementGroups` later
+ const elementMetadatas = new WeakMap();
+
+ /**
+ * A non-static node causes all its parents to be non-optimizable
+ */
+ const isNodeNonStatic = (node: Node) => {
+ // @ts-expect-error Access `.tagName` naively for perf
+ return node.type.startsWith('mdx') || ignoreElementNames.has(node.tagName);
+ };
+
+ visit(tree as any, {
+ // @ts-expect-error Force coerce node as hast node
+ enter(node: Node, key, index, parents: ParentNode[]) {
+ // `estree-util-visit` may traverse in MDX `attributes`, we don't want that. Only continue
+ // if it's traversing the root, or the `children` key.
+ if (key != null && key !== 'children') return SKIP;
+
+ // Mutate `node` as a normal hast element node if it's a plain MDX node, e.g. `something`
+ simplifyPlainMdxComponentNode(node, ignoreElementNames);
- visit(tree, {
- enter(node) {
- // @ts-expect-error read tagName naively
- const isCustomComponent = node.tagName && customComponentNames.has(node.tagName);
- // For nodes that can't be optimized, eliminate all elements in the
- // `elementStack` from the `allPossibleElements` set.
- if (node.type.startsWith('mdx') || isCustomComponent) {
+ // For nodes that are not static, eliminate all elements in the `elementStack` from the
+ // `allPossibleElements` set.
+ if (isNodeNonStatic(node)) {
for (const el of elementStack) {
- allPossibleElements.delete(el);
+ allPossibleElements.delete(el as OptimizableNode);
}
// Micro-optimization: While this destroys the meaning of an element
// stack for this node, things will still work but we won't repeatedly
@@ -64,17 +92,25 @@ export function rehypeOptimizeStatic(options?: OptimizeOptions) {
}
// For possible subtree root nodes, record them in `elementStack` and
// `allPossibleElements` to be used in the "leave" hook below.
- // @ts-expect-error MDX types for `.type` is not enhanced because MDX isn't used directly
- if (node.type === 'element' || node.type === 'mdxJsxFlowElement') {
+ if (node.type === 'element' || isMdxComponentNode(node)) {
elementStack.push(node);
allPossibleElements.add(node);
+
+ if (index != null && node.type === 'element') {
+ // Record metadata for element node to be used for grouping analysis later
+ elementMetadatas.set(node, { parent: parents[parents.length - 1], index });
+ }
}
},
- leave(node, _, __, parents) {
+ // @ts-expect-error Force coerce node as hast node
+ leave(node: Node, key, _, parents: ParentNode[]) {
+ // `estree-util-visit` may traverse in MDX `attributes`, we don't want that. Only continue
+ // if it's traversing the root, or the `children` key.
+ if (key != null && key !== 'children') return SKIP;
+
// Do the reverse of the if condition above, popping the `elementStack`,
// and consolidating `allPossibleElements` as a subtree root.
- // @ts-expect-error MDX types for `.type` is not enhanced because MDX isn't used directly
- if (node.type === 'element' || node.type === 'mdxJsxFlowElement') {
+ if (node.type === 'element' || isMdxComponentNode(node)) {
elementStack.pop();
// Many possible elements could be part of a subtree, in order to find
// the root, we check the parent of the element we're popping. If the
@@ -89,10 +125,18 @@ export function rehypeOptimizeStatic(options?: OptimizeOptions) {
},
});
+ // Within `allPossibleElements`, element nodes are often siblings and instead of setting `set:html`
+ // on each of the element node, we can create a `` element that includes
+ // all element nodes instead, simplifying the output.
+ const elementGroups = findElementGroups(allPossibleElements, elementMetadatas, isNodeNonStatic);
+
// For all possible subtree roots, collapse them into `set:html` and
// strip of their children
for (const el of allPossibleElements) {
- if (el.type === 'mdxJsxFlowElement') {
+ // Avoid adding empty `set:html` attributes if there's no children
+ if (el.children.length === 0) continue;
+
+ if (isMdxComponentNode(el)) {
el.attributes.push({
type: 'mdxJsxAttribute',
name: 'set:html',
@@ -103,5 +147,150 @@ export function rehypeOptimizeStatic(options?: OptimizeOptions) {
}
el.children = [];
}
+
+ // For each element group, we create a new `` MDX node with `set:html` of the children
+ // serialized as HTML. We insert this new fragment, replacing all the group children nodes.
+ // We iterate in reverse to avoid changing the index of groups of the same parent.
+ for (let i = elementGroups.length - 1; i >= 0; i--) {
+ const group = elementGroups[i];
+ const fragmentNode: MdxJsxFlowElementHast = {
+ type: 'mdxJsxFlowElement',
+ name: 'Fragment',
+ attributes: [
+ {
+ type: 'mdxJsxAttribute',
+ name: 'set:html',
+ value: toHtml(group.children),
+ },
+ ],
+ children: [],
+ };
+ group.parent.children.splice(group.startIndex, group.children.length, fragmentNode);
+ }
};
+};
+
+interface ElementGroup {
+ parent: ParentNode;
+ startIndex: number;
+ children: Node[];
+}
+
+/**
+ * Iterate through `allPossibleElements` and find elements that are siblings, and return them. `allPossibleElements`
+ * will be mutated to exclude these grouped elements.
+ */
+function findElementGroups(
+ allPossibleElements: Set,
+ elementMetadatas: WeakMap,
+ isNodeNonStatic: (node: Node) => boolean
+): ElementGroup[] {
+ const elementGroups: ElementGroup[] = [];
+
+ for (const el of allPossibleElements) {
+ // Non-static nodes can't be grouped. It can only optimize its static children.
+ if (isNodeNonStatic(el)) continue;
+
+ // Get the metadata for the element node, this should always exist
+ const metadata = elementMetadatas.get(el);
+ if (!metadata) {
+ throw new Error(
+ 'Internal MDX error: rehype-optimize-static should have metadata for element node'
+ );
+ }
+
+ // For this element, iterate through the next siblings and add them to this array
+ // if they are text nodes or elements that are in `allPossibleElements` (optimizable).
+ // If one of the next siblings don't match the criteria, break the loop as others are no longer siblings.
+ const groupableElements: Node[] = [el];
+ for (let i = metadata.index + 1; i < metadata.parent.children.length; i++) {
+ const node = metadata.parent.children[i];
+
+ // If the node is non-static, we can't group it with the current element
+ if (isNodeNonStatic(node)) break;
+
+ if (node.type === 'element') {
+ // This node is now (presumably) part of a group, remove it from `allPossibleElements`
+ const existed = allPossibleElements.delete(node);
+ // If this node didn't exist in `allPossibleElements`, it's likely that one of its children
+ // are non-static, hence this node can also not be grouped. So we break out here.
+ if (!existed) break;
+ }
+
+ groupableElements.push(node);
+ }
+
+ // If group elements are more than one, add them to the `elementGroups`.
+ // Grouping is most effective if there's multiple elements in it.
+ if (groupableElements.length > 1) {
+ elementGroups.push({
+ parent: metadata.parent,
+ startIndex: metadata.index,
+ children: groupableElements,
+ });
+ // The `el` is also now part of a group, remove it from `allPossibleElements`
+ allPossibleElements.delete(el);
+ }
+ }
+
+ return elementGroups;
+}
+
+function isMdxComponentNode(node: Node): node is MdxJsxFlowElementHast | MdxJsxTextElementHast {
+ return node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement';
+}
+
+/**
+ * Get the object keys from `export const components`
+ *
+ * @example
+ * `export const components = { foo, bar: Baz }`, returns `['foo', 'bar']`
+ */
+function getExportConstComponentObjectKeys(node: RootContentMap['mdxjsEsm']) {
+ const exportNamedDeclaration = node.data?.estree?.body[0];
+ if (exportNamedDeclaration?.type !== 'ExportNamedDeclaration') return;
+
+ const variableDeclaration = exportNamedDeclaration.declaration;
+ if (variableDeclaration?.type !== 'VariableDeclaration') return;
+
+ const variableInit = variableDeclaration.declarations[0]?.init;
+ if (variableInit?.type !== 'ObjectExpression') return;
+
+ const keys: string[] = [];
+ for (const propertyNode of variableInit.properties) {
+ if (propertyNode.type === 'Property' && propertyNode.key.type === 'Identifier') {
+ keys.push(propertyNode.key.name);
+ }
+ }
+ return keys;
+}
+
+/**
+ * Some MDX nodes are simply `something` which isn't needed to be completely treated
+ * as an MDX node. This function tries to mutate this node as a simple hast element node if so.
+ */
+function simplifyPlainMdxComponentNode(node: Node, ignoreElementNames: Set) {
+ if (
+ !isMdxComponentNode(node) ||
+ // Attributes could be dynamic, so bail if so.
+ node.attributes.length > 0 ||
+ // Fragments are also dynamic
+ !node.name ||
+ // Ignore if the node name is in the ignore list
+ ignoreElementNames.has(node.name) ||
+ // If the node name has uppercase characters, it's likely an actual MDX component
+ node.name.toLowerCase() !== node.name
+ ) {
+ return;
+ }
+
+ // Mutate as hast element node
+ const newNode = node as unknown as Element;
+ newNode.type = 'element';
+ newNode.tagName = node.name;
+ newNode.properties = {};
+
+ // @ts-expect-error Delete mdx-specific properties
+ node.attributes = undefined;
+ node.data = undefined;
}
diff --git a/packages/integrations/mdx/src/remark-images-to-component.ts b/packages/integrations/mdx/src/remark-images-to-component.ts
deleted file mode 100644
index 46d04d443341..000000000000
--- a/packages/integrations/mdx/src/remark-images-to-component.ts
+++ /dev/null
@@ -1,156 +0,0 @@
-import type { MarkdownVFile } from '@astrojs/markdown-remark';
-import type { Image, Parent } from 'mdast';
-import type { MdxJsxAttribute, MdxJsxFlowElement, MdxjsEsm } from 'mdast-util-mdx';
-import { visit } from 'unist-util-visit';
-import { jsToTreeNode } from './utils.js';
-
-export const ASTRO_IMAGE_ELEMENT = 'astro-image';
-export const ASTRO_IMAGE_IMPORT = '__AstroImage__';
-export const USES_ASTRO_IMAGE_FLAG = '__usesAstroImage';
-
-export function remarkImageToComponent() {
- return function (tree: any, file: MarkdownVFile) {
- if (!file.data.imagePaths) return;
-
- const importsStatements: MdxjsEsm[] = [];
- const importedImages = new Map();
-
- visit(tree, 'image', (node: Image, index: number | undefined, parent: Parent | null) => {
- // Use the imagePaths set from the remark-collect-images so we don't have to duplicate the logic for
- // checking if an image should be imported or not
- if (file.data.imagePaths?.has(node.url)) {
- let importName = importedImages.get(node.url);
-
- // If we haven't already imported this image, add an import statement
- if (!importName) {
- importName = `__${importedImages.size}_${node.url.replace(/\W/g, '_')}__`;
- importsStatements.push({
- type: 'mdxjsEsm',
- value: '',
- data: {
- estree: {
- type: 'Program',
- sourceType: 'module',
- body: [
- {
- type: 'ImportDeclaration',
- source: {
- type: 'Literal',
- value: node.url,
- raw: JSON.stringify(node.url),
- },
- specifiers: [
- {
- type: 'ImportDefaultSpecifier',
- local: { type: 'Identifier', name: importName },
- },
- ],
- },
- ],
- },
- },
- });
- importedImages.set(node.url, importName);
- }
-
- // Build a component that's equivalent to
- const componentElement: MdxJsxFlowElement = {
- name: ASTRO_IMAGE_ELEMENT,
- type: 'mdxJsxFlowElement',
- attributes: [
- {
- name: 'src',
- type: 'mdxJsxAttribute',
- value: {
- type: 'mdxJsxAttributeValueExpression',
- value: importName,
- data: {
- estree: {
- type: 'Program',
- sourceType: 'module',
- comments: [],
- body: [
- {
- type: 'ExpressionStatement',
- expression: { type: 'Identifier', name: importName },
- },
- ],
- },
- },
- },
- },
- { name: 'alt', type: 'mdxJsxAttribute', value: node.alt || '' },
- ],
- children: [],
- };
-
- if (node.title) {
- componentElement.attributes.push({
- type: 'mdxJsxAttribute',
- name: 'title',
- value: node.title,
- });
- }
-
- if (node.data && node.data.hProperties) {
- const createArrayAttribute = (name: string, values: string[]): MdxJsxAttribute => {
- return {
- type: 'mdxJsxAttribute',
- name: name,
- value: {
- type: 'mdxJsxAttributeValueExpression',
- value: name,
- data: {
- estree: {
- type: 'Program',
- body: [
- {
- type: 'ExpressionStatement',
- expression: {
- type: 'ArrayExpression',
- elements: values.map((value) => ({
- type: 'Literal',
- value: value,
- raw: String(value),
- })),
- },
- },
- ],
- sourceType: 'module',
- comments: [],
- },
- },
- },
- };
- };
- // Go through every hProperty and add it as an attribute of the
- Object.entries(node.data.hProperties as Record).forEach(
- ([key, value]) => {
- if (Array.isArray(value)) {
- componentElement.attributes.push(createArrayAttribute(key, value));
- } else {
- componentElement.attributes.push({
- name: key,
- type: 'mdxJsxAttribute',
- value: String(value),
- });
- }
- }
- );
- }
-
- parent!.children.splice(index!, 1, componentElement);
- }
- });
-
- // Add all the import statements to the top of the file for the images
- tree.children.unshift(...importsStatements);
-
- tree.children.unshift(
- jsToTreeNode(`import { Image as ${ASTRO_IMAGE_IMPORT} } from "astro:assets";`)
- );
- // Export `__usesAstroImage` to pick up `astro:assets` usage in the module graph.
- // @see the '@astrojs/mdx-postprocess' plugin
- tree.children.push(jsToTreeNode(`export const ${USES_ASTRO_IMAGE_FLAG} = true`));
- };
-}
diff --git a/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts b/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts
index c60504be6c9c..7661c0ecf874 100644
--- a/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts
+++ b/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts
@@ -5,24 +5,27 @@ import {
ASTRO_IMAGE_ELEMENT,
ASTRO_IMAGE_IMPORT,
USES_ASTRO_IMAGE_FLAG,
-} from './remark-images-to-component.js';
+} from './rehype-images-to-component.js';
import { type FileInfo, getFileInfo } from './utils.js';
+const underscoreFragmentImportRegex = /[\s,{]_Fragment[\s,}]/;
+const astroTagComponentImportRegex = /[\s,{]__astro_tag_component__[\s,}]/;
+
// These transforms must happen *after* JSX runtime transformations
export function vitePluginMdxPostprocess(astroConfig: AstroConfig): Plugin {
return {
name: '@astrojs/mdx-postprocess',
- transform(code, id) {
+ transform(code, id, opts) {
if (!id.endsWith('.mdx')) return;
const fileInfo = getFileInfo(id, astroConfig);
const [imports, exports] = parse(code);
// Call a series of functions that transform the code
- code = injectFragmentImport(code, imports);
+ code = injectUnderscoreFragmentImport(code, imports);
code = injectMetadataExports(code, exports, fileInfo);
code = transformContentExport(code, exports);
- code = annotateContentExport(code, id);
+ code = annotateContentExport(code, id, !!opts?.ssr, imports);
// The code transformations above are append-only, so the line/column mappings are the same
// and we can omit the sourcemap for performance.
@@ -31,23 +34,12 @@ export function vitePluginMdxPostprocess(astroConfig: AstroConfig): Plugin {
};
}
-const fragmentImportRegex = /[\s,{](?:Fragment,|Fragment\s*\})/;
-
/**
- * Inject `Fragment` identifier import if not already present. It should already be injected,
- * but check just to be safe.
- *
- * TODO: Double-check if we no longer need this function.
+ * Inject `Fragment` identifier import if not already present.
*/
-function injectFragmentImport(code: string, imports: readonly ImportSpecifier[]) {
- const importsFromJSXRuntime = imports
- .filter(({ n }) => n === 'astro/jsx-runtime')
- .map(({ ss, se }) => code.substring(ss, se));
- const hasFragmentImport = importsFromJSXRuntime.some((statement) =>
- fragmentImportRegex.test(statement)
- );
- if (!hasFragmentImport) {
- code = `import { Fragment } from "astro/jsx-runtime"\n` + code;
+function injectUnderscoreFragmentImport(code: string, imports: readonly ImportSpecifier[]) {
+ if (!isSpecifierImported(code, imports, underscoreFragmentImportRegex, 'astro/jsx-runtime')) {
+ code += `\nimport { Fragment as _Fragment } from 'astro/jsx-runtime';`;
}
return code;
}
@@ -81,7 +73,9 @@ function transformContentExport(code: string, exports: readonly ExportSpecifier[
const usesAstroImage = exports.find(({ n }) => n === USES_ASTRO_IMAGE_FLAG);
// Generate code for the `components` prop passed to `MDXContent`
- let componentsCode = `{ Fragment${hasComponents ? ', ...components' : ''}, ...props.components,`;
+ let componentsCode = `{ Fragment: _Fragment${
+ hasComponents ? ', ...components' : ''
+ }, ...props.components,`;
if (usesAstroImage) {
componentsCode += ` ${JSON.stringify(ASTRO_IMAGE_ELEMENT)}: ${
hasComponents ? 'components.img ?? ' : ''
@@ -103,7 +97,12 @@ export default Content;`;
/**
* Add properties to the `Content` export.
*/
-function annotateContentExport(code: string, id: string) {
+function annotateContentExport(
+ code: string,
+ id: string,
+ ssr: boolean,
+ imports: readonly ImportSpecifier[]
+) {
// Mark `Content` as MDX component
code += `\nContent[Symbol.for('mdx-component')] = true`;
// Ensure styles and scripts are injected into a `` when a layout is not applied
@@ -111,5 +110,39 @@ function annotateContentExport(code: string, id: string) {
// Assign the `moduleId` metadata to `Content`
code += `\nContent.moduleId = ${JSON.stringify(id)};`;
+ // Tag the `Content` export as "astro:jsx" so it's quicker to identify how to render this component
+ if (ssr) {
+ if (
+ !isSpecifierImported(
+ code,
+ imports,
+ astroTagComponentImportRegex,
+ 'astro/runtime/server/index.js'
+ )
+ ) {
+ code += `\nimport { __astro_tag_component__ } from 'astro/runtime/server/index.js';`;
+ }
+ code += `\n__astro_tag_component__(Content, 'astro:jsx');`;
+ }
+
return code;
}
+
+/**
+ * Check whether the `specifierRegex` matches for an import of `source` in the `code`.
+ */
+function isSpecifierImported(
+ code: string,
+ imports: readonly ImportSpecifier[],
+ specifierRegex: RegExp,
+ source: string
+) {
+ for (const imp of imports) {
+ if (imp.n !== source) continue;
+
+ const importStatement = code.slice(imp.ss, imp.se);
+ if (specifierRegex.test(importStatement)) return true;
+ }
+
+ return false;
+}
diff --git a/packages/integrations/mdx/src/vite-plugin-mdx.ts b/packages/integrations/mdx/src/vite-plugin-mdx.ts
index 6f2ec2cc487a..1b966ecd2a30 100644
--- a/packages/integrations/mdx/src/vite-plugin-mdx.ts
+++ b/packages/integrations/mdx/src/vite-plugin-mdx.ts
@@ -1,13 +1,13 @@
-import fs from 'node:fs/promises';
import { setVfileFrontmatter } from '@astrojs/markdown-remark';
-import type { AstroConfig, SSRError } from 'astro';
+import type { SSRError } from 'astro';
+import { getAstroMetadata } from 'astro/jsx/rehype.js';
import { VFile } from 'vfile';
import type { Plugin } from 'vite';
import type { MdxOptions } from './index.js';
import { createMdxProcessor } from './plugins.js';
-import { getFileInfo, parseFrontmatter } from './utils.js';
+import { parseFrontmatter } from './utils.js';
-export function vitePluginMdx(astroConfig: AstroConfig, mdxOptions: MdxOptions): Plugin {
+export function vitePluginMdx(mdxOptions: MdxOptions): Plugin {
let processor: ReturnType | undefined;
return {
@@ -17,21 +17,19 @@ export function vitePluginMdx(astroConfig: AstroConfig, mdxOptions: MdxOptions):
processor = undefined;
},
configResolved(resolved) {
+ // `mdxOptions` should be populated at this point, but `astro sync` doesn't call `astro:config:done` :(
+ // Workaround this for now by skipping here. `astro sync` shouldn't call the `transform()` hook here anyways.
+ if (Object.keys(mdxOptions).length === 0) return;
+
processor = createMdxProcessor(mdxOptions, {
sourcemap: !!resolved.build.sourcemap,
});
- // HACK: move ourselves before Astro's JSX plugin to transform things in the right order
+ // HACK: Remove the `astro:jsx` plugin if defined as we handle the JSX transformation ourselves
const jsxPluginIndex = resolved.plugins.findIndex((p) => p.name === 'astro:jsx');
if (jsxPluginIndex !== -1) {
- const myPluginIndex = resolved.plugins.findIndex((p) => p.name === '@mdx-js/rollup');
- if (myPluginIndex !== -1) {
- const myPlugin = resolved.plugins[myPluginIndex];
- // @ts-ignore-error ignore readonly annotation
- resolved.plugins.splice(myPluginIndex, 1);
- // @ts-ignore-error ignore readonly annotation
- resolved.plugins.splice(jsxPluginIndex, 0, myPlugin);
- }
+ // @ts-ignore-error ignore readonly annotation
+ resolved.plugins.splice(jsxPluginIndex, 1);
}
},
async resolveId(source, importer, options) {
@@ -43,13 +41,9 @@ export function vitePluginMdx(astroConfig: AstroConfig, mdxOptions: MdxOptions):
},
// Override transform to alter code before MDX compilation
// ex. inject layouts
- async transform(_, id) {
+ async transform(code, id) {
if (!id.endsWith('.mdx')) return;
- // Read code from file manually to prevent Vite from parsing `import.meta.env` expressions
- const { fileId } = getFileInfo(id, astroConfig);
- const code = await fs.readFile(fileId, 'utf-8');
-
const { data: frontmatter, content: pageContent } = parseFrontmatter(code, id);
const vfile = new VFile({ value: pageContent, path: id });
@@ -70,13 +64,14 @@ export function vitePluginMdx(astroConfig: AstroConfig, mdxOptions: MdxOptions):
return {
code: String(compiled.value),
map: compiled.map,
+ meta: getMdxMeta(vfile),
};
} catch (e: any) {
const err: SSRError = e;
// For some reason MDX puts the error location in the error's name, not very useful for us.
err.name = 'MDXError';
- err.loc = { file: fileId, line: e.line, column: e.column };
+ err.loc = { file: id, line: e.line, column: e.column };
// For another some reason, MDX doesn't include a stack trace. Weird
Error.captureStackTrace(err);
@@ -86,3 +81,20 @@ export function vitePluginMdx(astroConfig: AstroConfig, mdxOptions: MdxOptions):
},
};
}
+
+function getMdxMeta(vfile: VFile): Record {
+ const astroMetadata = getAstroMetadata(vfile);
+ if (!astroMetadata) {
+ throw new Error(
+ 'Internal MDX error: Astro metadata is not set by rehype-analyze-astro-metadata'
+ );
+ }
+ return {
+ astro: astroMetadata,
+ vite: {
+ // Setting this vite metadata to `ts` causes Vite to resolve .js
+ // extensions to .ts files.
+ lang: 'ts',
+ },
+ };
+}
diff --git a/packages/integrations/mdx/test/fixtures/mdx-optimize/astro.config.mjs b/packages/integrations/mdx/test/fixtures/mdx-optimize/astro.config.mjs
index b92b48617c28..204549479f5d 100644
--- a/packages/integrations/mdx/test/fixtures/mdx-optimize/astro.config.mjs
+++ b/packages/integrations/mdx/test/fixtures/mdx-optimize/astro.config.mjs
@@ -3,7 +3,7 @@ import mdx from '@astrojs/mdx';
export default {
integrations: [mdx({
optimize: {
- customComponentNames: ['strong']
+ ignoreElementNames: ['strong']
}
})]
}
diff --git a/packages/integrations/mdx/test/mdx-plugins.test.js b/packages/integrations/mdx/test/mdx-plugins.test.js
index 6b15884fb712..6bc8e096c268 100644
--- a/packages/integrations/mdx/test/mdx-plugins.test.js
+++ b/packages/integrations/mdx/test/mdx-plugins.test.js
@@ -64,6 +64,30 @@ describe('MDX plugins', () => {
assert.notEqual(selectRehypeExample(document), null);
});
+ it('supports custom rehype plugins from integrations', async () => {
+ const fixture = await buildFixture({
+ integrations: [
+ mdx(),
+ {
+ name: 'test',
+ hooks: {
+ 'astro:config:setup': ({ updateConfig }) => {
+ updateConfig({
+ markdown: {
+ rehypePlugins: [rehypeExamplePlugin],
+ },
+ });
+ },
+ },
+ },
+ ],
+ });
+ const html = await fixture.readFile(FILE);
+ const { document } = parseHTML(html);
+
+ assert.notEqual(selectRehypeExample(document), null);
+ });
+
it('supports custom rehype plugins with namespaced attributes', async () => {
const fixture = await buildFixture({
integrations: [
diff --git a/packages/integrations/mdx/test/units/rehype-optimize-static.test.js b/packages/integrations/mdx/test/units/rehype-optimize-static.test.js
new file mode 100644
index 000000000000..132f3849f5bf
--- /dev/null
+++ b/packages/integrations/mdx/test/units/rehype-optimize-static.test.js
@@ -0,0 +1,89 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { compile as _compile } from '@mdx-js/mdx';
+import { rehypeOptimizeStatic } from '../../dist/rehype-optimize-static.js';
+
+/**
+ * @param {string} mdxCode
+ * @param {Readonly} options
+ */
+async function compile(mdxCode, options) {
+ const result = await _compile(mdxCode, {
+ jsx: true,
+ rehypePlugins: [rehypeOptimizeStatic],
+ ...options,
+ });
+ const code = result.toString();
+ // Capture the returned JSX code for testing
+ const jsx = code.match(/return (.+);\n\}\nexport default function MDXContent/s)?.[1];
+ if (jsx == null) throw new Error('Could not find JSX code in compiled MDX');
+ return dedent(jsx);
+}
+
+function dedent(str) {
+ const lines = str.split('\n');
+ if (lines.length <= 1) return str;
+ // Get last line indent, and dedent this amount for the other lines
+ const lastLineIndent = lines[lines.length - 1].match(/^\s*/)[0].length;
+ return lines.map((line, i) => (i === 0 ? line : line.slice(lastLineIndent))).join('\n');
+}
+
+describe('rehype-optimize-static', () => {
+ it('works', async () => {
+ const jsx = await compile(`# hello`);
+ assert.equal(
+ jsx,
+ `\
+<_components.h1 {...{
+ "set:html": "hello"
+}} />`
+ );
+ });
+
+ it('groups sibling nodes as a single Fragment', async () => {
+ const jsx = await compile(`\
+# hello
+
+foo bar
+`);
+ assert.equal(
+ jsx,
+ `\
+`
+ );
+ });
+
+ it('skips optimization of components', async () => {
+ const jsx = await compile(`\
+import Comp from './Comp.jsx';
+
+# hello
+
+This is a
+`);
+ assert.equal(
+ jsx,
+ `\
+<><_components.p>{"This is a "}>`
+ );
+ });
+
+ it('optimizes explicit html elements', async () => {
+ const jsx = await compile(`\
+# hello
+
+foo bar baz
+
+qux
+`);
+ assert.equal(
+ jsx,
+ `\
+`
+ );
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 225e8e10ec66..43aa94a54d14 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -775,6 +775,12 @@ importers:
eol:
specifier: ^0.9.1
version: 0.9.1
+ mdast-util-mdx:
+ specifier: ^3.0.0
+ version: 3.0.0
+ mdast-util-mdx-jsx:
+ specifier: ^3.1.2
+ version: 3.1.2
memfs:
specifier: ^4.9.1
version: 4.9.1
@@ -4426,6 +4432,9 @@ importers:
'@types/estree':
specifier: ^1.0.5
version: 1.0.5
+ '@types/hast':
+ specifier: ^3.0.3
+ version: 3.0.4
'@types/mdast':
specifier: ^4.0.3
version: 4.0.3
@@ -4447,6 +4456,9 @@ importers:
mdast-util-mdx:
specifier: ^3.0.0
version: 3.0.0
+ mdast-util-mdx-jsx:
+ specifier: ^3.1.2
+ version: 3.1.2
mdast-util-to-string:
specifier: ^4.0.0
version: 4.0.0