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.
どうしたものかと思いましたがこちらを参考にしてカスタム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とします。