Skip to content

feat(mdx): Add support for turning ![]() into <Image> #6824

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

Merged
merged 8 commits into from
Apr 13, 2023
Merged
Show file tree
Hide file tree
Changes from 5 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
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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
![Houston](../assets/houston.webp)

![Houston](~/assets/houston.webp)
34 changes: 34 additions & 0 deletions packages/integrations/mdx/test/mdx-images.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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(2);
expect(imgs.item(0).src.startsWith('/_image')).to.be.true;
expect(imgs.item(1).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
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

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