Zenn
😸

MDX 内の画像を Astro の Picture コンポーネントに自動変換する remark プラグインを作る

2025/03/31に公開

モチベーション

Astro で Markdown に書いた ![alt](image-path) 形式のローカル画像は、自動で webp に変換される。
しかし私は特定のパスにある記事だけ画像に quality を指定した上で picture タグにしたかったので、今までは下記の記事を参考に、img タグを探して自力で picture タグへ変換する rehype プラグインを作成・使用していた。

https://zenn.dev/pompompudding/articles/1a8d8d54fe7823

しかし MDX の使用を前提とすれば、すべての img を Astro の Picture コンポーネントに自動変換することによって、画像の変換処理を Astro に任せることができるのではないかと考えた。

環境

  • astro: 5.4.1
  • @astrojs/mdx: 4.1.0
  • @astrojs/markdown-remark: 6.2.0
  • @types/estree: 1.0.6
  • mdast-util-mdx: 3.0.0
  • unist-util-visit: 5.0.0

サンプルリポジトリ

下記が動くサンプルである。
https://gitlab.com/k1350/astro-mdx-picture

実装

まずは変換先を調べる。汎用性は考えないものとして、自分が使う可能性がある Picture コンポーネントの props を含んだ下記のような MDX を作成し、

---
title: My Blog Post
date: 2025-02-02T23:05:00+09:00
---

import { Picture } from 'astro:assets';
import myImage from '../../assets/duong-ngan-Z_Iuwob1ttY-unsplash.jpg';

<Picture src={myImage} formats={['avif', 'webp']} widths={[240, 540, myImage.width]} sizes={`(max-width: 360px) 240px, (max-width: 720px) 540px, ${myImage.width}px`} alt="flower" quality="high" />

出力を以下の remark プラグインで調査する。

import { visit } from 'unist-util-visit';
import type { RemarkPlugin } from "@astrojs/markdown-remark";

export function testImgPlugin(): RemarkPlugin {
	return function imgPlugin() {
        return (tree) => {
            visit(tree, (node) => {
                console.log(JSON.stringify(node,null,"\t"));
            });
        };
    }
}

出力結果から position などの不要な要素を取り払った結果は下記の通りである。

長いので折り畳み
{
  "type": "mdxjsEsm",
  "value": "import { Picture } from 'astro:assets';\nimport myImage from '../../assets/duong-ngan-Z_Iuwob1ttY-unsplash.jpg';",
  "data": {
    "estree": {
      "type": "Program",
      "body": [
        {
          "type": "ImportDeclaration",
          "specifiers": [
            {
              "type": "ImportSpecifier",
              "imported": {
                "type": "Identifier",
                "name": "Picture"
              },
              "local": {
                "type": "Identifier",
                "name": "Picture"
              }
            }
          ],
          "source": {
            "type": "Literal",
            "value": "astro:assets",
            "raw": "'astro:assets'"
          }
        },
        {
          "type": "ImportDeclaration",
          "specifiers": [
            {
              "type": "ImportDefaultSpecifier",
              "local": {
                "type": "Identifier",
                "name": "myImage"
              }
            }
          ],
          "source": {
            "type": "Literal",
            "value": "../../assets/duong-ngan-Z_Iuwob1ttY-unsplash.jpg",
            "raw": "'../../assets/duong-ngan-Z_Iuwob1ttY-unsplash.jpg'"
          }
        }
      ],
      "sourceType": "module"
    }
  }
}
{
  "type": "mdxJsxFlowElement",
  "name": "Picture",
  "attributes": [
    {
      "type": "mdxJsxAttribute",
      "name": "src",
      "value": {
        "type": "mdxJsxAttributeValueExpression",
        "value": "myImage",
        "data": {
          "estree": {
            "type": "Program",
            "start": 188,
            "end": 195,
            "body": [
              {
                "type": "ExpressionStatement",
                "expression": {
                  "type": "Identifier",
                  "name": "myImage"
                }
              }
            ],
            "sourceType": "module"
          }
        }
      }
    },
    {
      "type": "mdxJsxAttribute",
      "name": "formats",
      "value": {
        "type": "mdxJsxAttributeValueExpression",
        "value": "['avif', 'webp']",
        "data": {
          "estree": {
            "type": "Program",
            "body": [
              {
                "type": "ExpressionStatement",
                "expression": {
                  "type": "ArrayExpression",
                  "elements": [
                    {
                      "type": "Literal",
                      "value": "avif",
                      "raw": "'avif'"
                    },
                    {
                      "type": "Literal",
                      "value": "webp",
                      "raw": "'webp'"
                    }
                  ]
                }
              }
            ],
            "sourceType": "module"
          }
        }
      }
    },
    {
      "type": "mdxJsxAttribute",
      "name": "widths",
      "value": {
        "type": "mdxJsxAttributeValueExpression",
        "value": "[240, 540, myImage.width]",
        "data": {
          "estree": {
            "type": "Program",
            "body": [
              {
                "type": "ExpressionStatement",
                "expression": {
                  "type": "ArrayExpression",
                  "elements": [
                    {
                      "type": "Literal",
                      "value": 240,
                      "raw": "240"
                    },
                    {
                      "type": "Literal",
                      "value": 540,
                      "raw": "540"
                    },
                    {
                      "type": "MemberExpression",
                      "object": {
                        "type": "Identifier",
                        "name": "myImage"
                      },
                      "property": {
                        "type": "Identifier",
                        "name": "width"
                      },
                      "computed": false,
                      "optional": false
                    }
                  ]
                }
              }
            ]
          }
        }
      }
    },
    {
      "type": "mdxJsxAttribute",
      "name": "sizes",
      "value": {
        "type": "mdxJsxAttributeValueExpression",
        "value": "`(max-width: 360px) 240px, (max-width: 720px) 540px, ${myImage.width}px`",
        "data": {
          "estree": {
            "type": "Program",
            "body": [
              {
                "type": "ExpressionStatement",
                "expression": {
                  "type": "TemplateLiteral",
                  "expressions": [
                    {
                      "type": "MemberExpression",
                      "object": {
                        "type": "Identifier",
                        "name": "myImage"
                      },
                      "property": {
                        "type": "Identifier",
                        "name": "width"
                      }
                    }
                  ],
                  "quasis": [
                    {
                      "type": "TemplateElement",
                      "value": {
                        "raw": "(max-width: 360px) 240px, (max-width: 720px) 540px, ",
                        "cooked": "(max-width: 360px) 240px, (max-width: 720px) 540px, "
                      },
                      "tail": false
                    },
                    {
                      "type": "TemplateElement",
                      "value": {
                        "raw": "px",
                        "cooked": "px"
                      },
                      "tail": true
                    }
                  ]
                }
              }
            ],
            "sourceType": "module"
          }
        }
      }
    },
    {
      "type": "mdxJsxAttribute",
      "name": "alt",
      "value": "flower"
    },
    {
      "type": "mdxJsxAttribute",
      "name": "quality",
      "value": "high"
    }
  ],
  "children": []
}

上記の出力結果を再現すればいいはずなので、下記のような remarkPlugin を作った。

src/plugins/remarkImgToPicturePlugin/index.ts
import type { RemarkPlugin } from "@astrojs/markdown-remark";
import type { ImageOutputFormat, ImageQuality } from "astro";
import { visit } from "unist-util-visit";
import { getImportMdxjsEsm, getPictureComponent } from "./utils";

type Props = {
  options: {
    collections: string[];
    formats: ImageOutputFormat[];
    /**
     * @default "mid"
     */
    quality?: ImageQuality;
    widths?: number[];
    sizes?: string;
  }[];
};

export function configureRemarkImgToPicturePlugin({
  options,
}: Props): RemarkPlugin {
  return function remarkImgToPicturePlugin() {
    return (tree, file) => {
      // MDX でなければスキップ
      if (file.extname !== ".mdx") return;

      const dirname = file.dirname;
      if (!dirname) return;

      const option = options.find((option) =>
        option.collections.some((collection) =>
          dirname.includes(`/${collection}`),
        ),
      );
      if (!option) return;

      const { formats, quality = "mid", widths, sizes } = option;
      const importNames = new Map<string, string>();

      visit(tree, "image", (node, index, parent) => {
        if (index === undefined || parent === undefined || !node.url) {
          return;
        }

        const src = node.url;

        // 処理しない画像をスキップ
        if (src.endsWith(".svg")) return;
        if (src.startsWith("http")) return;
        if (src.startsWith("/")) return;

        const importName = `${src.replace(/\W/g, "_")}`;
        if (!importNames.has(importName)) {
          importNames.set(importName, src);
        }

        // 現在の image 要素を Picture コンポーネントに置き換える
        parent.children.splice(
          index,
          1,
          getPictureComponent({
            importName,
            alt: node.alt || "",
            formats,
            quality,
            widths,
            sizes,
          }),
        );
      });

      if (importNames.size === 0) return;
      tree.children.unshift(getImportMdxjsEsm(importNames));
    };
  };
}
src/plugins/remarkImgToPicturePlugin/utils.ts
import type { ImageOutputFormat, ImageQuality } from "astro";
import type {
  ImportDeclaration,
  MemberExpression,
  ModuleDeclaration,
  SimpleLiteral,
} from "estree";
import type { MdxJsxFlowElement, MdxjsEsm } from "mdast-util-mdx";

const PICTURE_ALIAS = "MDX_PICTURE";

export function getImportMdxjsEsm(importNames: Map<string, string>): MdxjsEsm {
  const keys = importNames.keys().toArray();

  const data: ModuleDeclaration[] = keys
    .map((importName) => {
      const src = importNames.get(importName);
      if (!src) return null;

      const declaration: ImportDeclaration = {
        type: "ImportDeclaration",
        specifiers: [
          {
            type: "ImportDefaultSpecifier",
            local: {
              type: "Identifier",
              name: importName,
            },
          },
        ],
        source: {
          type: "Literal",
          value: src,
          raw: `'${src}'`,
        },
      };
      return declaration;
    })
    .filter(
      (declaration): declaration is ImportDeclaration => declaration !== null,
    );

  return {
    type: "mdxjsEsm",
    value: `import { Picture as ${PICTURE_ALIAS} } from 'astro:assets';\n${keys.map((importName) => `import ${importName} from '${importNames.get(importName)}';\n`)}`,
    data: {
      estree: {
        type: "Program",
        sourceType: "module",
        body: [
          {
            type: "ImportDeclaration",
            specifiers: [
              {
                type: "ImportSpecifier",
                imported: {
                  type: "Identifier",
                  name: "Picture",
                },
                local: {
                  type: "Identifier",
                  name: PICTURE_ALIAS,
                },
              },
            ],
            source: {
              type: "Literal",
              value: "astro:assets",
              raw: "'astro:assets'",
            },
          },
          ...data,
        ],
      },
    },
  };
}

type GetPictureComponentProps = {
  importName: string;
  alt: string;
  formats: ImageOutputFormat[];
  quality: ImageQuality;
  widths?: number[];
  sizes?: string;
};

export function getPictureComponent({
  importName,
  alt,
  formats,
  quality,
  widths,
  sizes,
}: GetPictureComponentProps): MdxJsxFlowElement {
  const data: MdxJsxFlowElement = {
    type: "mdxJsxFlowElement",
    name: PICTURE_ALIAS,
    attributes: [
      {
        type: "mdxJsxAttribute",
        name: "src",
        value: {
          type: "mdxJsxAttributeValueExpression",
          value: importName,
          data: {
            estree: {
              type: "Program",
              body: [
                {
                  type: "ExpressionStatement",
                  expression: {
                    type: "Identifier",
                    name: importName,
                  },
                },
              ],
              sourceType: "module",
            },
          },
        },
      },
      {
        type: "mdxJsxAttribute",
        name: "formats",
        value: {
          type: "mdxJsxAttributeValueExpression",
          value: formats.map((format) => `'${format}'`).join(", "),
          data: {
            estree: {
              type: "Program",
              body: [
                {
                  type: "ExpressionStatement",
                  expression: {
                    type: "ArrayExpression",
                    elements: formats.map((format) => ({
                      type: "Literal",
                      value: format,
                      raw: `'${format}'`,
                    })),
                  },
                },
              ],
              sourceType: "module",
            },
          },
        },
      },
      {
        type: "mdxJsxAttribute",
        name: "alt",
        value: alt,
      },
      {
        type: "mdxJsxAttribute",
        name: "quality",
        value: quality.toString(),
      },
    ],
    children: [],
  };

  if (widths && widths.length > 0 && sizes) {
    const widthsWithOriginal = [...widths, `${importName}.width`];
    const widthArrayExpression: (SimpleLiteral | MemberExpression)[] =
      widths.map((width) => ({
        type: "Literal",
        value: width,
        raw: `${width}`,
      }));
    widthArrayExpression.push({
      type: "MemberExpression",
      object: {
        type: "Identifier",
        name: importName,
      },
      property: {
        type: "Identifier",
        name: "width",
      },
      computed: false,
      optional: false,
    });
    data.attributes.push({
      type: "mdxJsxAttribute",
      name: "widths",
      value: {
        type: "mdxJsxAttributeValueExpression",
        value: `[${widthsWithOriginal.join(", ")}]`,
        data: {
          estree: {
            type: "Program",
            body: [
              {
                type: "ExpressionStatement",
                expression: {
                  type: "ArrayExpression",
                  elements: widthArrayExpression,
                },
              },
            ],
            sourceType: "module",
          },
        },
      },
    });

    data.attributes.push({
      type: "mdxJsxAttribute",
      name: "sizes",
      value: {
        type: "mdxJsxAttributeValueExpression",
        value: `\`${sizes}, \${${importName}.width}px\``,
        data: {
          estree: {
            type: "Program",
            body: [
              {
                type: "ExpressionStatement",
                expression: {
                  type: "TemplateLiteral",
                  expressions: [
                    {
                      type: "MemberExpression",
                      object: {
                        type: "Identifier",
                        name: importName,
                      },
                      property: {
                        type: "Identifier",
                        name: "width",
                      },
                      computed: false,
                      optional: false,
                    },
                  ],
                  quasis: [
                    {
                      type: "TemplateElement",
                      value: {
                        raw: `${sizes}, `,
                        cooked: `${sizes}, `,
                      },
                      tail: false,
                    },
                    {
                      type: "TemplateElement",
                      value: {
                        raw: "px",
                        cooked: "px",
                      },
                      tail: true,
                    },
                  ],
                },
              },
            ],
            sourceType: "module",
          },
        },
      },
    });
  }

  return data;
}

読み込み側では対象とするパスと各種 props を指定する。
ただし widths と sizes に関してはプラグイン側で自動的に画像そのものの width 情報を付け足すので、それを除いた値を指定する。

astro.config.mjs
// @ts-check
import mdx from "@astrojs/mdx";
import { defineConfig } from "astro/config";
import { configureRemarkImgToPicturePlugin } from "./src/plugins/remarkImgToPicturePlugin";

// https://astro.build/config
export default defineConfig({
  integrations: [mdx()],
  markdown: {
    remarkPlugins: [
      configureRemarkImgToPicturePlugin({
        options: [
          {
            collections: ["gallery"],
            formats: ["avif", "webp"],
            quality: "high",
            widths: [720, 1640],
            sizes: "(max-width: 820px) 100vw",
          },
        ],
      }),
    ],
  },
});

これで以下のような MDX を書いたとき

---
title: My Gallery Post
date: 2025-02-02T23:05:00+09:00
---

![flower](../../assets/duong-ngan-Z_Iuwob1ttY-unsplash.jpg)

![flower2](../../assets/pierre-lemos-wfOdYsDrxvs-unsplash.jpg)

Photo: [Unsplash](https://unsplash.com/ja)

img を Astro の Picture コンポーネントに変換し、画像処理自体は Astro に任せることができた。

変換を指定しなかったパスの記事はデフォルトの変換となっている。

まとめ

MDX を使用している前提で、Astro の Picture コンポーネントに変換することにより、画像変換処理自体は Astro に任せることができた。
キャッシュの制御も考えなくていいので、今回のプラグインの方向性のほうが自分にはあっていると思う。

chot Inc. tech blog

Discussion

ログインするとコメントできます