no7.space
Blog Post Emoji

リンクカードを作る

最近のブログだったり、Qiita とか Zenn とかだと URL だけが書かれた行が自動的にリンクカードに変換されて、リンク先のタイトルや OGP 画像なんかが表示されたりしますよね。
自分も記事を作るときに参考にしたリンクとか使ったライブラリへのリンクを貼ることがありますが、そういうときにもこの機能があれば便利だよなってことで、このブログにも導入することにしました。

定番のライブラリ

今回は Markdown をパースして表示してるので、Remark + Rehype を使っています。
その場合だと、remark-link-card というのがあってそれが定番らしいのですが、いくつか不具合があったりして、それを更に機能改善した remark-link-card-plus というものが作られています。

私も当初はこれを使おうと思ったのですが、なぜかうまく組み込むことができず(このライブラリは取得した OGP 画像とかをキャッシュできるんですがキャッシュ自体は生成されてたり、データは取れてそうな感じなんですけど、表示ができず…)仕方がないので自作することにしました。

リンクカード生成機能を自作する

Important

自作する場合はセキュリティリスクや、相手のサーバーに迷惑がかからないように慎重に行う必要があります。
なので、基本的にはライブラリに乗っかるのが一番安全だと思います。
自分のようにどうしても自作する必要がある場合とか、学習目的であえて自分で作るという場合は、上記に気をつけながら作るようにしてください。

仕様を考える

今回は、remark 側のプラグインと rehype 側のプラグイン両方を作ります。
reamark 側でURLのみの行を検知したら、mdast を拡張して link-card という要素を追加し、
rehype 側はそれを見つけたら LinkCard コンポーネントを呼び出す、みたいな感じです

remark 側のプラグイン

export interface LinkCardNode {
  type: 'link-card'
  url: string
}

declare module 'mdast' {
  interface RootContentMap {
    'link-card': LinkCardNode
  }
}

export const mdLinkCardRemark: Plugin<[], MdastRoot> = () => {
  return (tree: MdastRoot) => {
    return visit(tree, 'paragraph', (node: Paragraph, index, parent) => {
      const targetChild = node.children[0]

      if (node.children.length !== 1) return
      if (targetChild.type !== 'link') return
      if (targetChild.children.length !== 1) return

      const isPlainLink =
        targetChild.children[0].type === 'text' &&
        targetChild.url === targetChild.children[0].value
      if (!isPlainLink) return

      if (!parent || index === undefined) return

      parent.children[index] = {
        type: 'link-card',
        url: targetChild.url,
      }
    })
  }
}

paragraph を順番にチェックしていき、条件にマッチするものが現れるまでスキップし続けます。

  • 段落ノードの子要素が1つだけである(1行しか書かれてない)
  • 子要素のノードタイプがリンクとなっている
  • リンクノードの子も1つだけである

で、更に isPlainLink のチェックは、リンク先のURLと、リンクのテキストが同じかをチェックしています。これは、Markdown でURLのみ貼ると自動的に URL へのリンクが作られて、リンクテキストも URL になる、つまりこんなじょうたいになってることを利用しています

<a href="https://example.com/" >https://exapmle.com</a>

最後に、parent がなかったり index が undefined みたいな場合も除外。

ここまでの条件をすべてクリアできたノードのみ、晴れて link-card ノードに差し替えになります

parent.children[index] = {
  type: 'link-card',
  url: targetChild.url,
}

rehype 側のプラグイン

続いては rehype のプラグインを作ります

export function mdLinkCardRehype() {
  return (tree: HastRoot) => {
    visit(tree, 'link-card', (node: LinkCardNode, index, parent) => {
      if (!parent || index === undefined) return

      ;(parent as Parent).children[index] = {
        type: 'element',
        tagName: 'link-card',
        properties: { url: node.url },
        children: [],
      } as unknown as Element
    })
  }
}

こっちは、link-card ノードを受け取ったら、カスタムHTMLタグの link-card を持つ Element(というか React コンポーネント)を出力する感じにします。プロパティとして url を渡しておくことで、このあとコンポーネントの props から URL を受け取れるようになります。

ちなみに ;(parent as Parent); からスタートしてる行がありますが、これは直前の行が return で終わってるためで、
通常 ; は省略できますが、予期しない動作を防ぐためにこのようにするらしいです

remarkRehype のハンドラー

ところで、link-card というノードは、私が勝手に拡張したやつですので、rehype さんはそれが何者かを知りません。
なので、link-card というタグが来た場合にどう変換するかを設定しておきます

export const mdLinkHandler = (_: unknown, node: LinkCardNode) => {
  return {
    type: 'element',
    tagName: 'link-card',
    properties: {
      url: node.url,
    },
    children: [],
  }
}

markdown パーサに組み込む

ということで、こんな感じでそれぞれプラグインとして読み込ませることで、URLのみの行のときに LinkCard コンポーネントが表示できるようになりました。

export async function remarkToReact(markdown: string) {

  const file = await unified()
      .use(remarkParse)
      // remark plugins
      .use(mdLinkCardRemark) // ←追加
      .use(remarkGfm)
      .use(remarkGithubAlerts)
      .use(remarkBreaks)
      // end -- remark plugins
      .use(remarkRehype, {
        handlers: {  // ←追加
          'link-card': mdLinkHandler,
        },
      })
      // rehype plugins
      .use(rehypeShiki, {
        theme: 'catppuccin-mocha',
        defaultLanguage: 'text',
      } satisfies RehypeShikiOptions)
      // end -- rehype plugins
      .use(mdLinkCardRehype) // ←追加
      .use(rehypeReact, {
        createElement: React.createElement,
        ...jsxRuntime,
        Fragment: React.Fragment,
        components: {
          'link-card': LinkCard,
        },
      })
      .use(rehypeWrapTables, {
        className: 'table-container',
      })
      .process(markdown)
  return file.result as React.ReactNode
}

リンク先のサイトのメタデータを取得する

fetch してリンク先の HTML を取得し、それをスクレイピングライブラリの cheerio に入れて必要そうな要素のデータを集めます
この時点ではまだ何がほしいか手探りなのでいろいろ取ってますが、表示させたい内容が固まればこの辺はもっと減らせると思います

import * as cheerio from 'cheerio'

export type SiteMetadata = {
  url: string
  host?: string
  siteName?: string
  title?: string
  description?: string
  image?: string
  type?: string
}

export async function getSiteMetadata(
  url: string
): Promise<SiteMetadata | null> {
  try {
    const response = await fetch(url, {
      next: {
        revalidate: 60 * 60 * 24 * 7, // キャッシュを7日間保持
      },
    })

    if (!response.ok) {
      return null
    }

    const { host } = new URL(url)

    const html = await response.text()
    const metadata: SiteMetadata = { url, host }
    const $ = cheerio.load(html)

    metadata.title = $('title').text() || undefined

    Object.assign(metadata, {
      siteName:
        $('meta[property="og:site_name"]').attr('content') || metadata.title,
      title: $('meta[property="og:title"]').attr('content') || metadata.title,
      description:
        $('meta[property="og:description"]').attr('content') ||
        $('meta[name="description"]').attr('content') ||
        undefined,
      image: $('meta[property="og:image"]').attr('content') || undefined,
      type: $('meta[property="og:type"]').attr('content') || undefined,
    })

    return metadata
  } catch (e) {
    console.error(e)
    return null
  }
}

LinkCard コンポーネントを作る

最後に、React コンポーネントを作ります

import { Suspense } from 'react'
import { getSiteMetadata } from '@/lib/get-site-metadata'
import styles from './LinkCard.module.scss'

type LinkCardProps = {
  url: string
}

export default function LinkCard({ url }: LinkCardProps) {
  return (
    <Suspense fallback={null}>
      <LinkCardContent url={url} />
    </Suspense>
  )
}

async function LinkCardContent({ url }: LinkCardProps) {
  const siteMeta = await getSiteMetadata(url)
  return siteMeta ? (
    <div className={styles['link-card']}>
      <div className={styles['link-card-metadata']}>
        <div className={styles['link-card-metadata--title']}>
          <a href={siteMeta.url} target="_blank" rel="noopener noreferrer">
            {siteMeta.title}
          </a>
        </div>
        <div className={styles['link-card-metadata--description']}>
          {siteMeta.description}
        </div>
        <div className={styles['link-card-metadata--host']}>
          <a href={siteMeta.url} target="_blank" rel="noopener noreferrer">
            {siteMeta.host}
          </a>
        </div>
      </div>
      {siteMeta.image && (
        <div className={styles['link-card-thumbnail']}>
          <img
            className={styles['link-card-thumbnail--image']}
            src={siteMeta.image}
            alt={siteMeta.title}
          />
        </div>
      )}
    </div>
  ) : (
    <div>
      <a href={url} target="_blank" rel="noopener noreferrer">
        {url}
      </a>
    </div>
  )
}

とりあえずこれで、それらしくはできたので、あとは細かな調整をしていけば、よさそうです

続く

https://no7.space/blog/01K496MV1K369A8VR567BQXMC2

参考