Next.jsでmarkdownを使う

引き続きNext.jsをちまちまいじっています。

このブログを移行することを取り急ぎの目標にして作成中です。

ブログなのでmarkdown使えるようにしたら色々楽だろうということで今週その辺りを作っていました。

markdown関連のライブラリ

markdownをHTMLに変換するライブラリは数多くあります。

代表的なところで言うとmarked, markdown-it, remark辺りでしょうか。

ちなみに最近ではmarkdownとJSXを組み合わせたMDXというものもあるとチームのメンバーから教えてもらいました。

これを使えばmarkdown中でReactのコンポーネントを使うことができるようです。

公式ドキュメントに載ってた例ではこんな感じ。

import {Chart} from './snowfall.js'
export const year = 2018

# Last year’s snowfall

In {year}, the snowfall was above average.
It was followed by a warm spring which caused
flood conditions in many of the nearby rivers.

<Chart year={year} color="#fcb32c" />

確かにかなり便利そう。

ただ、markdownでブログを書く際にはあまりコンポーネントを意識したくないなというところです。

「ここでこれを表示したいからこのコンポーネントを使って、そのpropsはこれとこれで...」というのを考えながら書くのがちょっと面倒な気がします。

なので今回は別の方法を採ってみることにしました。

react-markdown

今回はreact-markdownを使うことにしました。

基本的な使い方は以下の通り。

import React from 'react'
import ReactMarkdown from 'react-markdown'
import ReactDom from 'react-dom'

ReactDom.render(<ReactMarkdown># Hello, *world*!</ReactMarkdown>, document.body)

<ReactMarkdown>コンポーネントでラップするだけ。簡単です。

で、先程markdown中でコンポーネントを使う話をしましたが、componentsプロパティで設定できます。

Use custom components (syntax highlight)

This example shows how you can overwrite the normal handling of an element by passing a component. In this case, we apply syntax highlighting with the seriously super amazing react-syntax-highlighter by @conorhastings:

import React from 'react'
import ReactDom from 'react-dom'
import ReactMarkdown from 'react-markdown'
import {Prism as SyntaxHighlighter} from 'react-syntax-highlighter'
import {dark} from 'react-syntax-highlighter/dist/esm/styles/prism'

// Did you know you can use tildes instead of backticks for code in markdown? ✨
const markdown = `Here is some JavaScript code:

~~~js
console.log('It works!')
~~~
`

ReactDom.render(
  <ReactMarkdown
    children={markdown}
    components={{
      code({node, inline, className, children, ...props}) {
        const match = /language-(\w+)/.exec(className || '')
        return !inline && match ? (
          <SyntaxHighlighter
            children={String(children).replace(/\n$/, '')}
            style={dark}
            language={match[1]}
            PreTag="div"
            {...props}
          />
        ) : (
          <code className={className} {...props}>
            {children}
          </code>
        )
      }
    }}
  />,
  document.body
)

上記の例ではコードブロックにシンタックスハイライトを適用するコンポーネントを使っています。

こんな感じでHTMLタグごとに出力を変えることができます。

なのでリンクカードを表示するLinkCardコンポーネントを作ってこんな感じにしてみました。

import ReactMarkdown from 'react-markdown'
import SyntaxHighlighter from 'react-syntax-highlighter'
import { atelierHeathDark } from 'react-syntax-highlighter/dist/cjs/styles/hljs'
import LinkCard from './LinkCard'
import { isValidElement } from 'react'
import remarkGfm from 'remark-gfm'
import { Body as BodyType }  from '../../types/atoms/Body'
import { Href } from '../../types/atoms/Href'

const Body = (props: { body: BodyType }): JSX.Element => {

  return (
    <div id='content'>
      <ReactMarkdown
        children={props.body.toString()}
        remarkPlugins={[remarkGfm]}
        components={{
          p({ children }) {
            if (
              Array.isArray(children)
              && children[0]
              && isValidElement(children[0])
              && children[0].props.node?.tagName === 'a'
            ) {
              return (
                <div>
                  {children}
                </div>
              )
            } else {
              return (
                <p>
                  {children}
                </p>
              )
            }
          },
          a({ href }) {
            if (!href) {
              return (
                <p></p>
              )
            }
            return (
              <LinkCard href={new Href(href)} />
            )
          },
          code({ node, inline, className, children, style, ...props }) {
            const match = /language-(\w+)/.exec(className || '')
            return !inline && match ? (
              <SyntaxHighlighter
                children={String(children).replace(/\n$/, '')}
                language={match[1]}
                style={atelierHeathDark}
                PreTag="div"
                {...props}
              />
            ) : (
              <code className={className} {...props}>
                {children}
              </code>
            )
          },
        }}
      />
    </div>
  )
}

export default Body

<a>タグを変更している箇所は以下です。

a({ href }) {
  if (!href) {
    return (
      <p></p>
    )
  }
  return (
    <LinkCard href={new Href(href)} />
  )
},

a(props)propsにはhrefの他、target<a>タグのattributesで設定できるものが入ります。

ただundefinedが入る可能性もあるのでその場合は空の<p>タグを返します。

hrefに値が入っている場合は<LinkCard>コンポーネントを返します。

new Href(href)はclass型です。URL形式になっていない場合はErrorを投げるようになっています。

ちなみに<LinkCard>はこんな感じです。まだ作り込みが甘いですが。。

import { useEffect, useState } from 'react'
import { useClient } from '../../hooks/useClient'
import styles from '../../styles/atoms/LinkCard.module.scss'
import { Href } from '../../types/atoms/Href'

const LinkCard = (props: { href: Href }): JSX.Element => {
  const [ogps, setOgps] = useState<Array<{
    prop: string,
    content: string
  }>>([])
  const isClient = useClient()
  useEffect(() => {
    const getOgps = async () => {
      if (!props.href) {
        return
      }
      const source = await fetch(props.href.toString())
      const text = await source.text()
      const html = new DOMParser().parseFromString(text, 'text/html')
      const headEls = html.head.children
      setOgps(Array.from(headEls).map(headEl => {
        const property = headEl.getAttribute('property')
        if (!property) {
          return {
            prop: '',
            content: '',
          }
        }
        return {
          prop: property.replace('og:', ''),
          content: headEl.getAttribute('content') ?? '',
        }
      }).filter(ogp => {
        return ogp.prop !== ''
      }))
    }
    getOgps()
  }, [props.href])

  const image = ogps.find(ogp => {
    return ogp.prop === 'image'
  })
  const title = ogps.find(ogp => {
    return ogp.prop === 'title'
  })
  const description = ogps.find(ogp => {
    return ogp.prop === 'description'
  })
  return (
    <>
      { isClient &&
        <a
          href={props.href.toString()}
          target="_blank"
          rel="noreferrer"
        >
          <div className={styles.link_card_wrapper}>
            <img
              className={styles.link_card_image}
              src={image?.content ?? ''}
              alt={title?.content ?? ''}
            />
            <h3>{title?.content}</h3>
            <p className={styles.link_card_description}>{description?.content}</p>
          </div>
        </a>
      }
    </>
  )
}

export default LinkCard

useEffect()でリンク先のOGPを取得しています。

useClient()はカスタムHookでこんな感じ。

import { useEffect, useState } from "react";

export const useClient = () => {
  const [isClient, setIsClient] = useState(false)
  useEffect(() => {
    if (typeof window !== 'undefined') {
      setIsClient(true)
    }
  })

  return isClient
}

クライアントサイドかサーバーサイドかを判定するHookです。

なぜこれを追加したかと言うと、Hydration Errorになってしまうからです。

Hydration Errorとはざっくり言うとサーバーサイドで生成したDOMとクライアントサイドでレンダリングされたDOMが違っていることによるものです。

While rendering your application, there was a difference between the React tree that was pre-rendered (SSR/SSG) and the React tree that rendered during the first render in the Browser. The first render is called Hydration which is a feature of React.

This can cause the React tree to be out of sync with the DOM and result in unexpected content/attributes being present.

React Hydration Error

どうしたものかと思いましたがこちらを参考にしてカスタムHookを作ることで対応しました。

【React】Hydrate時のワーニングを放置するとパフォーマンスが悪くなる!?

これでHydration Errorは回避できましたが、まだコンソールにWarningが出ます。

Warning: validateDOMNesting(...): <h3> cannot appear as a descendant of <p>.

markdownからHTMLに変換する際、地の文は一文ごとに<p>タグに入ります。

そうすると<a>タグを<LinkCard>コンポーネントに置き換えているので<p>タグの中に<h3>タグが入る形になってしまい、Warningが出てしまっているわけです。

これを回避しているのが以下の部分です。

p({ children }) {
  if (
    Array.isArray(children)
    && children[0]
    && isValidElement(children[0])
    && children[0].props.node?.tagName === 'a'
  ) {
    return (
      <div>
        {children}
      </div>
    )
  } else {
    return (
      <p>
        {children}
      </p>
    )
  }
},

<p>タグのchildren属性が配列で、かつ空でなく、ReactElementで、それが<a>タグの場合(=<LinkCard>コンポーネントを使う場合)は<div>タグに変換しています。

それ以外の場合は通常通り出力しています。

こうすることでWarningが出なくなりました。

ここまで対応した状態でmarkdownに通常通りリンク設定してあげると、

[https://numnam.net](https://numnam.net)

こんな感じで表示されます。

まとめ

以上、markdown中でReactコンポーネントを使う方法でした。

ただこれだとリンクだけ独立した段落に書かないと表示がおかしくなりそうなのでまだ改善の余地は大きそうです。

とりあえずは当初の狙い通りリンクカードを出すことができるようになったので今のところはOKとします。