[MDX] Support img component prop for optimized images (#8468)

Co-authored-by: Erika <3019731+Princesseuh@users.noreply.github.com>
This commit is contained in:
Ben Holmes 2023-09-13 12:27:03 -04:00 committed by GitHub
parent ecc65abbf9
commit a8d72ceaee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 177 additions and 4 deletions

View file

@ -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' ? (
<img class="custom-styles" src={src} alt={alt} />
) : (
<Image class="custom-styles" {src} {alt} />
)
}
<style>
.custom-styles {
border: 1px solid red;
}
</style>
```
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
```

View file

@ -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<typeof markdownConfigDefaults, 'remarkPlugins' | 'rehypePlugins'> & {
extendMarkdownConfig: boolean;
@ -194,12 +195,19 @@ export default function mdx(partialMdxOptions: Partial<MdxOptions> = {}): 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;`;
}

View file

@ -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 <Image src={importName} alt={node.alt} title={node.title} />
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`));
};
}

View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

View file

@ -0,0 +1,5 @@
Optimized image:
![Houston](../assets/houston.webp)
Public image:
![Astro logo](/favicon.svg)

View file

@ -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' ? (
<img data-my-image src={src} alt={alt} />
) : (
<Image data-my-image {src} {alt} />
)
}
<style>
[data-my-image] {
border: 1px solid red;
}
</style>

View file

@ -0,0 +1,5 @@
Optimized image:
![Houston](../../assets/houston.webp)
Public image:
![Astro logo](/favicon.svg)

View file

@ -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();
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Renderer</title>
</head>
<body>
<Content components={{ img: MyImage }} />
</body>
</html>

View file

@ -0,0 +1,16 @@
---
import MDX from '../components/Component.mdx';
import MyImage from 'src/components/MyImage.astro';
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Renderer</title>
</head>
<body>
<MDX components={{ img: MyImage }} />
</body>
</html>

View file

@ -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)

View file

@ -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;
});
}
});
});