diff --git a/packages/integrations/mdx/src/index.ts b/packages/integrations/mdx/src/index.ts index db7f6413451f..e48d0d8f1979 100644 --- a/packages/integrations/mdx/src/index.ts +++ b/packages/integrations/mdx/src/index.ts @@ -3,19 +3,14 @@ import { fileURLToPath } from 'node:url'; import { markdownConfigDefaults, setVfileFrontmatter } from '@astrojs/markdown-remark'; import type { AstroIntegration, ContentEntryType, HookParameters, SSRError } from 'astro'; import astroJSXRenderer from 'astro/jsx/renderer.js'; -import { parse as parseESM } from 'es-module-lexer'; import type { Options as RemarkRehypeOptions } from 'remark-rehype'; import type { PluggableList } from 'unified'; import { VFile } from 'vfile'; import type { Plugin as VitePlugin } from 'vite'; import { createMdxProcessor } from './plugins.js'; import type { OptimizeOptions } from './rehype-optimize-static.js'; -import { - ASTRO_IMAGE_ELEMENT, - ASTRO_IMAGE_IMPORT, - USES_ASTRO_IMAGE_FLAG, -} from './remark-images-to-component.js'; import { getFileInfo, ignoreStringPlugins, parseFrontmatter } from './utils.js'; +import { vitePluginMdxPostprocess } from './vite-plugin-mdx-postprocess.js'; export type MdxOptions = Omit & { extendMarkdownConfig: boolean; @@ -157,72 +152,7 @@ export default function mdx(partialMdxOptions: Partial = {}): AstroI } }, }, - { - name: '@astrojs/mdx-postprocess', - // These transforms must happen *after* JSX runtime transformations - transform(code, id) { - if (!id.endsWith('.mdx')) return; - - const [moduleImports, moduleExports] = parseESM(code); - - // Fragment import should already be injected, but check just to be safe. - const importsFromJSXRuntime = moduleImports - .filter(({ n }) => n === 'astro/jsx-runtime') - .map(({ ss, se }) => code.substring(ss, se)); - const hasFragmentImport = importsFromJSXRuntime.some((statement) => - /[\s,{](?:Fragment,|Fragment\s*\})/.test(statement) - ); - if (!hasFragmentImport) { - code = 'import { Fragment } from "astro/jsx-runtime"\n' + code; - } - - const { fileUrl, fileId } = getFileInfo(id, config); - if (!moduleExports.find(({ n }) => n === 'url')) { - code += `\nexport const url = ${JSON.stringify(fileUrl)};`; - } - if (!moduleExports.find(({ n }) => n === 'file')) { - code += `\nexport const file = ${JSON.stringify(fileId)};`; - } - if (!moduleExports.find(({ n }) => n === 'Content')) { - // If have `export const components`, pass that as props to `Content` as fallback - const hasComponents = moduleExports.find(({ n }) => n === 'components'); - const usesAstroImage = moduleExports.find( - ({ n }) => n === USES_ASTRO_IMAGE_FLAG - ); - - let componentsCode = `{ Fragment${ - hasComponents ? ', ...components' : '' - }, ...props.components,`; - if (usesAstroImage) { - componentsCode += ` ${JSON.stringify(ASTRO_IMAGE_ELEMENT)}: ${ - hasComponents ? 'components.img ?? ' : '' - } props.components?.img ?? ${ASTRO_IMAGE_IMPORT}`; - } - componentsCode += ' }'; - - // Make `Content` the default export so we can wrap `MDXContent` and pass in `Fragment` - code = code.replace( - 'export default function MDXContent', - 'function MDXContent' - ); - code += `\nexport const Content = (props = {}) => MDXContent({ - ...props, - components: ${componentsCode}, - }); - export default Content;`; - } - - // mark the component as an MDX component - code += `\nContent[Symbol.for('mdx-component')] = true`; - - // Ensures styles and scripts are injected into a `` - // When a layout is not applied - code += `\nContent[Symbol.for('astro.needsHeadRendering')] = !Boolean(frontmatter.layout);`; - code += `\nContent.moduleId = ${JSON.stringify(id)};`; - - return { code, map: null }; - }, - }, + vitePluginMdxPostprocess(config), ] as VitePlugin[], }, }); diff --git a/packages/integrations/mdx/src/utils.ts b/packages/integrations/mdx/src/utils.ts index 199929dc872f..9adf3586f417 100644 --- a/packages/integrations/mdx/src/utils.ts +++ b/packages/integrations/mdx/src/utils.ts @@ -10,7 +10,7 @@ function appendForwardSlash(path: string) { return path.endsWith('/') ? path : path + '/'; } -interface FileInfo { +export interface FileInfo { fileId: string; fileUrl: string; } diff --git a/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts b/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts new file mode 100644 index 000000000000..c60504be6c9c --- /dev/null +++ b/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts @@ -0,0 +1,115 @@ +import type { AstroConfig } from 'astro'; +import { type ExportSpecifier, type ImportSpecifier, parse } from 'es-module-lexer'; +import type { Plugin } from 'vite'; +import { + ASTRO_IMAGE_ELEMENT, + ASTRO_IMAGE_IMPORT, + USES_ASTRO_IMAGE_FLAG, +} from './remark-images-to-component.js'; +import { type FileInfo, getFileInfo } from './utils.js'; + +// These transforms must happen *after* JSX runtime transformations +export function vitePluginMdxPostprocess(astroConfig: AstroConfig): Plugin { + return { + name: '@astrojs/mdx-postprocess', + transform(code, id) { + 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 = injectMetadataExports(code, exports, fileInfo); + code = transformContentExport(code, exports); + code = annotateContentExport(code, id); + + // The code transformations above are append-only, so the line/column mappings are the same + // and we can omit the sourcemap for performance. + return { code, map: null }; + }, + }; +} + +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. + */ +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; + } + return code; +} + +/** + * Inject MDX metadata as exports of the module. + */ +function injectMetadataExports( + code: string, + exports: readonly ExportSpecifier[], + fileInfo: FileInfo +) { + if (!exports.some(({ n }) => n === 'url')) { + code += `\nexport const url = ${JSON.stringify(fileInfo.fileUrl)};`; + } + if (!exports.some(({ n }) => n === 'file')) { + code += `\nexport const file = ${JSON.stringify(fileInfo.fileId)};`; + } + return code; +} + +/** + * Transforms the `MDXContent` default export as `Content`, which wraps `MDXContent` and + * passes additional `components` props. + */ +function transformContentExport(code: string, exports: readonly ExportSpecifier[]) { + if (exports.find(({ n }) => n === 'Content')) return code; + + // If have `export const components`, pass that as props to `Content` as fallback + const hasComponents = exports.find(({ n }) => n === 'components'); + 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,`; + if (usesAstroImage) { + componentsCode += ` ${JSON.stringify(ASTRO_IMAGE_ELEMENT)}: ${ + hasComponents ? 'components.img ?? ' : '' + } props.components?.img ?? ${ASTRO_IMAGE_IMPORT}`; + } + componentsCode += ' }'; + + // Make `Content` the default export so we can wrap `MDXContent` and pass in `Fragment` + code = code.replace('export default function MDXContent', 'function MDXContent'); + code += ` +export const Content = (props = {}) => MDXContent({ + ...props, + components: ${componentsCode}, +}); +export default Content;`; + return code; +} + +/** + * Add properties to the `Content` export. + */ +function annotateContentExport(code: string, id: string) { + // 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 + code += `\nContent[Symbol.for('astro.needsHeadRendering')] = !Boolean(frontmatter.layout);`; + // Assign the `moduleId` metadata to `Content` + code += `\nContent.moduleId = ${JSON.stringify(id)};`; + + return code; +}