🔖

Next.js × microCMSで作ったブログにブログカードを作成してみた

2021/12/17に公開

Next.js × microCMSで作ったブログにブログカードを設置してみました。
そもそもブログカードを作成する記事が少なかったのと、殆どがマークダウン形式でのデータを取得していて、リッチエディタで入稿してHTML形式で取得した場合の記事がなかったので覚え書きとして記事を残しておきます。

他の人の参考になればよいのと、JavaScript自体勉強中なのでもっと良い方法など指摘いただければ幸いです。

ブログカードとは

ブログカードというのは以下のようなカード型のリンクになります。

参考にした記事
https://zenn.dev/tomi/articles/2021-03-22-blog-card
https://raykeymas.com/posts/nextjs/blogcard

ただのテキストリンクよりもOGP画像やディスクリプションの情報があり、デザイン的にもリンク先の情報量的にもより良くなります。

WordPressだとプラグインなどで一発で設置できるのですが、自作ブログだと1から作る必要があるのが辛いところですね。WordPressは偉大。

ブログカードを表示する仕組み

ブログカードを実装するためには、最低限タイトルテキスト、ディスクリプションテキスト、OGP画像の3つの要素を取得する必要があります。
その取得から表示までの一連の仕組みは以下のようになります。

  1. 記事の情報を取得する
  2. aタグからリンク先のurlを抜き出す
  3. urlからリンク先のtext/htmlデータを取得
  4. metaタグにあるtitle, description, ogpのurlを取得
  5. htmlをパースする時にリンクカードにしたいaタグを判定し、ブログカードコンポーネントに置き換える

1.記事の情報を取得する

まずは通常通り、記事情報を取得します。
詳しくはこちら
https://blog.microcms.io/microcms-next-jamstack-blog/

aタグからリンク先のurlを抜き出す

cheerioというjQueryライクに記述できるパーサーを使って、htmlに記述してあるaタグのurlを抜き出します。
https://www.npmjs.com/package/cheerio

export const getStaticProps = async (context) => {
  const id = context.params?.id;
  const draftKey = context.previewData?.draftKey;
  // 記事取得
  const content: contentProps = await fetch(
    `https://hogehoge.microcms.io/api/v1/blog/${id}${
      draftKey !== undefined ? `?draftKey=${draftKey}` : ""
    }`,
    { headers: { "X-API-KEY": process.env.API_KEY || "" } }
  ).then((res) => res.json());
  // htmlをパース
  const $ = cheerio.load(content);
  //aタグのhrefの情報を配列で取得
  const links = $("a")
    .toArray()
    .map((data) => {
      // urlをhttps://~形式に
      const url =
        data.attribs.href.indexOf("http") === -1
          ? `${process.env.NEXT_PUBLIC_DOMEIN}${data.attribs.href}`
          : data.attribs.href;
      return { url: url };
    });
};

リッチエディタだと絶対パスのhttps://~形式では外部リンク、相対パスの/~形式では内部リンクとなり、混在しているので配列に格納する前に相対パスのリンクは絶対パスに直しています。

3.urlからリンク先のtext/htmlデータを取得

4.metaタグにあるtitle, description, ogpのurlを取得

コードがまとまっているため、3と4は一緒に解説します。

取り出したurlは各々fetchを行い、リンク先のtext/htmlデータを取得します。
そして、リンク先のデータからOGPで使うtitle, description, imageのurlを抜き出します。

export const getStaticProps = async (context) => {
  //ここから上は省略
  const links = $("a")
    .toArray()
    .map((data) => {
      // urlをhttps://~形式に
      const url =
        data.attribs.href.indexOf("http") === -1
          ? `${process.env.NEXT_PUBLIC_DOMEIN}${data.attribs.href}`
          : data.attribs.href;
      return { url: url };
    });
    let cardDatas = [];
    const temps = await Promise.all(
    links.map(async (link) => {
      //fetchでurl先のhtmlデータを取得
      const metas = await fetch(link.url)
        .then((res) => res.text())
        .then((text) => {
	    //各サイトのmetaタグの情報をすべてmetasの配列に
          const $ = cheerio.load(text);
          const metas = $("meta").toArray();
          const metaData = {
            url: link.url,
            title: "",
            description: "",
            image: "",
          };
	  //各サイトのmeta情報で、title,description,imageのurlだけ取り出す
          for (let i = 0; i < metas.length; i++) {
            if (metas[i].attribs?.property === "og:title")
              metaData.title = metas[i].attribs.content;
            if (metas[i].attribs?.property === "og:description")
              metaData.description = metas[i].attribs.content;
            if (metas[i].attribs?.property === "og:image")
              metaData.image = metas[i].attribs.content;
          }
          return metaData;
        })
        .catch((e) => {
          console.log(e);
        });
      return metas;
    })
  );
  //外部に情報を渡せるようにjson形式に整形
  cardDatas = temps.filter((temp) => temp !== undefined);
   return {
    props: {
            content,
      cardDatas,
    },
  };
};

cardDatasには各サイトのurl, title, description, imageのurlが格納されています。

5.htmlをパースする時にリンクカードにしたいaタグを判定し、ブログカードコンポーネントに置き換える

getStaticPropsから帰ってきたhtmlを記事にhtml形式で渡す時に、ブログカードにしたいaタグをブログカードのコンポーネントに置き換えます。

ポイントは2つ

  1. aタグをブログカードに置き換える方法
  2. ブログカードにしたいaタグを判定する方法

この2つを満たす方法を探すのにとても苦労しました。。。

aタグをブログカードに置き換える方法

ブログカードに置き換えるときはhtml-react-parserを使いました。
パースする時にreplaceオプションとして関数を渡すことができ、パースされた結果のノード単位でreplace関数が実行されます。replace関数にはreactのコンポーネントを返せるのでとても扱いやすいと思います。
https://www.npmjs.com/package/html-react-parser
https://kray.jp/blog/react-html-parser/

ブログカードにしたいaタグを判定する方法

ブログカードにしたいリンクというのは、インラインのテキストリンクではなくaタグのみで独立している部分になります。

microCMSから取得できるhtmlの条件で言うと

  • aタグ
  • 親のpタグから見てaタグ以外他の要素がない
export default function BlogContents({ contents, cardDatas }) {
  //replaceオプションに渡す関数
  const replace = (node) => {
    const cardLinks = cardDatas.map((data) => data.url);
    function link(text) {
      return text.indexOf(node.attribs?.href) != -1;
    }
    if (
      node.name === "a" && //タグがa
      node.parent?.children.length === 1 && //他に並列で要素を持っていない
    ) {
      //取得したノードのhrefの情報とcardDatasのurlと一致しているオブジェクトをコンポーネントに渡す
      const indexOfUrl = cardDatas.findIndex((obj) => {
        return obj.url.indexOf(node.attribs?.href) != -1;
      });
      return (
        <BlogCard cardData={cardDatas[indexOfUrl]}>
          {domToReact(node.children)}
        </BlogCard>
      );
    }
    return null;
  };

  return (
    <div className={styles.postContents}>{parse(contents, { replace })}</div>
  );
}

ブログカード例
スタイリングを抜いた記述例を残しておきます

const BlogCard = ({ cardData, children }) => {
  //内部リンクか外部リンク化判定
  const blank = cardData.url.indexOf(process.env.NEXT_PUBLIC_DOMEIN) === -1;
  const blankProp = blank
    ? {
        target: "_blank",
        rel: "noopener nofollow",
      }
    : {};
  if (cardData.title) {
    return (
      <a href={cardData.url} {...blankProp}>
        <div>
          <img
            src={cardData.image ? cardData.image : "/noimage.png"}
            alt=""
          />
        </div>
        <div>
          <p>{cardData.title && cardData.title}</p>
          <div>
            <p>
              {cardData.description && cardData.description}
            </p>
          </div>
        </div>
      </a>
    );
  }
  return (
    <a href={cardData.url} {...blankProp} underline="none">
      {children}
    </a>
  );
};

export { BlogCard };

まとめ

結論、他の方が実装していたものに少し追加したぐらいになってしまいましたが、ほぼ1から自分で作成していったので色々なノードやテキストでのデータの扱い方、非同期処理、パース、サーバーサイドとクライアントサイドについて等かなり学ぶことが多かったです。
まだ細かい部分は処理は詰める必要があると思いますが、リリースできるぐらいには形になってよかったです。

おまけ

実装はこんなデザインになりました。

よかったらブログも見に来てください。
https://micro-cms-blog-nu.vercel.app/

さらにおまけ

OPGのパーサーがありました笑
https://www.npmjs.com/package/ogp-parser

Discussion