Skip to content

Commit

Permalink
feat(mdx): Add support for turning ![]() into <Image> (#6824)
Browse files Browse the repository at this point in the history
  • Loading branch information
Princesseuh authored Apr 13, 2023
1 parent 948a6d7 commit 2511d58
Show file tree
Hide file tree
Showing 12 changed files with 195 additions and 7 deletions.
6 changes: 6 additions & 0 deletions .changeset/giant-squids-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@astrojs/mdx': minor
'@astrojs/markdown-remark': patch
---

Add support for using optimized and relative images in MDX files with `experimental.assets`
5 changes: 3 additions & 2 deletions packages/integrations/mdx/src/plugins.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { rehypeHeadingIds } from '@astrojs/markdown-remark';
import { rehypeHeadingIds, remarkCollectImages } from '@astrojs/markdown-remark';
import {
InvalidAstroDataError,
safelyGetAstroData,
Expand All @@ -16,6 +16,7 @@ import type { VFile } from 'vfile';
import type { MdxOptions } from './index.js';
import { rehypeInjectHeadingsExport } from './rehype-collect-headings.js';
import rehypeMetaString from './rehype-meta-string.js';
import { remarkImageToComponent } from './remark-images-to-component.js';
import remarkPrism from './remark-prism.js';
import remarkShiki from './remark-shiki.js';
import { jsToTreeNode } from './utils.js';
Expand Down Expand Up @@ -99,7 +100,7 @@ export async function getRemarkPlugins(
mdxOptions: MdxOptions,
config: AstroConfig
): Promise<MdxRollupPluginOptions['remarkPlugins']> {
let remarkPlugins: PluggableList = [];
let remarkPlugins: PluggableList = [...(config.experimental.assets ? [remarkCollectImages, remarkImageToComponent] : [])];

if (!isPerformanceBenchmark) {
if (mdxOptions.gfm) {
Expand Down
98 changes: 98 additions & 0 deletions packages/integrations/mdx/src/remark-images-to-component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { MarkdownVFile } from '@astrojs/markdown-remark';
import { type Image, type Parent } from 'mdast';
import type { MdxJsxFlowElement, MdxjsEsm } from 'mdast-util-mdx';
import { visit } from 'unist-util-visit';
import { jsToTreeNode } from './utils.js';

export function remarkImageToComponent() {
return function (tree: any, file: MarkdownVFile) {
if (!file.data.imagePaths) return;

const importsStatements: MdxjsEsm[] = [];
const importedImages = new Map<string, string>();

visit(tree, 'image', (node: Image, index: number | null, parent: Parent | null) => {
// Use the imagePaths set from the remark-collect-images so we don't have to duplicate the logic for
// checking if an image should be imported or not
if (file.data.imagePaths?.has(node.url)) {
let importName = importedImages.get(node.url);

// If we haven't already imported this image, add an import statement
if (!importName) {
importName = `__${importedImages.size}_${node.url.replace(/\W/g, '_')}__`;

importsStatements.push({
type: 'mdxjsEsm',
value: '',
data: {
estree: {
type: 'Program',
sourceType: 'module',
body: [
{
type: 'ImportDeclaration',
source: { type: 'Literal', value: node.url, raw: JSON.stringify(node.url) },
specifiers: [
{
type: 'ImportDefaultSpecifier',
local: { type: 'Identifier', name: importName },
},
],
},
],
},
},
});
importedImages.set(node.url, importName);
}

// Build a component that's equivalent to <Image src={importName} alt={node.alt} title={node.title} />
const componentElement: MdxJsxFlowElement = {
name: '__AstroImage__',
type: 'mdxJsxFlowElement',
attributes: [
{
name: 'src',
type: 'mdxJsxAttribute',
value: {
type: 'mdxJsxAttributeValueExpression',
value: importName,
data: {
estree: {
type: 'Program',
sourceType: 'module',
comments: [],
body: [
{
type: 'ExpressionStatement',
expression: { type: 'Identifier', name: importName },
},
],
},
},
},
},
{ name: 'alt', type: 'mdxJsxAttribute', value: node.alt || '' },
],
children: [],
};

if (node.title) {
componentElement.attributes.push({
type: 'mdxJsxAttribute',
name: 'title',
value: node.title,
});
}

parent!.children.splice(index!, 1, componentElement);
}
});

// 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";`));
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import mdx from '@astrojs/mdx';

export default {
integrations: [mdx()],
experimental: {
assets: true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "@test/mdx-page",
"dependencies": {
"@astrojs/mdx": "workspace:*",
"astro": "workspace:*",
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Image using a relative path:
![Houston](../assets/houston.webp)

Image using an aliased path:
![Houston](~/assets/houston.webp)

Image with a title:
![Houston](~/assets/houston.webp "Houston title")

Image with spaces in the path:
![Houston](<~/assets/houston in space.webp>)
40 changes: 40 additions & 0 deletions packages/integrations/mdx/test/mdx-images.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { expect } from 'chai';
import { parseHTML } from 'linkedom';
import { loadFixture } from '../../../astro/test/test-utils.js';

describe('MDX Page', () => {
let devServer;
let fixture;

before(async () => {
fixture = await loadFixture({
root: new URL('./fixtures/mdx-images/', import.meta.url),
});
devServer = await fixture.startDevServer();
});

after(async () => {
await devServer.stop();
});

describe('Optimized images in MDX', () => {
it('works', async () => {
const res = await fixture.fetch('/');
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(4);
// Image using a relative path
expect(imgs.item(0).src.startsWith('/_image')).to.be.true;
// Image using an aliased path
expect(imgs.item(1).src.startsWith('/_image')).to.be.true;
// Image with title
expect(imgs.item(2).title).to.equal('Houston title');
// Image with spaces in the path
expect(imgs.item(3).src.startsWith('/_image')).to.be.true;
});
});
});
5 changes: 3 additions & 2 deletions packages/markdown/remark/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {
import { toRemarkInitializeAstroData } from './frontmatter-injection.js';
import { loadPlugins } from './load-plugins.js';
import { rehypeHeadingIds } from './rehype-collect-headings.js';
import toRemarkCollectImages from './remark-collect-images.js';
import { remarkCollectImages } from './remark-collect-images.js';
import remarkPrism from './remark-prism.js';
import scopedStyles from './remark-scoped-styles.js';
import remarkShiki from './remark-shiki.js';
Expand All @@ -24,6 +24,7 @@ import { VFile } from 'vfile';
import { rehypeImages } from './rehype-images.js';

export { rehypeHeadingIds } from './rehype-collect-headings.js';
export { remarkCollectImages } from './remark-collect-images.js';
export * from './types.js';

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

if (opts.experimentalAssets) {
// Apply later in case user plugins resolve relative image paths
parser.use([toRemarkCollectImages()]);
parser.use([remarkCollectImages]);
}
}

Expand Down
5 changes: 2 additions & 3 deletions packages/markdown/remark/src/remark-collect-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ import type { Image } from 'mdast';
import { visit } from 'unist-util-visit';
import type { MarkdownVFile } from './types';

export default function toRemarkCollectImages() {
return () =>
async function (tree: any, vfile: MarkdownVFile) {
export function remarkCollectImages() {
return function (tree: any, vfile: MarkdownVFile) {
if (typeof vfile?.path !== 'string') return;

const imagePaths = new Set<string>();
Expand Down
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 2511d58

Please sign in to comment.