ブログ内の外部リンクをカード型のコンポーネントにした話

はじめに

ブログの外部リンクコンポーネントをカード型のコンポーネントにしました。 こういうやつです。

「カード型」というキーワードがなかなか出てこず、ずっと OGP 情報を設定する記事ばかり見つけてましたが、なんとか実装することができました。

アプローチ

marked によって記事の文字列を HTML タグに変換する際に、カスタムの renderer を噛ませています。 URLがそのまま書かれている部分のみを<a>タグではなくカード型のコンポーネントに置き換えるというアプローチです。

実装のはなし

カード型の外部リンクコンポーネントを作成するに当たり、実装したのは以下です。

  • Markdown の文字列(以下 content)から直書きの URL を抽出する関数
  • URL から OGP 情報を取得する関数
  • OGP情報をもとにカード型コンポーネントを描画するカスタム renderer

content から直書きの URL を抽出する

これは content を入力とし、正規表現にマッチした部分を抜き出し返却するものです。

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

  return slackingUrls ?? [];
}

"slacking" とは「怠ける・手を抜く」という意味です。 (という指摘がある時点で読みにくいですよね。全くリーダブルコードじゃないです。)

rawUrls とかでよかったですね。

URL から OGP 情報を取得する

OGP 情報の取得には open-graph-scraper というものを使いました。

上記の関数で取得した各 URL を入力とし、OGP データを返します。

export async function getOGPData(slackingUrls: string[]): Promise<OGPData[]> {
  const ogpData: OGPData[] = [];
  if (slackingUrls.length === 0) return ogpData;

  await Promise.all(
    slackingUrls.map(async (url) => {
      const options = { url, onlyGetOpenGraphInfo: true };
      return openGraphScraper(options)
        .then((data) => {
          if (!data.result.success) {
            // 失敗時の処理
            return;
          }
          ogpData.push(data.result);
        })
        .catch(() => {
          // エラー処理
          return;
        });
    }),
  );

  return ogpData;
}

OGP情報をもとにカード型コンポーネントを描画するカスタムrenderer

各記事(markdownファイル)で使用することになる OGP 情報を受け取り、カスタム renderer を返す関数を作成します。 返された renderer を marked.use() でプラグインとして登録しています。

function createLinkRenderer(ogpDatas: OGPData[]) {
  const renderer = new marked.Renderer();
  renderer.link = (href: string, title: string, text: string) => {
    const sanitizedUrl = sanitizeUrl(href ?? undefined);
    const ogpData = ogpDatas.find((data) => href === data.ogUrl || `${text}/` === data.ogUrl);

    if ((text !== href && `${text}/` !== href) || !ogpData) {
      return `
          <a href="${sanitizedUrl}" target="_blanck" rel="noreferrer" class="text-blue-700 dark:text-blue-500 hover:underline">${text}${title}</a>`;
    }

    const { ogImage } = ogpData;
    const image = Array.isArray(ogImage) ? ogImage[0] : ogImage;

    const domain = getDomainFromUrl(ogpData?.ogUrl);

    return `
        <div>
          <a href=${ogpData?.ogUrl} target="_blanck" class="og-link">
            <div class="og-container">
              <div class="og-thumbnail-container">
                <img src="${image?.url}" alt="${ogpData?.ogTitle}" class="og-thumbnail"/>
              </div>
              <div class="og-text-container">
                <p class="og-title">${ogpData?.ogTitle}</p>
                <p class="og-description">${ogpData?.ogDescription}</p>
                <div class="og-domain-container">
                  <img src="https://www.google.com/s2/u/0/favicons?domain=${domain}" alt="${domain}"/>
                  <div class="og-domain-name">${domain}</div>
                </div>
              </div>
            </div>
          </a>
        </div>`;
  };

  return { renderer };
}

スタイリングは css をいい感じに書いて,_app.tsx で読み込ませます。

仕上げ

以上のものを getStaticProps 内で呼び出し、各記事を生成します。

export async function getStaticProps({ params }: Params) {
  const post = getPostBySlug(params.slug, ['some', 'params']);

  // 直書きのURL抽出
  const slackingUrls = getSlackingUrls(post.content);
  // OGP データの取得
  const ogpData = await getOGPData(slackingUrls);
  // markdown の内容を html タグに変換
  const content = await markdownToHtml(post.content || "", ogpData);

  return {
    props: {
      post: {
        ...post,
        content,
      },
    },
  };
}

以上で完成です。

参考

(追記) リンク先ページのURLが更新されていたので修正しました。

さいごに

次はコードブロックのシンタックスハイライトですね。