Skip to content

Commit 2511d58

Browse files
authored
feat(mdx): Add support for turning ![]() into <Image> (#6824)
1 parent 948a6d7 commit 2511d58

12 files changed

Lines changed: 195 additions & 7 deletions

File tree

.changeset/giant-squids-pull.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@astrojs/mdx': minor
3+
'@astrojs/markdown-remark': patch
4+
---
5+
6+
Add support for using optimized and relative images in MDX files with `experimental.assets`

packages/integrations/mdx/src/plugins.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { rehypeHeadingIds } from '@astrojs/markdown-remark';
1+
import { rehypeHeadingIds, remarkCollectImages } from '@astrojs/markdown-remark';
22
import {
33
InvalidAstroDataError,
44
safelyGetAstroData,
@@ -16,6 +16,7 @@ import type { VFile } from 'vfile';
1616
import type { MdxOptions } from './index.js';
1717
import { rehypeInjectHeadingsExport } from './rehype-collect-headings.js';
1818
import rehypeMetaString from './rehype-meta-string.js';
19+
import { remarkImageToComponent } from './remark-images-to-component.js';
1920
import remarkPrism from './remark-prism.js';
2021
import remarkShiki from './remark-shiki.js';
2122
import { jsToTreeNode } from './utils.js';
@@ -99,7 +100,7 @@ export async function getRemarkPlugins(
99100
mdxOptions: MdxOptions,
100101
config: AstroConfig
101102
): Promise<MdxRollupPluginOptions['remarkPlugins']> {
102-
let remarkPlugins: PluggableList = [];
103+
let remarkPlugins: PluggableList = [...(config.experimental.assets ? [remarkCollectImages, remarkImageToComponent] : [])];
103104

104105
if (!isPerformanceBenchmark) {
105106
if (mdxOptions.gfm) {
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import type { MarkdownVFile } from '@astrojs/markdown-remark';
2+
import { type Image, type Parent } from 'mdast';
3+
import type { MdxJsxFlowElement, MdxjsEsm } from 'mdast-util-mdx';
4+
import { visit } from 'unist-util-visit';
5+
import { jsToTreeNode } from './utils.js';
6+
7+
export function remarkImageToComponent() {
8+
return function (tree: any, file: MarkdownVFile) {
9+
if (!file.data.imagePaths) return;
10+
11+
const importsStatements: MdxjsEsm[] = [];
12+
const importedImages = new Map<string, string>();
13+
14+
visit(tree, 'image', (node: Image, index: number | null, parent: Parent | null) => {
15+
// Use the imagePaths set from the remark-collect-images so we don't have to duplicate the logic for
16+
// checking if an image should be imported or not
17+
if (file.data.imagePaths?.has(node.url)) {
18+
let importName = importedImages.get(node.url);
19+
20+
// If we haven't already imported this image, add an import statement
21+
if (!importName) {
22+
importName = `__${importedImages.size}_${node.url.replace(/\W/g, '_')}__`;
23+
24+
importsStatements.push({
25+
type: 'mdxjsEsm',
26+
value: '',
27+
data: {
28+
estree: {
29+
type: 'Program',
30+
sourceType: 'module',
31+
body: [
32+
{
33+
type: 'ImportDeclaration',
34+
source: { type: 'Literal', value: node.url, raw: JSON.stringify(node.url) },
35+
specifiers: [
36+
{
37+
type: 'ImportDefaultSpecifier',
38+
local: { type: 'Identifier', name: importName },
39+
},
40+
],
41+
},
42+
],
43+
},
44+
},
45+
});
46+
importedImages.set(node.url, importName);
47+
}
48+
49+
// Build a component that's equivalent to <Image src={importName} alt={node.alt} title={node.title} />
50+
const componentElement: MdxJsxFlowElement = {
51+
name: '__AstroImage__',
52+
type: 'mdxJsxFlowElement',
53+
attributes: [
54+
{
55+
name: 'src',
56+
type: 'mdxJsxAttribute',
57+
value: {
58+
type: 'mdxJsxAttributeValueExpression',
59+
value: importName,
60+
data: {
61+
estree: {
62+
type: 'Program',
63+
sourceType: 'module',
64+
comments: [],
65+
body: [
66+
{
67+
type: 'ExpressionStatement',
68+
expression: { type: 'Identifier', name: importName },
69+
},
70+
],
71+
},
72+
},
73+
},
74+
},
75+
{ name: 'alt', type: 'mdxJsxAttribute', value: node.alt || '' },
76+
],
77+
children: [],
78+
};
79+
80+
if (node.title) {
81+
componentElement.attributes.push({
82+
type: 'mdxJsxAttribute',
83+
name: 'title',
84+
value: node.title,
85+
});
86+
}
87+
88+
parent!.children.splice(index!, 1, componentElement);
89+
}
90+
});
91+
92+
// Add all the import statements to the top of the file for the images
93+
tree.children.unshift(...importsStatements);
94+
95+
// Add an import statement for the Astro Image component, we rename it to avoid conflicts
96+
tree.children.unshift(jsToTreeNode(`import { Image as __AstroImage__ } from "astro:assets";`));
97+
};
98+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import mdx from '@astrojs/mdx';
2+
3+
export default {
4+
integrations: [mdx()],
5+
experimental: {
6+
assets: true
7+
}
8+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "@test/mdx-page",
3+
"dependencies": {
4+
"@astrojs/mdx": "workspace:*",
5+
"astro": "workspace:*",
6+
"react": "^18.2.0",
7+
"react-dom": "^18.2.0"
8+
}
9+
}
3.64 KB
Loading
3.64 KB
Loading
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
Image using a relative path:
2+
![Houston](../assets/houston.webp)
3+
4+
Image using an aliased path:
5+
![Houston](~/assets/houston.webp)
6+
7+
Image with a title:
8+
![Houston](~/assets/houston.webp "Houston title")
9+
10+
Image with spaces in the path:
11+
![Houston](<~/assets/houston in space.webp>)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { expect } from 'chai';
2+
import { parseHTML } from 'linkedom';
3+
import { loadFixture } from '../../../astro/test/test-utils.js';
4+
5+
describe('MDX Page', () => {
6+
let devServer;
7+
let fixture;
8+
9+
before(async () => {
10+
fixture = await loadFixture({
11+
root: new URL('./fixtures/mdx-images/', import.meta.url),
12+
});
13+
devServer = await fixture.startDevServer();
14+
});
15+
16+
after(async () => {
17+
await devServer.stop();
18+
});
19+
20+
describe('Optimized images in MDX', () => {
21+
it('works', async () => {
22+
const res = await fixture.fetch('/');
23+
expect(res.status).to.equal(200);
24+
25+
const html = await res.text();
26+
const { document } = parseHTML(html);
27+
28+
const imgs = document.getElementsByTagName('img');
29+
expect(imgs.length).to.equal(4);
30+
// Image using a relative path
31+
expect(imgs.item(0).src.startsWith('/_image')).to.be.true;
32+
// Image using an aliased path
33+
expect(imgs.item(1).src.startsWith('/_image')).to.be.true;
34+
// Image with title
35+
expect(imgs.item(2).title).to.equal('Houston title');
36+
// Image with spaces in the path
37+
expect(imgs.item(3).src.startsWith('/_image')).to.be.true;
38+
});
39+
});
40+
});

packages/markdown/remark/src/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type {
88
import { toRemarkInitializeAstroData } from './frontmatter-injection.js';
99
import { loadPlugins } from './load-plugins.js';
1010
import { rehypeHeadingIds } from './rehype-collect-headings.js';
11-
import toRemarkCollectImages from './remark-collect-images.js';
11+
import { remarkCollectImages } from './remark-collect-images.js';
1212
import remarkPrism from './remark-prism.js';
1313
import scopedStyles from './remark-scoped-styles.js';
1414
import remarkShiki from './remark-shiki.js';
@@ -24,6 +24,7 @@ import { VFile } from 'vfile';
2424
import { rehypeImages } from './rehype-images.js';
2525

2626
export { rehypeHeadingIds } from './rehype-collect-headings.js';
27+
export { remarkCollectImages } from './remark-collect-images.js';
2728
export * from './types.js';
2829

2930
export const markdownConfigDefaults: Omit<Required<AstroMarkdownOptions>, 'drafts'> = {
@@ -96,7 +97,7 @@ export async function renderMarkdown(
9697

9798
if (opts.experimentalAssets) {
9899
// Apply later in case user plugins resolve relative image paths
99-
parser.use([toRemarkCollectImages()]);
100+
parser.use([remarkCollectImages]);
100101
}
101102
}
102103

0 commit comments

Comments
 (0)