【Next.js】markdown-itでリンクカードを作成する

2024-04-15に公開

当ブログにリンクカードを実装しました!

直書きのURLに対してリンクカードを生成するようにしております。
なかなか調べても情報が少なかったので、
catnoseさんのzenn-editorのコードで勉強させていただきました。

誰かの参考にしていただけたら嬉しいです!


使用するもの

  • markdown-it
    • markdownからhtmlにパースする
  • open-graph-scraper
    • OGP情報を簡単にスクレイピングして取得できるライブラリです

ロジック(このサイト)

  1. マークダウンデータから直書きリンクを探す
  2. OGP情報を取得
  3. markdown-it用のプラグインを自作する
  4. markdown-itでのパース時に、自作したプラグインを読みこむ
  5. 1,2,4をサーバー側で読み込む

1. マークダウンデータから直書きリンクを探す

このブログはマークダウンデータをDBに保存しています。
データから直書きリンクを正規表現で抽出し配列に入れます。

export function extractLinks(md: string) {
  const regFloatLink =
    /(?<!\()https?:\/\/[-_.!~*\\'()a-zA-Z0-9;\\/?:\\@&=+\\$,%#]+/g;
  const floatLinks = md.match(regFloatLink);

  return floatLinks ?? [];
}

2. OGP情報を取得

1で取得したリンクを元に、open-graph-scraperを使用し直書きリンクからOGP情報を取得します。
これは、リンクカードを表示するときのテキストやfavicon, ogp画像を使用するためです。

export async function getOgpData(
  autolink: string[],
): Promise<OgpDataRes[] | undefined> {
  let res: any = [];

  if (autolink.length === 0) {
    return res;
  }
  if (!autolink) return;

  await Promise.all(
    autolink.map(async (link) => {
      const { error, html, result, response } = await ogs({
        url: link,
        timeout: 5000,
      });

      // エラー時とデータ取得失敗の際
      if (error || !result.success) {
        if (error) {
          console.error('ogp取得エラー1', error);
        } else if (!result.success) {
          console.error('ogp取得エラー2', result.success);
        }

        return response;
      }

      // OGPデータ取得に成功;
      res.push(result);
    }),
  );

  return res;
}

無事、OGPデータを取得できると下記のように情報が返ってきます。
いくつか情報が返ってきますが、
このサイトで使用しているのは下記になります。

  • ogSiteName
  • ogTitle
  • favicon
  • ogImageのurl, alt

3. markdown-it用のプラグインを自作する

プラグインの作成に関しては、下記の記事を参考にさせていただきました。

export function linkCard(md: MarkdownIt, ogpDatas: OgpDataRes[]) {
  md.core.ruler.after('replacements', 'link_card', ({ tokens }) => {
    // https://から始まるリンクのみが対象
    const linkRegex = /^https:\/\//;

    tokens.forEach((token, i) => {
      // 直書きリンクはinlineのchildrenにのみ存在するのでそれ以外は除外
      if (token.type !== 'inline') return;
      if (!token.children) return;

      // リンクカードの生成
      token.children.forEach((child) => {
        if (
          child.content &&
          child.type === 'text' &&
          child.markup !== 'linkify' &&
          linkRegex.test(child.content)
        ) {
          // ogpDataのリンクと同じ場合htmlを生成する
          const ogpData = ogpDatas.find((data) => {
            return data.ogUrl === child.content;
          });
          console.log(ogpData);

          if (ogpData) {
            child.content = `
              <section class="md-link-card">
                <div class="og-text">
                  <h1>${ogpData.ogTitle}</h1>
                  <div class="og-text__icon">
                    ${
                      ogpData.favicon
                        ? `<img src="${ogpData.favicon ?? 'リンクカードのfaviconです'}" />`
                        : ``
                    }
                    <p>${ogpData.ogSiteName}</p>
                  </div>
                </div>
                <div class="og-img">
                  <img src="${ogpData.ogImage[0].url}" alt="${ogpData.ogImage[0].alt ?? 'リンクカードのイメージです'}">
                </div>
              </section>
            `;
            child.type = 'html_block';
          }
        }
      });
    });
  });

  return true;
}

4.markdown-itでのパース時に、自作したプラグインを読みこむ

3で作成したlinkCardプラグインをパース時に動くように読み込みます。

import MarkdownIt from 'markdown-it';
import { linkCard } from './linkCard';

export function changeHtml(markdown: string, ogpDatas?: any) {
  // オプションが別途必要な場合はlinkifyの後に書けばOK
  const md: MarkdownIt = MarkdownIt({
    html: true,
    linkify: true,
  });

  // 自作プラグインを読み込み取得したOGP情報を渡す
  if (ogpDatas) {
    md.use(linkCard, ogpDatas);
  }

  return md.render(markdown);
}

5. 1,2,4をサーバー側で読み込む

これで準備ができたので、Next.jsのgetStaticPropsで実行します。
SSR, ISRでも同様で大丈夫だと思います。

export const getStaticProps: GetStaticProps = async () => {
  // response.notice.contentの中にマークダウンデータが入っています。

  --- 省略 ---

  // リンクカード生成
  const floatLink = extractLinks(response.notice.content ?? '');
  const ogpDatas = await getOgpData(floatLink);

  // マークダウンをhtmlに変換
  const htmlContent = changeHtml(response.notice.content ?? '', ogpDatas);

  --- 省略 ---
}

あとはCSSで装飾すればリンクカードが完成します!
当ブログはGithubで公開しておりますので、
コードの全般は下記のリンクカードからご覧いただければと思います。


https://github.com/zaki-app/zaki-dev