From 2511d58d586af080a78e5ef8a63020b3e17770db Mon Sep 17 00:00:00 2001 From: Erika <3019731+Princesseuh@users.noreply.github.com> Date: Thu, 13 Apr 2023 11:54:40 +0200 Subject: [PATCH] feat(mdx): Add support for turning ![]() into (#6824) --- .changeset/giant-squids-pull.md | 6 ++ packages/integrations/mdx/src/plugins.ts | 5 +- .../mdx/src/remark-images-to-component.ts | 98 ++++++++++++++++++ .../test/fixtures/mdx-images/astro.config.ts | 8 ++ .../mdx/test/fixtures/mdx-images/package.json | 9 ++ .../src/assets/houston in space.webp | Bin 0 -> 3728 bytes .../mdx-images/src/assets/houston.webp | Bin 0 -> 3728 bytes .../fixtures/mdx-images/src/pages/index.mdx | 11 ++ .../integrations/mdx/test/mdx-images.test.js | 40 +++++++ packages/markdown/remark/src/index.ts | 5 +- .../remark/src/remark-collect-images.ts | 5 +- pnpm-lock.yaml | 15 +++ 12 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 .changeset/giant-squids-pull.md create mode 100644 packages/integrations/mdx/src/remark-images-to-component.ts create mode 100644 packages/integrations/mdx/test/fixtures/mdx-images/astro.config.ts create mode 100644 packages/integrations/mdx/test/fixtures/mdx-images/package.json create mode 100644 packages/integrations/mdx/test/fixtures/mdx-images/src/assets/houston in space.webp create mode 100644 packages/integrations/mdx/test/fixtures/mdx-images/src/assets/houston.webp create mode 100644 packages/integrations/mdx/test/fixtures/mdx-images/src/pages/index.mdx create mode 100644 packages/integrations/mdx/test/mdx-images.test.js diff --git a/.changeset/giant-squids-pull.md b/.changeset/giant-squids-pull.md new file mode 100644 index 000000000..795bb6359 --- /dev/null +++ b/.changeset/giant-squids-pull.md @@ -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` diff --git a/packages/integrations/mdx/src/plugins.ts b/packages/integrations/mdx/src/plugins.ts index 12b8f2bd3..56fbbf837 100644 --- a/packages/integrations/mdx/src/plugins.ts +++ b/packages/integrations/mdx/src/plugins.ts @@ -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 { - let remarkPlugins: PluggableList = []; + let remarkPlugins: PluggableList = [...(config.experimental.assets ? [remarkCollectImages, remarkImageToComponent] : [])]; if (!isPerformanceBenchmark) { if (mdxOptions.gfm) { diff --git a/packages/integrations/mdx/src/remark-images-to-component.ts b/packages/integrations/mdx/src/remark-images-to-component.ts new file mode 100644 index 000000000..8a3166f49 --- /dev/null +++ b/packages/integrations/mdx/src/remark-images-to-component.ts @@ -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(); + + 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 {node.alt} + 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";`)); + }; +} diff --git a/packages/integrations/mdx/test/fixtures/mdx-images/astro.config.ts b/packages/integrations/mdx/test/fixtures/mdx-images/astro.config.ts new file mode 100644 index 000000000..fe92bd37f --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-images/astro.config.ts @@ -0,0 +1,8 @@ +import mdx from '@astrojs/mdx'; + +export default { + integrations: [mdx()], + experimental: { + assets: true + } +} diff --git a/packages/integrations/mdx/test/fixtures/mdx-images/package.json b/packages/integrations/mdx/test/fixtures/mdx-images/package.json new file mode 100644 index 000000000..7ff215df1 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-images/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/mdx-page", + "dependencies": { + "@astrojs/mdx": "workspace:*", + "astro": "workspace:*", + "react": "^18.2.0", + "react-dom": "^18.2.0" + } +} diff --git a/packages/integrations/mdx/test/fixtures/mdx-images/src/assets/houston in space.webp b/packages/integrations/mdx/test/fixtures/mdx-images/src/assets/houston in space.webp new file mode 100644 index 0000000000000000000000000000000000000000..3727bc508d8d4dfc6484e0d7729ecfe18ca6c4b2 GIT binary patch literal 3728 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 diff --git a/packages/integrations/mdx/test/mdx-images.test.js b/packages/integrations/mdx/test/mdx-images.test.js new file mode 100644 index 000000000..c9c8e1f7c --- /dev/null +++ b/packages/integrations/mdx/test/mdx-images.test.js @@ -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; + }); + }); +}); diff --git a/packages/markdown/remark/src/index.ts b/packages/markdown/remark/src/index.ts index f37b9ed68..0a21e1c98 100644 --- a/packages/markdown/remark/src/index.ts +++ b/packages/markdown/remark/src/index.ts @@ -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, '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]); } } diff --git a/packages/markdown/remark/src/remark-collect-images.ts b/packages/markdown/remark/src/remark-collect-images.ts index 470b770ed..0f1eb59f7 100644 --- a/packages/markdown/remark/src/remark-collect-images.ts +++ b/packages/markdown/remark/src/remark-collect-images.ts @@ -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(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0fa7f3d8..d6f7ec104 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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':