diff --git a/.changeset/cyan-penguins-divide.md b/.changeset/cyan-penguins-divide.md new file mode 100644 index 000000000..0d889f9a2 --- /dev/null +++ b/.changeset/cyan-penguins-divide.md @@ -0,0 +1,48 @@ +--- +'@astrojs/mdx': minor +--- + +Support the `img` component export for optimized images. This allows you to customize how optimized images are styled and rendered. + +When rendering an optimized image, Astro will pass the `ImageMetadata` object to your `img` component as the `src` prop. For unoptimized images (i.e. images using URLs or absolute paths), Astro will continue to pass the `src` as a string. + +This example handles both cases and applies custom styling: + +```astro +--- +// src/components/MyImage.astro +import type { ImageMetadata } from 'astro'; +import { Image } from 'astro:assets'; + +type Props = { + src: string | ImageMetadata; + alt: string; +}; + +const { src, alt } = Astro.props; +--- + +{ + typeof src === 'string' ? ( + {alt} + ) : ( + + ) +} + + +``` + +Now, this components can be applied to the `img` component props object or file export: + +```md +import MyImage from '../../components/MyImage.astro'; + +export const components = { img: MyImage }; + +# My MDX article +``` diff --git a/packages/integrations/mdx/src/index.ts b/packages/integrations/mdx/src/index.ts index c27abb4d1..98390b1be 100644 --- a/packages/integrations/mdx/src/index.ts +++ b/packages/integrations/mdx/src/index.ts @@ -14,6 +14,7 @@ import type { Plugin as VitePlugin } from 'vite'; import { getRehypePlugins, getRemarkPlugins, recmaInjectImportMetaEnvPlugin } from './plugins.js'; import type { OptimizeOptions } from './rehype-optimize-static.js'; import { getFileInfo, ignoreStringPlugins, parseFrontmatter } from './utils.js'; +import { ASTRO_IMAGE_ELEMENT, ASTRO_IMAGE_IMPORT, USES_ASTRO_IMAGE_FLAG } from './remark-images-to-component.js'; export type MdxOptions = Omit & { extendMarkdownConfig: boolean; @@ -194,12 +195,19 @@ export default function mdx(partialMdxOptions: Partial = {}): AstroI 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 MDXContent;', ''); code += `\nexport const Content = (props = {}) => MDXContent({ ...props, - components: { Fragment${hasComponents ? ', ...components' : ''}, ...props.components }, + components: ${componentsCode}, }); export default Content;`; } diff --git a/packages/integrations/mdx/src/remark-images-to-component.ts b/packages/integrations/mdx/src/remark-images-to-component.ts index bb9657f42..40d414b5c 100644 --- a/packages/integrations/mdx/src/remark-images-to-component.ts +++ b/packages/integrations/mdx/src/remark-images-to-component.ts @@ -4,6 +4,10 @@ import type { 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; @@ -48,7 +52,7 @@ export function remarkImageToComponent() { // Build a component that's equivalent to {node.alt} const componentElement: MdxJsxFlowElement = { - name: '__AstroImage__', + name: ASTRO_IMAGE_ELEMENT, type: 'mdxJsxFlowElement', attributes: [ { @@ -92,7 +96,9 @@ export function remarkImageToComponent() { // Add all the import statements to the top of the file for the images tree.children.unshift(...importsStatements); - // Add an import statement for the Astro Image component, we rename it to avoid conflicts - tree.children.unshift(jsToTreeNode(`import { Image as __AstroImage__ } from "astro:assets";`)); + 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/test/fixtures/mdx-images/public/favicon.svg b/packages/integrations/mdx/test/fixtures/mdx-images/public/favicon.svg new file mode 100644 index 000000000..f157bd1c5 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-images/public/favicon.svg @@ -0,0 +1,9 @@ + + + + diff --git a/packages/integrations/mdx/test/fixtures/mdx-images/src/components/Component.mdx b/packages/integrations/mdx/test/fixtures/mdx-images/src/components/Component.mdx new file mode 100644 index 000000000..7463939ba --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-images/src/components/Component.mdx @@ -0,0 +1,5 @@ +Optimized image: +![Houston](../assets/houston.webp) + +Public image: +![Astro logo](/favicon.svg) diff --git a/packages/integrations/mdx/test/fixtures/mdx-images/src/components/MyImage.astro b/packages/integrations/mdx/test/fixtures/mdx-images/src/components/MyImage.astro new file mode 100644 index 000000000..e3541867c --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-images/src/components/MyImage.astro @@ -0,0 +1,25 @@ +--- +import type { ImageMetadata } from 'astro'; +import { Image } from 'astro:assets'; + +type Props = { + src: string | ImageMetadata; + alt: string; +}; + +const { src, alt } = Astro.props; +--- + +{ + typeof src === 'string' ? ( + {alt} + ) : ( + + ) +} + + diff --git a/packages/integrations/mdx/test/fixtures/mdx-images/src/content/blog/entry.mdx b/packages/integrations/mdx/test/fixtures/mdx-images/src/content/blog/entry.mdx new file mode 100644 index 000000000..58aebcf54 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-images/src/content/blog/entry.mdx @@ -0,0 +1,5 @@ +Optimized image: +![Houston](../../assets/houston.webp) + +Public image: +![Astro logo](/favicon.svg) diff --git a/packages/integrations/mdx/test/fixtures/mdx-images/src/pages/content-collection.astro b/packages/integrations/mdx/test/fixtures/mdx-images/src/pages/content-collection.astro new file mode 100644 index 000000000..63d068b5c --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-images/src/pages/content-collection.astro @@ -0,0 +1,19 @@ +--- +import { getEntry } from 'astro:content'; +import MyImage from 'src/components/MyImage.astro'; + +const entry = await getEntry('blog', 'entry'); +const { Content } = await entry.render(); +--- + + + + + + + Renderer + + + + + diff --git a/packages/integrations/mdx/test/fixtures/mdx-images/src/pages/esm-import.astro b/packages/integrations/mdx/test/fixtures/mdx-images/src/pages/esm-import.astro new file mode 100644 index 000000000..e5f7a61d9 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-images/src/pages/esm-import.astro @@ -0,0 +1,16 @@ +--- +import MDX from '../components/Component.mdx'; +import MyImage from 'src/components/MyImage.astro'; +--- + + + + + + + Renderer + + + + + diff --git a/packages/integrations/mdx/test/fixtures/mdx-images/src/pages/with-components.mdx b/packages/integrations/mdx/test/fixtures/mdx-images/src/pages/with-components.mdx new file mode 100644 index 000000000..763256b1c --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-images/src/pages/with-components.mdx @@ -0,0 +1,9 @@ +import MyImage from '../components/MyImage.astro'; + +export const components = { img: MyImage }; + +Optimized image: +![Houston](../assets/houston.webp) + +Public image: +![Astro logo](/favicon.svg) diff --git a/packages/integrations/mdx/test/mdx-images.test.js b/packages/integrations/mdx/test/mdx-images.test.js index c9c8e1f7c..128a2fcb0 100644 --- a/packages/integrations/mdx/test/mdx-images.test.js +++ b/packages/integrations/mdx/test/mdx-images.test.js @@ -2,6 +2,8 @@ import { expect } from 'chai'; import { parseHTML } from 'linkedom'; import { loadFixture } from '../../../astro/test/test-utils.js'; +const imageTestRoutes = ['with-components', 'esm-import', 'content-collection'] + describe('MDX Page', () => { let devServer; let fixture; @@ -36,5 +38,26 @@ describe('MDX Page', () => { // Image with spaces in the path expect(imgs.item(3).src.startsWith('/_image')).to.be.true; }); + + for (const route of imageTestRoutes) { + it(`supports img component - ${route}`, async () => { + const res = await fixture.fetch(`/${route}`); + expect(res.status).to.equal(200); + + const html = await res.text(); + const { document } = parseHTML(html); + + const imgs = document.getElementsByTagName('img'); + expect(imgs.length).to.equal(2); + + const assetsImg = imgs.item(0); + expect(assetsImg.src.startsWith('/_image')).to.be.true; + expect(assetsImg.hasAttribute('data-my-image')).to.be.true; + + const publicImg = imgs.item(1); + expect(publicImg.src).to.equal('/favicon.svg'); + expect(publicImg.hasAttribute('data-my-image')).to.be.true; + }); + } }); });