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' + *