Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 5 commits into from
Sep 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .changeset/cyan-penguins-divide.md
Original file line number Diff line number Diff line change
@@ -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
```
10 changes: 9 additions & 1 deletion packages/integrations/mdx/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;`;
}
Expand Down
12 changes: 9 additions & 3 deletions packages/integrations/mdx/src/remark-images-to-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: [
{
Expand Down Expand Up @@ -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`));
};
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Optimized image:
![Houston](../assets/houston.webp)

Public image:
![Astro logo](/favicon.svg)
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Optimized image:
![Houston](../../assets/houston.webp)

Public image:
![Astro logo](/favicon.svg)
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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)
23 changes: 23 additions & 0 deletions packages/integrations/mdx/test/mdx-images.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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