feat(mdx): Add support for turning ![]() into <Image> (#6824)
This commit is contained in:
parent
948a6d7be0
commit
2511d58d58
12 changed files with 195 additions and 7 deletions
6
.changeset/giant-squids-pull.md
Normal file
6
.changeset/giant-squids-pull.md
Normal file
|
@ -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`
|
|
@ -1,4 +1,4 @@
|
|||
import { rehypeHeadingIds } from '@astrojs/markdown-remark';
|
||||
import { rehypeHeadingIds, remarkCollectImages } from '@astrojs/markdown-remark';
|
||||
import {
|
||||
InvalidAstroDataError,
|
||||
safelyGetAstroData,
|
||||
|
@ -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';
|
||||
|
@ -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) {
|
||||
|
|
98
packages/integrations/mdx/src/remark-images-to-component.ts
Normal file
98
packages/integrations/mdx/src/remark-images-to-component.ts
Normal file
|
@ -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";`));
|
||||
};
|
||||
}
|
8
packages/integrations/mdx/test/fixtures/mdx-images/astro.config.ts
vendored
Normal file
8
packages/integrations/mdx/test/fixtures/mdx-images/astro.config.ts
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
import mdx from '@astrojs/mdx';
|
||||
|
||||
export default {
|
||||
integrations: [mdx()],
|
||||
experimental: {
|
||||
assets: true
|
||||
}
|
||||
}
|
9
packages/integrations/mdx/test/fixtures/mdx-images/package.json
vendored
Normal file
9
packages/integrations/mdx/test/fixtures/mdx-images/package.json
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "@test/mdx-page",
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "workspace:*",
|
||||
"astro": "workspace:*",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
}
|
||||
}
|
BIN
packages/integrations/mdx/test/fixtures/mdx-images/src/assets/houston in space.webp
vendored
Normal file
BIN
packages/integrations/mdx/test/fixtures/mdx-images/src/assets/houston in space.webp
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.6 KiB |
BIN
packages/integrations/mdx/test/fixtures/mdx-images/src/assets/houston.webp
vendored
Normal file
BIN
packages/integrations/mdx/test/fixtures/mdx-images/src/assets/houston.webp
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.6 KiB |
11
packages/integrations/mdx/test/fixtures/mdx-images/src/pages/index.mdx
vendored
Normal file
11
packages/integrations/mdx/test/fixtures/mdx-images/src/pages/index.mdx
vendored
Normal file
|
@ -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
packages/integrations/mdx/test/mdx-images.test.js
Normal file
40
packages/integrations/mdx/test/mdx-images.test.js
Normal file
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
||||
|
@ -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'> = {
|
||||
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>();
|
||||
|
|
|
@ -4085,6 +4085,21 @@ importers:
|
|||
specifier: ^4.1.0
|
||||
version: 4.1.2
|
||||
|
||||
packages/integrations/mdx/test/fixtures/mdx-images:
|
||||
dependencies:
|
||||
'@astrojs/mdx':
|
||||
specifier: workspace:*
|
||||
version: link:../../..
|
||||
astro:
|
||||
specifier: workspace:*
|
||||
version: link:../../../../../astro
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.2.0
|
||||
react-dom:
|
||||
specifier: ^18.2.0
|
||||
version: 18.2.0(react@18.2.0)
|
||||
|
||||
packages/integrations/mdx/test/fixtures/mdx-infinite-loop:
|
||||
dependencies:
|
||||
'@astrojs/mdx':
|
||||
|
|
Loading…
Reference in a new issue