[MDX] Support img
component prop for optimized images (#8468)
Co-authored-by: Erika <3019731+Princesseuh@users.noreply.github.com>
This commit is contained in:
parent
ecc65abbf9
commit
a8d72ceaee
11 changed files with 177 additions and 4 deletions
48
.changeset/cyan-penguins-divide.md
Normal file
48
.changeset/cyan-penguins-divide.md
Normal 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
|
||||
```
|
|
@ -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;`;
|
||||
}
|
||||
|
|
|
@ -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`));
|
||||
};
|
||||
}
|
||||
|
|
9
packages/integrations/mdx/test/fixtures/mdx-images/public/favicon.svg
vendored
Normal file
9
packages/integrations/mdx/test/fixtures/mdx-images/public/favicon.svg
vendored
Normal 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 |
5
packages/integrations/mdx/test/fixtures/mdx-images/src/components/Component.mdx
vendored
Normal file
5
packages/integrations/mdx/test/fixtures/mdx-images/src/components/Component.mdx
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
Optimized image:
|
||||
![Houston](../assets/houston.webp)
|
||||
|
||||
Public image:
|
||||
![Astro logo](/favicon.svg)
|
25
packages/integrations/mdx/test/fixtures/mdx-images/src/components/MyImage.astro
vendored
Normal file
25
packages/integrations/mdx/test/fixtures/mdx-images/src/components/MyImage.astro
vendored
Normal 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>
|
5
packages/integrations/mdx/test/fixtures/mdx-images/src/content/blog/entry.mdx
vendored
Normal file
5
packages/integrations/mdx/test/fixtures/mdx-images/src/content/blog/entry.mdx
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
Optimized image:
|
||||
![Houston](../../assets/houston.webp)
|
||||
|
||||
Public image:
|
||||
![Astro logo](/favicon.svg)
|
19
packages/integrations/mdx/test/fixtures/mdx-images/src/pages/content-collection.astro
vendored
Normal file
19
packages/integrations/mdx/test/fixtures/mdx-images/src/pages/content-collection.astro
vendored
Normal 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>
|
16
packages/integrations/mdx/test/fixtures/mdx-images/src/pages/esm-import.astro
vendored
Normal file
16
packages/integrations/mdx/test/fixtures/mdx-images/src/pages/esm-import.astro
vendored
Normal 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>
|
9
packages/integrations/mdx/test/fixtures/mdx-images/src/pages/with-components.mdx
vendored
Normal file
9
packages/integrations/mdx/test/fixtures/mdx-images/src/pages/with-components.mdx
vendored
Normal 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)
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue