microCMS + Astro|CMSとローカルの画像を使ったOGPイメージの自動生成方法
この記事では、microCMS + Astroで構成されているサイトで、OGPイメージをビルド時に自動生成する方法について説明します。
microCMSで持っている画像と、ソースコードと一緒に管理しているローカル画像をイメージに使用する方法も合わせて紹介します。
概要
Astroエンドポイント
Astroでは、どんな種類のデータでも提供できるカスタムエンドポイントを作成できます。これを利用して、今回はOGPイメージとしてPNGファイルを生成するエンドポイントを作成します。
下の様にsrc/pages/フォルダ配下に、エンドポイント用のtsファイルを作成する形となります。ビルド時には.tsが外れ、image.pngが生成されます。
src/pages/image.png.tsSatoriとsharpを使った画像データ生成
Satoriは、JSX構文で記載された要素をインプットとしてSVGデータを生成するパッケージです。これを活用して、サイトをデザインするように、OGPイメージのデザインを行います。
生成したSVGデータは、sharpを使ってPNGデータに変換します。
CMSとローカルの画像の利用
microCMSで持っている画像と、ソースコードと一緒に管理しているローカル画像をイメージに使用することも可能です。
今回は例として、下の様に背景はローカル画像、作者アイコンはmicroCMSで持っている画像を使うパターンで説明します。
 使用パッケージ
カッコ内は、記事作成で使用したバージョンです。
- astro (4.3.5)
 - satori (0.10.13)
 - sharp (0.33.2)
 - @astrojs/react (3.0.10)
 
上のパッケージが未インストールであれば、リンク先に従ってインストールしてください。
実装方法
エンドポイントの作成
下のmicroCMS公式ブログの通りにAstroブログが作成されているとして、実装するポイントを説明します。
ブログ記事詳細ページ (src/pages/[blogId].astro) に対応するOGPイメージを自動生成するようにします。
まずは、src/pages/og/フォルダを作成し、エンドポイントとして[blogId].png.tsを新規作成してください。
コードは下の通りです。ブログ記事詳細ページと同様、getStaticPathsで動的なパスをAstroに渡します。
また、GETでは、パラメータとして取得したblogIdから記事の詳細情報を取得し、それを元に生成したOGPイメージデータを返却しています。
OGPイメージデータを作成するgetOgImageは後で説明します。
import { getBlogs, getBlogDetail } from "../library/microcms";
import { getOgImage } from "../components/OgImage";
// 詳細記事ページの全パスを取得
export async function getStaticPaths() {
  const response = await getBlogs({ fields: ["id"] });
  return response.contents.map((content: any) => ({
    params: {
      blogId: content.id,
    },
  }));
}
export async function GET({ params }: APIContext) {
  if (params.blogId === undefined) {
    // blogIdがない場合は404を返す
    return new Response(null, { status: 404 });
  }
  // 記事の詳細情報を取得
  const blog = await getBlogDetail(blogId as string);
  // OGPイメージを生成
  return new Response(await getOgImage(blog));
}APIスキーマの設定
作者アイコンと作者名をmicroCMSから取得するため、下の様にAPIスキーマの設定を行います。authorImgの画像フィールドとauthorNameのテキストフィールドを追加してください。
 次に、src/library/microcms.tsの型定義Blogに、authorImgとauthorNameを追加してください。
//型定義
export type Blog = {
  id: string;
  createdAt: string;
  updatedAt: string;
  publishedAt: string;
  revisedAt: string;
  title: string;
  content: string;
  // 追加
  authorImg: {
    url: string;
    height: number;
    width: number;
  };
  // 追加
  authorName: string;
};コンポーネントの作成
OGPイメージデータを生成するコンポーネントを作成します。src/components/フォルダを作り、OgImage.tsxを新規作成してください。
コードの全体像は下の通りです。個々の処理を追って説明していきます。
import fs from "fs";
import satori from "satori";
import sharp from "sharp";
export async function getOgImage(blog: any) {
  // フォントを読み込む
  const fontRegularData = fs.readFileSync(
    "src/styles/fonts/NotoSansJP-Regular.ttf",
  );
  const fontBoldData = fs.readFileSync(
    "src/styles/fonts/NotoSansJP-Bold.ttf",
  );
  // ベースのローカル画像を読み込む
  const baseImage = Uint8Array.from(
    fs.readFileSync("src/assets/ogpImageBase.png"),
  ).buffer;
  // authorImgの画像を読み込む
  const authorImage = await fetch(blog.authorImg.url);
  const authorImageBuffer = await authorImage.arrayBuffer();
  const svg = await satori(
    <div
      style={{
        width: "1200px",
        height: "630px",
        display: "flex",
        position: "relative",
      }}
    >
      <img
        /* @ts-ignore リンターの警告がでる場合はignoreする */
        src={baseImage}
        width={1600}
        height={630}
        style={{
          height: "100%",
          width: "100%",
        }}
      />
      <div
        style={{
          width: "100%",
          height: "100%",
          position: "absolute",
          padding: "30px",
          fontFamily: "Noto Sans JP",
          display: "flex",
          flexDirection: "column",
          justifyContent: "space-between",
        }}
      >
        <div
          style={{
            flexGrow: 1,
            display: "flex",
            flexDirection: "column",
            justifyContent: "center",
          }}
        >
          <div
            style={{
              display: "flex",
              margin: "18px 30px",
              flexDirection: "column",
            }}
          >
            <div
              style={{
                fontSize: "58px",
                fontWeight: 700,
                lineHeight: "1.1",
              }}
            >
              {blog.title}
            </div>
          </div>
        </div>
        <div
          style={{
            marginLeft: "30px",
            marginBottom: "34px",
            display: "flex",
            alignItems: "center",
          }}
        >
          <img
            /* @ts-ignore リンターの警告がでる場合はignoreする */
            src={authorImageBuffer}
            width={blog.authorImg.width}
            height={blog.authorImg.height}
            alt="author image"
            style={{
              height: "100px",
              width: "100px",
              objectFit: "cover",
              borderRadius: "50%",
            }}
          />
          <div
            style={{
              fontSize: "40px",
              marginLeft: "12px",
            }}
          >
            {blog.authorName}
          </div>
        </div>
      </div>
    </div>,
    {
      width: 1200,
      height: 630,
      fonts: [
        {
          name: "Noto Sans JP",
          data: fontRegularData,
          weight: 400,
          style: "normal",
        },
        {
          name: "Noto Sans JP",
          data: fontBoldData,
          weight: 700,
          style: "normal",
        },
      ],
    },
  );
  // SVGをPNGに変換
  return await sharp(Buffer.from(svg)).png().toBuffer();
}コンポーネント詳細|フォントファイルの読み込み
Satoriでテキストをレンダリングする場合は、フォントデータを渡す必要があります。下のコードでは例として、Google FontsのNoto Sans Japaneseをダウンロードして、src/styles/fonts/フォルダに格納したファイルを読み込んでいます。
  // フォントを読み込む
  const fontRegularData = fs.readFileSync(
    "src/styles/fonts/NotoSansJP-Regular.ttf",
  );
  const fontBoldData = fs.readFileSync(
    "src/styles/fonts/NotoSansJP-Bold.ttf",
  );コンポーネント詳細|画像の読み込み
Satoriで画像を使うために、ローカルの画像とmicroCMSで登録するauthorの画像のデータを読み込んでいます。両方とも、Satoriで受け取れるArrayBufferオブジェクトになるようにしています。
対応している画像ファイル形式について、Satoriのドキュメントに記載はありませんでしたが、PNGは使用可能でした。こちらのissueにもある通り、現状WEBPは使用不可となっていました。
  // ベースのローカル画像を読み込む
  const baseImage = Uint8Array.from(
    fs.readFileSync("src/assets/ogpImageBase.png"),
  ).buffer;
  // authorの画像を読み込む
  const authorImage = await fetch(blog.authorImg.url);
  const authorImageBuffer = await authorImage.arrayBuffer();コンポーネント詳細|SVGデータの生成
satoriの第一引数にOGPイメージのJSX要素、第二引数にサイズ、フォントデータを渡してSVGデータを生成しています。backgroundImageにArrayBufferオブジェクトを渡すことができないため、imgタグを重ねるようになっています。
JSX要素に設定できるstyleには制限がありますので、詳細はこちらの公式ドキュメントを参照してください。
注意点として、複数の子ノードを持つdivタグにはdisplay: "flex"またはdisplay: "none"を明示する必要があります。
const svg = await satori(
    <div
      style={{
        width: "1200px",
        height: "630px",
        display: "flex",
        position: "relative",
      }}
    >
      
    /*** デザイン部分は省略 ***/
    </div>,
    {
      width: 1200,
      height: 630,
      fonts: [
        {
          name: "Noto Sans JP",
          data: fontRegularData,
          weight: 400,
          style: "normal",
        },
        {
          name: "Noto Sans JP",
          data: fontBoldData,
          weight: 700,
          style: "normal",
        },
      ],
    },
  );コンポーネント詳細|SVGからPNGデータへの変換
最後に、sharpを使ってSVGデータをPNGデータに変換しています。
  // SVGをPNGに変換
  return await sharp(Buffer.from(svg)).png().toBuffer();headerタグへのイメージパス指定
OGPイメージを生成するだけでなく、headerタグにイメージのパスを設定することも必要です。下のコードで今回作成したOGPイメージのパスが取得できます。
new URL(`/og/${blogId}.png`, Astro.url).toString()イメージパスをheaderタグに設定する方法は、この記事の本題から外れますので割愛します。
以上で実装は完了です。
動作確認
下のコマンドでビルドを実行し、dist/og/フォルダにOGPイメージが作成されることを確認してください。
npm run build関連タグの記事