Ryota Kondo

Ryota Kondo

2024/02/19

microCMS + Astro|CMSとローカルの画像を使ったOGPイメージの自動生成方法

この記事では、microCMS + Astroで構成されているサイトで、OGPイメージをビルド時に自動生成する方法について説明します。

microCMSで持っている画像と、ソースコードと一緒に管理しているローカル画像をイメージに使用する方法も合わせて紹介します。

概要

Astroエンドポイント

Astroでは、どんな種類のデータでも提供できるカスタムエンドポイントを作成できます。これを利用して、今回はOGPイメージとしてPNGファイルを生成するエンドポイントを作成します。

下の様にsrc/pages/フォルダ配下に、エンドポイント用のtsファイルを作成する形となります。ビルド時には.tsが外れ、image.pngが生成されます。

src/pages/image.png.ts

Satoriとsharpを使った画像データ生成

Satoriは、JSX構文で記載された要素をインプットとしてSVGデータを生成するパッケージです。これを活用して、サイトをデザインするように、OGPイメージのデザインを行います。

生成したSVGデータは、sharpを使ってPNGデータに変換します。

CMSとローカルの画像の利用

microCMSで持っている画像と、ソースコードと一緒に管理しているローカル画像をイメージに使用することも可能です。

今回は例として、下の様に背景はローカル画像、作者アイコンはmicroCMSで持っている画像を使うパターンで説明します。

CMSとローカルの画像の利用

使用パッケージ

カッコ内は、記事作成で使用したバージョンです。

上のパッケージが未インストールであれば、リンク先に従ってインストールしてください。

実装方法

エンドポイントの作成

下のmicroCMS公式ブログの通りにAstroブログが作成されているとして、実装するポイントを説明します。

ブログ記事詳細ページ (src/pages/[blogId].astro) に対応するOGPイメージを自動生成するようにします。

まずは、src/pages/og/フォルダを作成し、エンドポイントとして[blogId].png.tsを新規作成してください。

コードは下の通りです。ブログ記事詳細ページと同様、getStaticPathsで動的なパスをAstroに渡します。

また、GETでは、パラメータとして取得したblogIdから記事の詳細情報を取得し、それを元に生成したOGPイメージデータを返却しています。

OGPイメージデータを作成するgetOgImageは後で説明します。

src/pages/og/[blogId].png.ts
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のテキストフィールドを追加してください。

APIスキーマの設定

次に、src/library/microcms.tsの型定義Blogに、authorImgauthorNameを追加してください。

//型定義
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を新規作成してください。

コードの全体像は下の通りです。個々の処理を追って説明していきます。

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

関連タグの記事

Ryota Kondo
Ryota Kondo

システムエンジニア・プログラマー|このブログサイトの運営もしており、思いついたことをまとめて記事を書いています💡