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

March 10, 2024

はじめに

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

「カード型」というキーワードがなかなか出てこず、ずっと 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 が更新されていたので修正しました。

    さいごに

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