Next.js と MDX でリンクカードを実装する

はじめに

Qiita や Zenn を始め巷のブログサービスには Markdown に貼った際、リンクをカード形式で表示するリンクカードなるものに対応されている。このサイトでもそれを実装してみたので手順などについて簡単にまとめる。

デモ

このサイトは Markdown ファイルでブログ記事を書いている。以下のように URL のみの行を書くとリンクカード形式で表示される。

some-article.md
https://github.com/ega4432

https://zenn.dev/ysmtegsr/articles/bd9b5935f40d73f80d8a
ega4432 - Overview
I'm a software engineer based in Fukuoka, Japan. ega4432 has 60 repositories available. Follow their code on GitHub.
ega4432 - Overview favicon github.com
ega4432 - Overview
S3 + CloudFront でホスティングしている静的 Web サイトをメンテナンス状態にする
S3 + CloudFront でホスティングしている静的 Web サイトをメンテナンス状態にする favicon zenn.dev
S3 + CloudFront でホスティングしている静的 Web サイトをメンテナンス状態にする

unified について

こちらの記事が理解の参考になったので、unified 周辺の言葉の定義が曖昧な場合は先にこちらを読んでいただくほう早いかもしれない。

unified を使う前準備
unified を使う前準備 favicon zenn.dev
unified を使う前準備

実装手順

使用したライブラリ

使用している技術は以下である。

  • フレームワーク
    • React/Next.js
  • MDX ライブラリ
    • mdx-bundler

選定した理由としては、まず前提としてこのサイトは以下のリポジトリを fork しているものになっている。

GitHub - timlrx/tailwind-nextjs-starter-blog: This is a Next.js, Tailwind CSS blogging starter template. Comes out of the box configured with the latest technologies to make technical writing a breeze. Easily configurable and customizable. Perfect as a replacement to existing Jekyll and Hugo individual blogs.
This is a Next.js, Tailwind CSS blogging starter template. Comes out of the box configured with the latest technologies to make technical writing a breeze. Easily configurable and customizable. Per...
GitHub - timlrx/tailwind-nextjs-starter-blog: This is a Next.js, Tailwind CSS blogging starter template. Comes out of the box configured with the latest technologies to make technical writing a breeze. Easily configurable and customizable. Perfect as a replacement to existing Jekyll and Hugo individual blogs. favicon github.com
GitHub - timlrx/tailwind-nextjs-starter-blog: This is a Next.js, Tailwind CSS blogging starter template. Comes out of the box configured with the latest technologies to make technical writing a breeze. Easily configurable and customizable. Perfect as a replacement to existing Jekyll and Hugo individual blogs.

そのため MDX をバンドルするライブラリはこちらで使用されている mdx-bundler をそのまま使用した。

GitHub - kentcdodds/mdx-bundler: 🦤 Give me MDX/TSX strings and I'll give you back a component you can render. Supports imports!
🦤 Give me MDX/TSX strings and I'll give you back a component you can render. Supports imports! - kentcdodds/mdx-bundler
GitHub - kentcdodds/mdx-bundler: 🦤 Give me MDX/TSX strings and I'll give you back a component you can render. Supports imports! favicon github.com
GitHub - kentcdodds/mdx-bundler: 🦤 Give me MDX/TSX strings and I'll give you back a component you can render. Supports imports!

プラグインを実装する

リンクだけのパラグラフをリンクカードに変換する unified プラグインを実装する。かなり長いので一旦全体を貼り付けつつ、ポイントだけを後で解説する。

lib/remark-link-card.ts
import { Parent, Position } from 'unist'
import { visit } from 'unist-util-visit'
import getMetadata from 'metadata-scraper'

const URL_REGEXP =
  /^https?:\/\/[-_.!~*\'()a-zA-Z0-9;\/?:\@&=+\$,%#\u3000-\u30FE\u4E00-\u9FA0\uFF01-\uFFE3]+$/g
const MY_HOST = 'egashira.dev'

type LinkNode = Parent & {
  children: { type: string; value: string; position: Position }[]
  url: string
  title: string | null
}

type Meta = {
  url: string
  title: string
  description: string
  image: string
  icon: string
}

type JsxElement = {
  type: 'mdxJsxFlowElement' | 'mdxJsxTextElement'
  name: string
  attributes: JsxAttribute[]
  children: (JsxElement | TextElement)[]
}

type TextElement = {
  type: 'text'
  value: string
}

type JsxAttribute = {
  type: 'mdxJsxAttribute'
  name: string
  value: string
}

const remarkCardLinks = () => {
  return async (tree: Parent) => {
    const promises: (() => Promise<void>) = []

    const visitor = (node: Parent) => {
      const linkNode = node.children.find((n) => n.type === 'link') as LinkNode
      const url = linkNode.url

      promises.push(async () => {
        const meta = await fetchMeta(url)
        if (!meta) {
          return
        }

        const domain = new URL(url)
        const isExternal = domain.hostname !== MY_HOST
        const main = {
          type: 'mdxJsxFlowElement',
          name: 'div',
          attributes: [
            { type: 'mdxJsxAttribute', name: 'className', value: 'remark-link-card-main' }
          ],
          children: [
            {
              type: 'mdxJsxTextElement',
              name: 'div',
              attributes: [
                { type: 'mdxJsxAttribute', name: 'className', value: 'remark-link-card-title' }
              ],
              children: [{ type: 'text', value: meta.title }]
            }
          ]
        } as JsxElement

        if (meta.description) {
          main.children.push({
            type: 'mdxJsxTextElement',
            name: 'div',
            attributes: [
              { type: 'mdxJsxAttribute', name: 'className', value: 'remark-link-card-description' }
            ],
            children: [{ type: 'text', value: meta.description }]
          })
        }

        if (meta.icon) {
          main.children.push({
            type: 'mdxJsxFlowElement',
            name: 'div',
            attributes: [
              {
                type: 'mdxJsxAttribute',
                name: 'className',
                value: 'remark-link-card-origin'
              }
            ],
            children: [
              {
                type: 'mdxJsxFlowElement',
                name: 'img',
                attributes: [
                  { type: 'mdxJsxAttribute', name: 'alt', value: url },
                  { type: 'mdxJsxAttribute', name: 'src', value: meta.icon }
                ],
                children: []
              },
              {
                type: 'mdxJsxTextElement',
                name: 'div',
                attributes: [
                  { type: 'mdxJsxAttribute', name: 'className', value: 'remark-link-card-domain' }
                ],
                children: [{ type: 'text', value: domain.hostname }]
              }
            ]
          })
        }

        const image = {
          type: 'mdxJsxFlowElement',
          name: 'div',
          attributes: [
            { type: 'mdxJsxAttribute', name: 'className', value: 'remark-link-card-image' }
          ],
          children: []
        } as JsxElement

        const linkCardNode = {
          type: 'mdxJsxFlowElement',
          name: 'a',
          attributes: [
            { type: 'mdxJsxAttribute', name: 'className', value: 'remark-link-card-wrapper' },
            { type: 'mdxJsxAttribute', name: 'href', value: isExternal ? url : domain.pathname }
          ],
          children: [main, image]
        } as JsxElement

        if (isExternal) {
          linkCardNode.attributes.push({ type: 'mdxJsxAttribute', name: 'target', value: '_blank' })
        }

        if (meta.image) {
          (linkCardNode.children[1] as JsxElement).children.push({
            type: 'mdxJsxFlowElement',
            name: 'img',
            attributes: [
              { type: 'mdxJsxAttribute', name: 'alt', value: meta.url },
              { type: 'mdxJsxAttribute', name: 'src', value: meta.image }
            ],
            children: []
          })
        }

        node.type = 'LinkCard'
        node.children = [linkCardNode]
      })
    }

    visit(tree, isLink, visitor)
    await Promise.all(promises.map((t) => t()))
  }
}

const isLink = (node: Parent): node is Parent =>
  node.type === 'paragraph' &&
  node.children &&
  node.children.length === 1 &&
  node.children[0].type === 'link' &&
  (node.children[0] as LinkNode).children[0].value.match(URL_REGEXP) !== null

const fetchMeta = async (url: string): Promise<Meta | null> => {
  return await getMetadata(url)
    .then((meta) => {
      return {
        url,
        title: meta.title || '',
        description: meta.description || '',
        image: meta.image || '',
        icon: meta.icon || ''
      }
    })
    .catch((e) => {
      console.error(e)
      return null
    })
}

export default remarkCardLinks

まずプラグインの肝は以下の部分で第一引数にノードすなわち MDAST(Markdown AST)を受け取り、第二引数にこのプラグインで処理したいノードの条件を、第三引数で実際に処理内容を書く。

const remarkCardLinks = () => {
  visit(tree, isLink, visitor);
};

ただ、通常ならこれでいいが、今回のように HTTP 通信などが必要な場合は非同期処理が必要にある。しかし、visitor を async/await で書くことは現状できなかったためこの issue を参考に以下のようにした。

Support for async visitors · Issue #8 · syntax-tree/unist-util-visit-parents
In my opinion, this should be working: await visit( tree, 'code', visitor ) function visitor( node, index, parent ) { return new Promise( resolve => { const removeNode = () => { parent.children.spl...
Support for async visitors · Issue #8 · syntax-tree/unist-util-visit-parents favicon github.com
Support for async visitors · Issue #8 · syntax-tree/unist-util-visit-parents
const remarkCardLinks = () => {
  const promises: (() => Promise<void>)[] = [];
  const visitor = () => {
    promises.push(async () => {
      // 非同期処理
      // ex: await axios.get('https://api.example.com')
    });
  };
  visit(tree, isLink, visitor);
  await Promise.all(promises.map((t) => t()));
};

次に mdxJsxFlowElement, mdxJsxTextElement みたいなものが出てきているのは何かと思われるかもしれない。これは mdast-util-mdx-jsx という MDX 中の JSX タグをパースするもので mdxJsxFlowElement だとブロック要素、mdxJsxTextElement だとインライン要素に変換される。

GitHub - syntax-tree/mdast-util-mdx-jsx: mdast extension to parse and serialize MDX JSX
mdast extension to parse and serialize MDX JSX. Contribute to syntax-tree/mdast-util-mdx-jsx development by creating an account on GitHub.
GitHub - syntax-tree/mdast-util-mdx-jsx: mdast extension to parse and serialize MDX JSX favicon github.com
GitHub - syntax-tree/mdast-util-mdx-jsx: mdast extension to parse and serialize MDX JSX
{
  type: "mdxJsxFlowElement",
  name: "div",
  attributes: [{ type: "mdxJsxAttribute", name: "className", value: "contents" }],
  children: [{ type: "text", value: "This is contents!" }]
}

上のようなものは以下の HTML へとパースされる。

<div class="contents">This is contents!</div>

サイトのメタデータの取得には metadata-scraper というライブラリを使用した。

import getMetadata from "metadata-scraper";

const fetchMeta = async (url: string): Promise<Meta | null> => {
  return await getMetadata(url);
};
GitHub - BetaHuhn/metadata-scraper: 🏷️ A JavaScript library for scraping/parsing metadata from a web page.
🏷️ A JavaScript library for scraping/parsing metadata from a web page. - BetaHuhn/metadata-scraper
GitHub - BetaHuhn/metadata-scraper: 🏷️ A JavaScript library for scraping/parsing metadata from a web page. favicon github.com
GitHub - BetaHuhn/metadata-scraper: 🏷️ A JavaScript library for scraping/parsing metadata from a web page.

プラグインを mdx-bundler に適用する

実装したプラグインを mdx-bundler に読み込ませる。

lib/mdx.ts
const { code, frontmatter } = await bundleMDX({
    source,
    cwd: path.join(process.cwd(), 'components'),
    xdmOptions(options, frontmatter) {
        options.remarkPlugins = {
            ...(options.remarkPlugins ?? []),
           remarkCardLinks
        }
    }
})

今後の課題

今回は対応しなかったが、いずれやりたいこと。

  • Twitter URL によるツイートを埋め込み
  • GitHub, gist URL によるソースコードの表示およ行指定対応 プレゼンテーションサービスのリンクへの対応(SlideShare, SpeakerDeck, etc …)
  • OGP 画像のレスポンシブ対応(現状スマホの場合見切れる場合がある)

まとめ

自分が選定していないライブラリで実装するのは結構骨の折れる作業だった。トレードオフなのでなんとも言えないが、テンプレートリポジトリやスターターキットを使うとこういうカスタマイズに柔軟に対応できないデメリットがあると感じた。

参考

Next.jsでブログをつくった
自作ブログの実装について
Next.jsでブログをつくった favicon www.haxibami.net
Next.jsでブログをつくった
Next.js のための Remark / Rehype 入門 - Qiita
この記事は Jamstack Advent Calendar 2020 4日目の記事です。はじめにMarkdown -&gt; HTML の変換に使われる事が多い、Remark / Rehype につ…
Next.js のための Remark / Rehype 入門 - Qiita favicon qiita.com
Next.js のための Remark / Rehype 入門 - Qiita
MarkdownをHTMLに変換するunifiedインターフェースについての解説 - Qiita
はじめにこの記事はマークアップ言語変換インターフェイスunifiedについて解説、共有するためのものです。node.js環境でQiitaのマークダウンファイルをHTMLに変換する過程を題材に、un…
MarkdownをHTMLに変換するunifiedインターフェースについての解説 - Qiita favicon qiita.com
MarkdownをHTMLに変換するunifiedインターフェースについての解説 - Qiita
zenn-editor/packages/zenn-markdown-html at canary · zenn-dev/zenn-editor
Convert markdown to html in Zenn format. Contribute to zenn-dev/zenn-editor development by creating an account on GitHub.
zenn-editor/packages/zenn-markdown-html at canary · zenn-dev/zenn-editor favicon github.com
zenn-editor/packages/zenn-markdown-html at canary · zenn-dev/zenn-editor
GitHub - kentcdodds/mdx-bundler: 🦤 Give me MDX/TSX strings and I'll give you back a component you can render. Supports imports!
🦤 Give me MDX/TSX strings and I'll give you back a component you can render. Supports imports! - kentcdodds/mdx-bundler
GitHub - kentcdodds/mdx-bundler: 🦤 Give me MDX/TSX strings and I'll give you back a component you can render. Supports imports! favicon github.com
GitHub - kentcdodds/mdx-bundler: 🦤 Give me MDX/TSX strings and I'll give you back a component you can render. Supports imports!