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' ? (
+
+ ) : (
+
+ )
+}
+
+
+```
+
+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
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' ? (
+
+ ) : (
+
+ )
+}
+
+
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;
+ });
+ }
});
});