🖼️

ぼかし画像`blurDataURL`を付加できるrehypeプラグインの実装 feat. `next/image`✨

2024/02/15に公開

はじめまして、情報系高専4年生の @ReoHakase です。
今回はMarkdownの画像をnext/imageで最適化するために必要なプロパティwidth, height, blurDataURLを付加できるrehypeプラグインを実装してみたので、それを共有します。

できること

  1. <img>タグのsrc属性で指定されている画像をfetch()もしくはfs.readFile()で読み込む → つまり、リモートにある画像にも対応可能✨
  2. 画像のPlaceholderの生成ライブラリplaiceholderを用いてblurDataURL, width, heightなどを得る
  3. <img>タグの属性に追加する

わかりやすいように実際の例を挙げると、このMarkdownの画像記法が、

![New Jeans, my love](https://s3-ap-northeast-1.amazonaws.com/pf-web/fanclubs/148/assets/229/images/top/main.jpg?20230707125959)

このようにぼかし画像のプレースホルダー付きで描画されるようになります😊

next/imageを使ったレンダー結果
<img
    alt="New Jeans, my love"
    loading="lazy"
    width="1920"
    height="1280"
    decoding="async"
    data-nimg="1"
    class="markup_image__img markup_image__img--caption_true"
    style="color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;
        background-image:url(&quot;data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1920 1280'%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='20'/%3E%3CfeColorMatrix values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 100 -1' result='s'/%3E%3CfeFlood x='0' y='0' width='100%25' height='100%25'/%3E%3CfeComposite operator='out' in='s'/%3E%3CfeComposite in2='SourceGraphic'/%3E%3CfeGaussianBlur stdDeviation='20'/%3E%3C/filter%3E%3Cimage width='100%25' height='100%25' x='0' y='0' preserveAspectRatio='none' style='filter: url(%23b);' href='data:image/png;base64,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'/%3E%3C/svg%3E&quot;)"
    srcset="/_next/image?url=https%3A%2F%2Fs3-ap-northeast-1.amazonaws.com%2Fpf-web%2Ffanclubs%2F148%2Fassets%2F229%2Fimages%2Ftop%2Fmain.jpg%3F20230707125959&amp;w=1920&amp;q=75 1x, /_next/image?url=https%3A%2F%2Fs3-ap-northeast-1.amazonaws.com%2Fpf-web%2Ffanclubs%2F148%2Fassets%2F229%2Fimages%2Ftop%2Fmain.jpg%3F20230707125959&amp;w=3840&amp;q=75 2x"
    src="/_next/image?url=https%3A%2F%2Fs3-ap-northeast-1.amazonaws.com%2Fpf-web%2Ffanclubs%2F148%2Fassets%2F229%2Fimages%2Ftop%2Fmain.jpg%3F20230707125959&amp;w=3840&amp;q=75"
>

base64で埋め込まれた画像のPlaceholderが読み込み中に表示されている様子 base64で埋め込まれた画像のPlaceholderが読み込み中に表示されている様子

width, height属性が元の画像に合わせて付加されているので、MDXの設定で<img>タグを<Image>(next/image)で描画させるようにしても例外が投げられません。

使用するライブラリ

今回は、画像のBufferから、ぼかし画像や代表色、大きさなどプレースホルダーを設けるのに必要なデータを抽出できるライブラリplaiceholderを用いました。

https://plaiceholder.co/docs

https://github.com/joe-bell/plaiceholder

GitHubのリポジトリは ⭐Star 2.2k、 npmでの週間ダウンロード数は18kほどと、程よく知られたライブラリです。

The plaiceholder project is feature complete and will now be kept in maintenance mode.
Read the migration guide for further information.
If this project has been useful to you, please consider sponsoring my work.

公式リポジトリのREADMEにあるように、plaiceholderは機能が完成しきっていて現在メンテナンスのみを行う開発状況のようです。なので、ある程度安心して採用できそうです。

手順

コードだけ早く見たいという方は、こちらを参照ください。
https://github.com/ReoHakase/reoiam-dev/tree/67/repackage-rehype-image-optimizer/packages/rehype-image-optimizer

依存関係の追加

まず、rehypeプラグインを作成するために必要になるパッケージを追加します。

pnpm i unified unist-util-visit
pnpm i -D @types/unist @types/hast # Typescriptを用いる場合のみ

そして、今回の要となるライブラリplaiceholderを追加します。

pnpm i -D plaiceholder

rehypeプラグインの実装

まず、画像のパスもしくはURLsrcから画像のBufferを解決できる関数と、plaiceholderのラッパーを定義します。

rehype-image-optimizer/src/placeholder.ts
import fs from 'fs/promises';
import path from 'path';
import { getPlaiceholder } from 'plaiceholder';
import type { GetPlaiceholderOptions } from 'plaiceholder';

/**
 * Retrieves the image buffer for the given source.
 * If the source is a remote URL, it is downloaded and returned as a buffer.
 * If the source is a local file, it is read and returned as a buffer.
 * Plaiceholder with version above 3 requires the image to be a buffer, not a path.
 * @param src - The source of the image.
 * @param basePath - The base path for the image. It is used when the image is a local file.
 * @returns A promise that resolves to the image buffer.
 * @see https://plaiceholder.co/docs/upgrading-to-3
 */
export const getImageBuffer = async (src: string, basePath: string): Promise<Buffer> => {
  // If the image is a remote URL, download it.
  if (src.startsWith('http')) {
    const res = await fetch(src);
    return Buffer.from(await res.arrayBuffer());
  }
  // If the image is a local file, read it.
  // The path is relative to the project root.
  return fs.readFile(path.join(basePath, src));
};

export type GetPlaceholderOptions = GetPlaiceholderOptions;

/**
 * Retrieves the placeholder color, CSS, SVG and Base64 image for the given source and options.
 * @param src - The source of the image.
 * @param basePath - The base path for the image. It is used when the image is a local file.
 * @param options - The options for generating the placeholder.
 * @returns A promise that resolves to the result of generating the placeholder.
 * ```markdown
 * - Color: It's just a color
 * - CSS: ~600B when rendered as CSS
 * - SVG: ~1.2kB when rendered in HTML
 * - Base64: ~300B asset size
 * ```
 * @see https://plaiceholder.co/docs
 */
export const getPlaceholder = async (
  src: string,
  basePath: string,
  options?: GetPlaceholderOptions,
): ReturnType<typeof getPlaiceholder> => {
  // Retrieve the image buffer and generate the blur data URL and size data.
  const imageBuffer = await getImageBuffer(src, basePath);
  const result = await getPlaiceholder(imageBuffer, options);
  return result;
};

次に、rehypeプラグイン本体を定義します。

rehype-image-optimizer/src/plugin.ts
import type { Root, Element } from 'hast';
import type { Plugin } from 'unified';
import { visit } from 'unist-util-visit';
import { getPlaceholder } from './placeholder';
import type { GetPlaceholderOptions } from './placeholder';

export type ImageElement = {
  tagName: 'img';
  url: string;
  properties: {
    src: string;
    width: number;
    height: number;
    blurDataURL: string;
  };
} & Element;

export const isImageElement = (node: Element): node is ImageElement => node.tagName === 'img';

export type RehypeImageOptimizerOptions = {
  basePath: string;
  placeholderOptions?: GetPlaceholderOptions;
};

/**
 * Optimizes images in the given HTML tree by adding the placeholder image base64 URL, width, and height.
 * These properties are generated in order to be passed to `next/image` component.
 * This plugin uses the `getImageBuffer` and `getPlaiceholder` functions to retrieve the image buffer and generate a blur data URL.
 * The optimized image properties (src, width, height, aspectRatio, blurDataURL) are added to the image node's data.
 * @param tree The HTML tree to optimize.
 * @see https://www.haxibami.net/blog/posts/blog-renewal#%E7%94%BB%E5%83%8F%E5%87%A6%E7%90%86
 */
export const rehypeImageOptimizer: Plugin<[RehypeImageOptimizerOptions?], Root> = (options) => async (tree) => {
  const promises: (() => Promise<void>)[] = [];
  visit(tree, 'element', (node: ImageElement | Element) => {
    // Check if node has tagName and tagName is 'img', while tagName could be undefined.
    if (!isImageElement(node)) return;
    // Now, we can safely assume that node is an image node.
    const { src } = node.properties;
    promises.push(async () => {
      try {
        // Retrieve the image buffer and generate the blur data URL and size data.
        const result = await getPlaceholder(src, options?.basePath ?? process.cwd(), options?.placeholderOptions);
        const {
          base64,
          metadata: { width, height },
        } = result;

        // Add the optimized image properties to the image node.
        node.properties = {
          ...node.properties,
          src,
          width,
          height,
          blurDataURL: base64,
        };
      } catch (e) {
        // If an error occurs, log it and throw it again, since contentlayer does not handle and output exceptions correctly.
        // eslint-disable-next-line no-console
        console.error('🖼️ rehypeImageOptimizer', e);
        throw e;
      }
    });
  });
  await Promise.allSettled(promises.map((t) => t()));
};

このファイルで名前付きエクスポートされているrehypeImageOptimizerをあなたのMarkdown処理系の設定に書き加えれば、導入は完了です。

rehypeプラグインを設定に追加する

先ほど作成したrehypeImageOptimizerをインポートして、rehypeプラグインの設定に追加しましょう。

contentlayer.config.ts
import { defineDocumentType, makeSource } from 'contentlayer/source-files';
import { rehypeGithubAlerts } from 'rehype-github-alerts';
import { rehypeImageOptimizer } from 'rehype-image-optimizer';
import rehypeKatex from 'rehype-katex';
import rehypePrettyCode from 'rehype-pretty-code';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import remarkUnwrapImages from 'remark-unwrap-images';

export const ContentDocument = defineDocumentType(() => ({
    //...
}));

const source: ReturnType<typeof makeSource> = makeSource({
  contentDirPath: 'docs',
  documentTypes: [ContentDocument],
  mdx: {
    remarkPlugins: [remarkGfm, remarkMath, remarkUnwrapImages],
    rehypePlugins: [
      [
        // @ts-expect-error TODO: Fix the type error, which seems to be caused by incorrect type definition provided by contentlayer
        rehypePrettyCode,
        {
          theme: {
            light: 'github-light',
            dark: 'github-dark',
          },
          keepBackground: false,
        },
      ],
      rehypeGithubAlerts,
      // @ts-expect-error TODO: Fix the type error, which seems to be caused by incorrect type definition provided by contentlayer
      rehypeKatex,
      // @ts-expect-error Ignore confusing `Pluggable` generics type error
      [rehypeImageOptimizer, { basePath: './public', placeholderOptions: { size: 32 } }],
    ],
  },
});

export default source;

MDXコンポーネントの設定でnext/imageを使うようにする

お使いのMarkdown/MDX処理系に合わせて、<img>タグを<Image>で描画するように設定しましょう。

この実装例では、altテキストがある場合には<figcaption>でそれを描画するようにしているため記述が長いです。

src/features/markup/components/mdxComponents.tsx
import type { MDXComponents } from 'mdx/types';
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import { Image } from '@/components/Image/Image';
import type { ImageProps } from '@/components/Image/Image';
import { css, cx } from 'styled-system/css';
import {
  markupImage,
} from 'styled-system/recipes';

// Define your custom MDX components.
export const mdxComponents: MDXComponents = {
  // Override the default <a> element to use the next/link component.
  // a: ({ href, children }) => <Link href={href as string}>{children}</Link>,
  // Add a custom component.
  // MyComponent: () => <div>Hello World!</div>,
  // @ts-expect-error Ignore difference between <img> and <Image> from next/image
  img: ({ src, alt, width, height, blurDataURL, className, ...props }: ImageProps): ReactNode => {
    const { img, figure, figcaption } = markupImage({ caption: !!alt });
    if (alt) {
      return (
        <figure className={figure}>
          <Image
            src={src}
            alt={alt}
            width={width}
            height={height}
            placeholder="blur"
            blurDataURL={blurDataURL}
            className={cx(img, className)}
            {...props}
          />
          <figcaption className={figcaption}>{alt}</figcaption>
        </figure>
      );
    }
    <Image
      src={src}
      alt={alt}
      width={width}
      height={height}
      placeholder="blur"
      blurDataURL={blurDataURL}
      className={cx(img, className)}
      {...props}
    />;
  },
  // ...
};

完成🎉

これで初回読み込み時でも見た目の変化が少なくなりました🥳

出典・謝辞

https://www.haxibami.net/blog/posts/blog-renewal#画像処理

Haxibami氏の上記ブログ記載のコードを強く参考にさせていただきました🙇

勝手ながら一部プロパティや型定義の修正、plaiceholderのv3.x系への移行に対応をさせたものを今回共有しました。大変助かりました。この場を借りて感謝申し上げます。

Discussion