踊る犬.netブログ (旧)

remark+ReactでMarkdownをレンダリングする

本記事は React Advent Calendar 2016 9日目の記事です。

本稿では、remarkというプラグインベースのMarkdownプロセッサを用いて、ReactのdangerouslySetInnerHTMLを使わずにMarkdownをレンダリングする方法をご紹介します。
InkdropというMarkdown専用ノートアプリを作っていて、その中でこのremarkを使っています。

XSSを回避してReact方式でDOM操作したい

ReactでMarkdownをレンダリングしようと思うと、markedとかmarkdown-itを使ってHTMLの文字列に変換してからdangerouslySetInnerHTMLで無理やりねじ込む実装方法が多いと思います。

この方法ではXSSの危険性を孕みます。そしてMarkdown周りだけ文字列ベースの処理となり、Reactを使ったDOM操作の恩恵に預かれません。
Remarkを使うことでこの問題を解消できます。

プラグインベースのMarkdownプロセッサ「remark」

remarkは単なるMarkdownからHTMLへのコンパイラではなく、プラグインを使って様々なことが出来るMarkdownプロセッサです。例えば以下のような事ができます:

そしてremark-reactがReactでレンダリングするためのプラグインです。
使い方はとても簡単で、以下のように使用します:

import React from 'react'
import remark from 'remark'
import reactRenderer from 'remark-react'

const processor = remark().use(reactRenderer)

class App {
  constructor () {
    this.state = { text: '# hello world' }
  }
  onChange (e) {
    this.setState({ text: e.target.value })
  }
  render () {
    return (
{processor.process(this.state.text).contents}
</div>) } } React.render(<App />, document.getElementById('app'))

詳しく見て行きましょう。

Markdownパーサのオプション

Markdownのパースを担当しているのはremark-parseです。これはremarkにデフォルトで同梱されているプラグインです。
オプションは以下の通りで、processor.process()に渡して使います:

  • gfm (boolean, default: true)
    • GitHub-flavored Markdownモードを有効にします
  • yaml (boolean, default: true)
    • YAML front matterを有効にします
  • commonmark (boolean, default: false)
    • CommonMarkモードを有効にします
  • footnotes (boolean, default: false)
    • リファレンス脚注とインライン脚注をサポートします
  • pedantic (boolean, default: false)
    • _による強調などを有効にします
  • breaks (boolean, default: false)
    • パラグラフ内の改行を新しい行として扱います
  • blocks (Array.<string>, default: list of block HTML elements)
    • ブロックレベルのHTML要素を定義可能にします

以下のように指定します:

processor.process(this.state.text, { gfm: true, breaks: true, yaml: false })

詳しくはドキュメントを参照ください。

このパーサは新しいシンタックスに対応させる際に必要になります。
Parser#blockTokenizersParser#inlineTokenizersからトークナイザを追加できます。

Reactレンダラのオプション

  • sanitize (object or boolean, default: undefined)
    • サニタイズのルールを指定します。デフォルトではGitHubのルールを適用します
  • prefix (string, default: h-)
    • React keyのプリフィックスを指定します
  • createElement (function, default: require('react').createElement)
    • Reactエレメント生成関数を変更できます
  • remarkReactComponents (object, default: undefined)
    • 任意のHTML要素を独自のReactコンポーネントに置換できます(詳しくは後述)
  • toHast (object, default: {})
    • MDASTからHASTへ変換する際のオプションです

以下のように指定します:

const processor = remark().use(reactRenderer, {
  sanitize: true,
  prefix: 'md-'
})

コードのシンタックスハイライトに対応する

デフォルトのremarkおよびremark-reactにはコードのシンタックスハイライトに対応していません。
これはhighlight.jsremark-react-lowlightを組み合わせて実現できます。

import RemarkLowlight from 'remark-react-lowlight'
import js from 'highlight.js/lib/languages/javascript'

const processor = remark().use(reactRenderer, {
  sanitize: true,
  prefix: 'md-',
  remarkReactComponents: {
    code: RemarkLowlight({
      js
    })
  }
})

highlight.jsのサポートしている言語を一度に全てサポートさせたい場合は、こちらのGistを参考にしてください。

テーブルの寄せ(アライン)に対応する

Reactはテーブル要素のalign属性を無視します。これはHTML5では非推奨の属性のためです。
remark-reactはこの問題をまだ上手く回避してくれません
そこで、以下のように独自にtdth要素をレンダリングすることで回避します:

function createTableCellComponent (tagName) {
  return class TableCell extends React.Component {
    render () {
      const style = { textAlign: this.props.align }
      const props = { ...this.props, style }
      return createElement(tagName, props, this.props.children)
    }
  }
}

const processor = remark().use(reactRenderer, {
  remarkReactComponents: {
    td: createTableCellComponent('td'),
    th: createTableCellComponent('th')
  }
})

かなり柔軟に出来ている

使ってみた感じでは少し複雑に感じるところもありますが、その分柔性がとても高いと思いました。
MDAST(Markdown Abstract Syntax Tree)とかHAST(Hypertext Abstract Syntax Tree)とかは特に分かりにくいのですが、そこを理解してしまえばシンプルな方法でシンタックスを拡張したりできます。
既存のプラグインを組み合わせるだけならとても簡単ですので、ぜひ使ってみてください!