かめ。ブログ

フロントエンドエンジニアが「Web」「Youtube」「3D」「お金」「お買い物情報」など、興味があることについて深堀っていくブログです。

Markdownの中に、Reactコンポーネントを使用できるようにする

2022年2月25日

5 日前

unifiedとは、Markdownで書いた文字列をHTMLに変換したり
さまざまなプラグインを使い、処理を行うことで、さまざまな処理が行えるようなプラグイン(ライブラリ?)になっているようです。

ちょっと全体像を理解しようとすると、かなり複雑になってしまいそうなので
詳しい説明は省いて、今回やりたいことと、必要な部分のみを乗せていきます。

今回やりたいこと

  • MarkdownをHTMLに変換する
  • 任意のカスタムタグを、Reactのコンポーネントに変換する
  • propsも渡すようにする


必要なもの


unifiedを使って処理を書く

import React from 'react'
import { unified, Plugin } from 'unified'
import remarkGfm from 'remark-gfm'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'

import rehypeParse from 'rehype-parse'
import rehypeReact from 'rehype-react'

import Skill from '~/components/common/Skill'
import Comment from '~/components/comment/Comment'
import CustomLink from '~/components/common/CustomLink'
import CustomImage from '~/components/common/CustomImage'

// カスタムしたタグを作っている場合にはpタグで囲われてしまう問題があったので、pタグで囲わないように
const removeCustomElementParagraph: Plugin = () => {
  return (tree: any) => {
    const firstChildren = tree.children[0]
    if(firstChildren?.type === 'paragraph' && firstChildren?.children![0].type === 'html') {
      tree.children[0].type = 'html'
      tree.children[0].value = firstChildren.children.map((v: any) => v.value).join('')
    }
  }
}

// 既存のタグをカスタマイズしたタグに変更したり、オリジナルのタグをコンポーネントに変換する処理を行なっています
export const parseReact = (str: string) =>
  unified()
  .use(rehypeParse, { fragment: true })
  .use(rehypeReact, {
    passNode: true,
    Fragment: React.Fragment,
    createElement: React.createElement,
    components: {
      a: CustomLink,
      img: CustomImage,
      skill: (props: any) => <Skill {...props}></Sns>,
      comment: (props: any) => {
        return <Comment isRight={props.isright === ''}>{props.children}</Comment>
      },
    },
  } as any) // tyescriptの関係でanyにしないとエラーになったため
  .processSync(str).result

// markdownの記述をHTMLに変換します
export const markdownToHtml = (markdown: string) =>
  unified()
    .use(remarkParse)
    .use(removeCustomElementParagraph)
    .use(remarkGfm)
    .use(remarkRehype, {
      allowDangerousHtml: true // trueにしておくことで、自分でカスタマイスしたタグをそのまま吐き出してくれるようになります。
    })
    .use(rehypeStringify, {
      allowDangerousHtml: true
    })
    .processSync(markdown).value as string


markdownToHtmlについて

markdown形式の文字列(string)をHTMLにタグに変換する処理を行なっています。
removeCustomElementParagraphでは自前の処理で自分で作ったカスタムタグがpで囲われないようにしています。

parseReactについて

既存のタグをカスタマイズしたタグに変更したり、オリジナルのタグをコンポーネントに変換する処理を行なっています。

aタグやimageタグをNextのタグに変更

a: CustomLink,
img: CustomImage,


オリジナルのタグを作る

sns: (props: any) => <Sns {...props}></Sns>,
comment: (props: any) => {
  return <Comment isRight={props.isright === ''}>{props.children}</Comment>
},


microCMS側でmarkdownで下記のように書くとそれぞれのコンポーネントに変換される仕組みになっています。

<comment>test</comment>
<comment isRight>test</comment>
<skill></skill>


propsは文字列としてであれば、渡せるようです。
なのでこれをうまく使って、<Comment isRight={props.isright === ''}>{props.children}</Comment>このように書いてあげることで、propsを渡すような処理を書くことができます。
どうやら属性として渡す形なのか、キャメルケースにはならないようです。
この点気をつけながらであれば、propsもお手軽に使うことができます。

Linkタグ(next/link)に変更する

CustomLink.tsx

import React from 'react'
import Link from 'next/link'

const CustomLink : React.FC<{href: string}> = ({
  children,
  href,
}) => {
  const isMyPageLink = href.startsWith('/') || href === ''
  return isMyPageLink ? (
    <Link href={href}>
      <a>{children}</a>
    </Link>
  ) : (
    <a href={href} target="_blank" rel="noopener noreferrer">
      {children}
    </a>
  )
}

export default CustomLink


Imageタグ(next/image)に変更する

CustomImage.tsx

import React from 'react'
import Image from 'next/image'

interface CustomImage {
  src: string
  alt: string
  width: number
  height: number
}

const CustomImage : React.FC<CustomImage> = props => {
  return ~props.src.indexOf('images.microcms-assets.io') ? (
    <Image src={props.src} width={props.width} height={props.height} alt={props.alt} />
  ) : (
    <img src={props.src} width={props.width} height={props.height} alt={props.alt} />
  )
}

export default CustomImage


任意のurl(images.microcms-assets.io)が含まれている場合のみImageタグを使うようにしています。
import Image from 'next/image'この部分最初next/imaegnext/ImageIを大文字にしちゃっていたので下記のようなエラーが出てしまっていました。

Server Error
Error: Invalid src prop (https://images.microcms-assets.io/assets/8d63ea55db89496cbf00ccb8c6bf8880/25cd3bb522294516b247308f15d7c9ca/fullbloom.jpg) on `next/image`, hostname "images.microcms-assets.io" is not configured under images in your `next.config.js`
See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host

ちゃんと正しくnext.config.jsのドメインにnext/imageで使用したいドメインを記述する必要があります。
ちゃんと記述していたのにも関わらず上のエラーが出てしまっていたので悩みました。。

const config = {
  images: {
    domains: ['images.microcms-assets.io'],
  },
}


実際に使う

<div>
  {props?.body?.map((v, i) => {
    const commentValue = v as Comments

    // カスタムフィールドのフィールドIDだった場合は自前のコンポーネントを使います
    if(v.fieldId === 'comments') {
      return commentValue.comments!.map((v) => <Comment {...v} key={`${v.fieldId}${i}`} />)
    }

    // htmlとmarkdownをmicroCMSのフィールドに作っていますその場合はmarkdownToHtmlを使います
    if(v.fieldId === 'html' || v.fieldId === 'markdown') {
      return <div key={`${v.fieldId}${i}`}>{parseReact(markdownToHtml(v?.value))}</div>
    }

    // リッチエディタ用
    return <div key={`${v.fieldId}${i}`}>{parseReact(v?.value)}</div>
  })}
</div>


fieldIdで判別を行なって、それぞれの処理を行うようにします

  • カスタムフィールドで独自のコンポーネントを使いたい場合
  • markdownの文字列から、HTMLタグを生成しさらに、自前のコンポーネントに差し替える処理を行いたい場合
  • リッチエディタでもともとHTMLになっているタグをさらに、自前のコンポーネントに差し替える処理を行いたい場合


この3つのケースに処理を分けています。

リッチエディタとmarkdown変換分けなくても良くない問題

最初処理を分けずにmarkdownToHtmlを共通で使っていたのですがpreタグのインデントがなくなってしまう問題があったので分けることにしました。

参考

おすすめの本

Javascriptを勉強する際に読んでおくと良いなと思った本たちです。
特にリーダブルコードは綺麗なコードを書く考え方が身に付きます。いまでもその考え方が根付いています。

よくみられてる記事