diff --git a/.changeset/sweet-goats-own.md b/.changeset/sweet-goats-own.md new file mode 100644 index 000000000000..172376c29564 --- /dev/null +++ b/.changeset/sweet-goats-own.md @@ -0,0 +1,5 @@ +--- +"@astrojs/mdx": major +--- + +Replace remark-images-to-component with rehype-images-to-component to let users use additional rehype plugins for images diff --git a/packages/integrations/mdx/package.json b/packages/integrations/mdx/package.json index cb3016167498..e1055cb52318 100644 --- a/packages/integrations/mdx/package.json +++ b/packages/integrations/mdx/package.json @@ -54,6 +54,7 @@ }, "devDependencies": { "@types/estree": "^1.0.5", + "@types/hast": "^3.0.3", "@types/mdast": "^4.0.3", "@types/yargs-parser": "^21.0.3", "astro": "workspace:*", @@ -61,6 +62,7 @@ "cheerio": "1.0.0-rc.12", "linkedom": "^0.16.11", "mdast-util-mdx": "^3.0.0", + "mdast-util-mdx-jsx": "^3.1.2", "mdast-util-to-string": "^4.0.0", "reading-time": "^1.5.0", "rehype-mathjax": "^6.0.0", diff --git a/packages/integrations/mdx/src/plugins.ts b/packages/integrations/mdx/src/plugins.ts index 99d0c70b2756..e314be1893a9 100644 --- a/packages/integrations/mdx/src/plugins.ts +++ b/packages/integrations/mdx/src/plugins.ts @@ -15,7 +15,7 @@ import { rehypeApplyFrontmatterExport } from './rehype-apply-frontmatter-export. import { rehypeInjectHeadingsExport } from './rehype-collect-headings.js'; import rehypeMetaString from './rehype-meta-string.js'; import { rehypeOptimizeStatic } from './rehype-optimize-static.js'; -import { remarkImageToComponent } from './remark-images-to-component.js'; +import { rehypeImageToComponent } from './rehype-images-to-component.js'; // Skip nonessential plugins during performance benchmark runs const isPerformanceBenchmark = Boolean(process.env.ASTRO_PERFORMANCE_BENCHMARK); @@ -52,7 +52,7 @@ function getRemarkPlugins(mdxOptions: MdxOptions): PluggableList { } } - remarkPlugins.push(...mdxOptions.remarkPlugins, remarkCollectImages, remarkImageToComponent); + remarkPlugins.push(...mdxOptions.remarkPlugins, remarkCollectImages); return remarkPlugins; } @@ -74,7 +74,7 @@ function getRehypePlugins(mdxOptions: MdxOptions): PluggableList { } } - rehypePlugins.push(...mdxOptions.rehypePlugins); + rehypePlugins.push(...mdxOptions.rehypePlugins, rehypeImageToComponent); if (!isPerformanceBenchmark) { // getHeadings() is guaranteed by TS, so this must be included. diff --git a/packages/integrations/mdx/src/rehype-images-to-component.ts b/packages/integrations/mdx/src/rehype-images-to-component.ts new file mode 100644 index 000000000000..6c797fda235f --- /dev/null +++ b/packages/integrations/mdx/src/rehype-images-to-component.ts @@ -0,0 +1,166 @@ +import type { MarkdownVFile } from '@astrojs/markdown-remark'; +import type { Properties, Root } from 'hast'; +import type { MdxJsxAttribute, MdxjsEsm } from 'mdast-util-mdx'; +import type { MdxJsxFlowElementHast } from 'mdast-util-mdx-jsx'; +import { visit } from 'unist-util-visit'; +import { jsToTreeNode } from './utils.js'; + +export const ASTRO_IMAGE_ELEMENT = 'astro-image'; +export const ASTRO_IMAGE_IMPORT = '__AstroImage__'; +export const USES_ASTRO_IMAGE_FLAG = '__usesAstroImage'; + +function createArrayAttribute(name: string, values: (string | number)[]): MdxJsxAttribute { + return { + type: 'mdxJsxAttribute', + name: name, + value: { + type: 'mdxJsxAttributeValueExpression', + value: name, + data: { + estree: { + type: 'Program', + body: [ + { + type: 'ExpressionStatement', + expression: { + type: 'ArrayExpression', + elements: values.map((value) => ({ + type: 'Literal', + value: value, + raw: String(value), + })), + }, + }, + ], + sourceType: 'module', + comments: [], + }, + }, + }, + }; +} + +/** + * Convert the element properties (except `src`) to MDX JSX attributes. + * + * @param {Properties} props - The element properties + * @returns {MdxJsxAttribute[]} The MDX attributes + */ +function getImageComponentAttributes(props: Properties): MdxJsxAttribute[] { + const attrs: MdxJsxAttribute[] = []; + + for (const [prop, value] of Object.entries(props)) { + if (prop === 'src') continue; + + /* + * component expects an array for those attributes but the + * received properties are sanitized as strings. So we need to convert them + * back to an array. + */ + if (prop === 'widths' || prop === 'densities') { + attrs.push(createArrayAttribute(prop, String(value).split(' '))); + } else { + attrs.push({ + name: prop, + type: 'mdxJsxAttribute', + value: String(value), + }); + } + } + + return attrs; +} + +export function rehypeImageToComponent() { + return function (tree: Root, file: MarkdownVFile) { + if (!file.data.imagePaths) return; + + const importsStatements: MdxjsEsm[] = []; + const importedImages = new Map(); + + visit(tree, 'element', (node, index, parent) => { + if (!file.data.imagePaths || node.tagName !== 'img' || !node.properties.src) return; + + const src = decodeURI(String(node.properties.src)); + + if (!file.data.imagePaths.has(src)) return; + + let importName = importedImages.get(src); + + if (!importName) { + importName = `__${importedImages.size}_${src.replace(/\W/g, '_')}__`; + + importsStatements.push({ + type: 'mdxjsEsm', + value: '', + data: { + estree: { + type: 'Program', + sourceType: 'module', + body: [ + { + type: 'ImportDeclaration', + source: { + type: 'Literal', + value: src, + raw: JSON.stringify(src), + }, + specifiers: [ + { + type: 'ImportDefaultSpecifier', + local: { type: 'Identifier', name: importName }, + }, + ], + }, + ], + }, + }, + }); + importedImages.set(src, importName); + } + + // Build a component that's equivalent to + const componentElement: MdxJsxFlowElementHast = { + name: ASTRO_IMAGE_ELEMENT, + type: 'mdxJsxFlowElement', + attributes: [ + ...getImageComponentAttributes(node.properties), + { + name: 'src', + type: 'mdxJsxAttribute', + value: { + type: 'mdxJsxAttributeValueExpression', + value: importName, + data: { + estree: { + type: 'Program', + sourceType: 'module', + comments: [], + body: [ + { + type: 'ExpressionStatement', + expression: { type: 'Identifier', name: importName }, + }, + ], + }, + }, + }, + }, + ], + children: [], + }; + + parent!.children.splice(index!, 1, componentElement); + }); + + // Add all the import statements to the top of the file for the images + tree.children.unshift(...importsStatements); + + tree.children.unshift( + jsToTreeNode(`import { Image as ${ASTRO_IMAGE_IMPORT} } from "astro:assets";`) + ); + // Export `__usesAstroImage` to pick up `astro:assets` usage in the module graph. + // @see the '@astrojs/mdx-postprocess' plugin + tree.children.push(jsToTreeNode(`export const ${USES_ASTRO_IMAGE_FLAG} = true`)); + }; +} diff --git a/packages/integrations/mdx/src/remark-images-to-component.ts b/packages/integrations/mdx/src/remark-images-to-component.ts deleted file mode 100644 index 46d04d443341..000000000000 --- a/packages/integrations/mdx/src/remark-images-to-component.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { MarkdownVFile } from '@astrojs/markdown-remark'; -import type { Image, Parent } from 'mdast'; -import type { MdxJsxAttribute, MdxJsxFlowElement, MdxjsEsm } from 'mdast-util-mdx'; -import { visit } from 'unist-util-visit'; -import { jsToTreeNode } from './utils.js'; - -export const ASTRO_IMAGE_ELEMENT = 'astro-image'; -export const ASTRO_IMAGE_IMPORT = '__AstroImage__'; -export const USES_ASTRO_IMAGE_FLAG = '__usesAstroImage'; - -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 | undefined, 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: ASTRO_IMAGE_ELEMENT, - 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, - }); - } - - if (node.data && node.data.hProperties) { - const createArrayAttribute = (name: string, values: string[]): MdxJsxAttribute => { - return { - type: 'mdxJsxAttribute', - name: name, - value: { - type: 'mdxJsxAttributeValueExpression', - value: name, - data: { - estree: { - type: 'Program', - body: [ - { - type: 'ExpressionStatement', - expression: { - type: 'ArrayExpression', - elements: values.map((value) => ({ - type: 'Literal', - value: value, - raw: String(value), - })), - }, - }, - ], - sourceType: 'module', - comments: [], - }, - }, - }, - }; - }; - // Go through every hProperty and add it as an attribute of the - Object.entries(node.data.hProperties as Record).forEach( - ([key, value]) => { - if (Array.isArray(value)) { - componentElement.attributes.push(createArrayAttribute(key, value)); - } else { - componentElement.attributes.push({ - name: key, - type: 'mdxJsxAttribute', - value: String(value), - }); - } - } - ); - } - - parent!.children.splice(index!, 1, componentElement); - } - }); - - // Add all the import statements to the top of the file for the images - tree.children.unshift(...importsStatements); - - tree.children.unshift( - jsToTreeNode(`import { Image as ${ASTRO_IMAGE_IMPORT} } from "astro:assets";`) - ); - // Export `__usesAstroImage` to pick up `astro:assets` usage in the module graph. - // @see the '@astrojs/mdx-postprocess' plugin - tree.children.push(jsToTreeNode(`export const ${USES_ASTRO_IMAGE_FLAG} = true`)); - }; -} diff --git a/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts b/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts index c60504be6c9c..0ce1cca24477 100644 --- a/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts +++ b/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts @@ -5,7 +5,7 @@ import { ASTRO_IMAGE_ELEMENT, ASTRO_IMAGE_IMPORT, USES_ASTRO_IMAGE_FLAG, -} from './remark-images-to-component.js'; +} from './rehype-images-to-component.js'; import { type FileInfo, getFileInfo } from './utils.js'; // These transforms must happen *after* JSX runtime transformations diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4f5d9cb6729..76b7f4547c23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4411,6 +4411,9 @@ importers: '@types/estree': specifier: ^1.0.5 version: 1.0.5 + '@types/hast': + specifier: ^3.0.3 + version: 3.0.4 '@types/mdast': specifier: ^4.0.3 version: 4.0.3 @@ -4432,6 +4435,9 @@ importers: mdast-util-mdx: specifier: ^3.0.0 version: 3.0.0 + mdast-util-mdx-jsx: + specifier: ^3.1.2 + version: 3.1.2 mdast-util-to-string: specifier: ^4.0.0 version: 4.0.0