[object Object]

現在本サイトはNuxtにリプレースされたので内容と表示の整合性が取れてない可能性があります

MarkdownとReact

JavaScriptのMarkdownパーサーは様々ある。メジャーどころで言えば

  • marked
  • commonmark
  • markdown-it

があるだろう。これらをReactで使うばあい、一番単純な方法はdangerouslySetInnerHTMLを使うことである。ただしこれはあまりイケてない。というのは

  1. ライブ変換とかしようとすると遅い
  2. react-routerを使ってSPAにしてるばあい、ドキュメント内のリンクをLinkに置き換える手段がない
  3. 何よりReactである利点を全く活かせていない

以上の理由が挙げられる。そのため各Markdownパーサーごとに、HTMLへ変換するのではなく、ReactElementに変換するようなライブラリが作られている。

まともに使えそうなレベルのはこの3つくらい。始めは作者のやる気があってメンテもそこそこされているrexxars/react-markdownを使っていて、ルールのオーバーライドも見通しが良く使い勝手は良かったが、commonmark準拠でtableが吐けないのが辛かった。mizchi/md2reactは同様にtableがサポートされていないのと、これから対応するような気配もなかったので試してもいない。Markdownパーサーの中で一番拡張性が思われるのがmarkdown-itで、これを使っていたReactElementを吐けるライブラリを探して、見つけたのがalexkuz/markdown-react-jsだった。

alexkuz/markdown-react-jsが全然メンテされてない問題

markdown-itparse()で得られるASTを使っていていたり、結構雰囲気いい感じなのだが、まず最新のReactとpearDependenciesのバージョンが合ってないとか、hrタグとbrタグがself-closingタグとして処理されてないので変換時にエラー吹いて死ぬとか、マージされれば割とマシになりそうな大きめなPRも来てるのだがそれも放置状態とかで、なんとも惜しい感じ。

こうなりゃもう自分で対応するしかないと、endaaman/markdown-react-jsに怒りのフォーク。

イジったところとか

  • 依存性の整理
  • hrbrで子をレンダリングしない
  • onIterateが与えられた時子タグレンダリングするかのチェックをすり抜けるのでその修正

という調整をしてなんとか使えるレベルに戻した。

そして骨までしゃぶる

markdown-itは独自記法を追加するプラグインが提供されているので、公式のプラグイン

  1. markdown-it-ins
  2. markdown-it-mark
  3. markdown-it-deflist
  4. markdown-it-sup
  5. markdown-it-sub
  6. markdown-it-container

とシンタックスハイライト

  1. isagalaev/highlight.js

に対応させ、さらにリンクをreact-routerのLinkに置き換えるようにした。

デモ

Linkとの置き換え

- [Home](/)
- [Github](https://github.com)

上はpushStateによるページ遷移、下はtarget="_blank"で別タブで開く。

markdown-it-ins

++inserted++

inserted

markdown-it-mark

==marked==

marked

markdown-it-deflist


Term 1

:   Definition 1

Term 2 with *inline markup*

:   Definition 2

    Second paragraph of definition 2.
Term 1

Definition 1

Term 2 with inline markup

Definition 2

Second paragraph of definition 2.

markdown-it-sup

29^th^

29th

markdown-it-sub

H~2~O

H2O

markdown-it-container

:::well
like well of twbs/bootstrap
:::
:::color:red
red colored text
:::

:::well like well of twbs/bootstrap :::

:::color:red red colored text :::

実装など

<MDReactComponent text={mdContent}  {...mdOptions}/>

このようにレンダーするとして、mdOptionsには

import MDReactComponent from 'markdown-react-js'
import markdownItContainer from 'markdown-it-container'
import markdownItIns from 'markdown-it-ins'
import markdownItMark from 'markdown-it-mark'
import markdownItDeflist from 'markdown-it-deflist'
import markdownItSup from 'markdown-it-sup'
import markdownItSub from 'markdown-it-sub'


export function isInnerLink(uri) {
  return /^\/.*/.test(uri)
}

const customContainers = {
  well(props, children, arg) {
    return <div className={styles.well} {...props}>{children}</div>
  },
  color(props, children, arg) {
    return <div style={{color: arg}} {...props}>{children}</div>
  },
}

const mdOptions = {
  onIterate(tag, props, children) {
    if (tag === 'a') {
      if (isInnerLink(props.href)) {
        props = {...props, ...{to: props.href}}
        return (<Link {...props}>{children}</Link>)
      } else {
        props = {...props, ...{target: '_blank'}}
        return React.createElement(tag, props, children)
      }
    }
    if (tag === 'pre') {
      const lang = children[0].props['data-language'] || null
      return <CodeBlock language={lang} codeElement={children[0]} key={props.key} />;
    }
    const dataInfo = props['data-info']
    if (dataInfo) {
      const [marker, arg] = dataInfo.split(/:(.+)?/)
      return customContainers[marker](props, children, arg)
    }
    return null
  },
  plugins: [
    {
      plugin: markdownItContainer,
      args: [null, {
        validate(param) {
          const [marker] = param.split(':')
          return Object.keys(customContainers).find(key => marker.trim() === key)
        },
      }]
    },
    markdownItIns,
    markdownItMark,
    markdownItDeflist,
    markdownItSub,
    markdownItSup
  ],
}

という感じ

Linkとの置き換え

onIteratetag'a'かつ/hogeのようにサイト内部のリンクならLinkに置き換え、そうでなければtarget="_blank"を追加するだけ

シンタックスハイライト

```js
console.log('hello')
```

みたいなMarkdownを書いた時の言語アノテーションのjsというような文字列はpreの子のcodedata-language要素に入っているので、そこから取り出している。CodeBlockの実装は

import React, { Component } from 'react'
import PureRenderMixin from 'react-addons-pure-render-mixin'
import cx from 'classnames'
import hljs from 'highlight.js'

import styles from '../styles/code_block.css'

class CodeBlock extends Component{
  constructor(props) {
    super(props)
    this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this)
    this.state = {
      codeBlock: null,
    }
  }

  componentDidMount () {
    this.highlightCode()
  }

  componentDidUpdate () {
    this.highlightCode()
  }

  highlightCode () {
    if (this.props.language && this.state.codeBlock) {
      hljs.highlightBlock(this.state.codeBlock)
    }
  }

  render () {
    const codeElement = {...this.props.codeElement}
    codeElement.props = {...codeElement.props, ...{
      className: 'hljs'
    }}
    return (
      <pre
        className={cx(styles.codeBlock, this.props.language)}
        ref={(ref)=> this.setState({codeBlock: ref})}
        >
        {codeElement}
      </pre>
    )
  }
}

という感じ。コンポーネントがマウントされたあとにDOMをhighlight.jsに引き渡してハイライトさせればdangerouslySetInnerHTMLを使わずに済む。highlight.jshighlightBlockは勝手に言語を識別してハイライトしようとするので、言語が指定されなければハイライトをスキップしている。

markdown-it-container

今回の目玉。これのおかげで任意のコンテナをReactのコンポーネントとしてレンダリングできるので、オレオレMarkdown化が一気に加速する。

{
  plugin: markdownItContainer,
  args: [null, {
    validate(param) {
      const [marker] = param.split(':')
      return Object.keys(customContainers).find(key => marker.trim() === key)
    },
  }]
}

という部分だが、たとえば

:::well
This is well
:::

というように書くと、:::wellの言語アノテーションの様な部分がvalidate(param)param'well'というような文字列が渡ってくる。ここではmarkdown-it-containerを使った レンダリングの可否をチェックしているので、truthyな値を返せば、

  onIterate(tag, props, children) {
    /* SNIP */
    const dataInfo = props['data-info']
    if (dataInfo) {
      const [marker, arg] = dataInfo.split(/:(.+)?/)
      return customContainers[marker](props, children, arg)
    }

onIteratedataInfo属性にアノテーション部分が入ってくるので、ここで:でアノテーション部分を区切って前半をコンテナ名、後半を引数に見たてて、

const customContainers = {
  well: (props, children, arg)=> {
    return <div className={styles.well} {...props}>{children}</div>
  },
  color: (props, children, arg)=> {
    const style = {}
    if (arg) {
      style.color = arg
    }
    return <div style={style} {...props}>{children}</div>
  },
}

というようにすれば:::well:::color:redとか:::color:blueとか使えるようになる。これを上手く活用すればメディアの埋め込みとか、動的な要素など様々なものがMarkdown上から制御できるようになる。

課題など

  • markdown-it-container
    • ネストできない
    • インライン展開できないとか
  • markdown-it-footnoteに対応できなかった