Markdownを拡張して独自記法をつくる

Qiita::Markdownの解説記事です。Markdown拡張ならHTML::Pipelineという旨の投稿です。 いま読んでいるこの投稿の描画にもQiita::Markdownが利用されています。

方言とライブラリ

Markdownには様々な種類・方言があり、最近ではStandard Markdown is now Common Markdownが一部で話題になったりしました。かいつまんで言うと「Markdownの方言多すぎるしStandard Markdownって名前で共同プロジェクトつくろうとしたけど紆余曲折あって結局CommonMarkって名前になったわ」という感じです。

MarkdownをHTMLに変換するためのライブラリを探すと、例えばRubyではRedcarpet、C言語では同作者のSundown等が有名なところです。GitHubが利用しているライブラリgithub-markdownは、RubyGemsでは公開されていてもGitHubでは公開されていないようです。Build software better, together.

独自記法

さて、Markdownを利用するWebサービスなどを開発していると、様々な理由のもとに独自記法を導入したくなることがあります。例を挙げるならば、GitHub風のチェックボックスを導入したり、@r7kamuraのようにmentionにリンクを貼ったりという具合です。文字列を置き換えるだけでなく、script要素を使えないようにしたり、通知を送るためにmention対象になったユーザ名一覧を抽出したり、という処理もあり得ます。

正規表現

正規表現を使って実装するなら、例えばこういう感じになるかもしれません。以下のコードは絵文字を実現するためのもので、:pray: のような絵文字のパターンの文字列を抽出し、img要素を表す文字列に変換しています。

def emoji(text)
  text.gsub!(/:([a-z0-9\+\-_]+):/) do |match|
    emoji = Regexp.last_match(1)
    if Emoji.names.include?(emoji)
      src = "/emoji/#{CGI.escape(emoji)}.png"
      %[<img class="emoji" alt=":#{emoji}:" title=":#{emoji}:" src="#{src}"/>]
    else
      match
    end
  end
end

もし :pray: がコードブロックの中に含まれていた場合にはどうしたら良いでしょうか? まさにこの :pray: を記述している表現がその好例です。予めコードブロックを表現するパターンを正規表現で先読みして除外したり、行単位で変換していったりという策は、HTMLが直接記述される例などを考慮すると早々に破綻する気配が感じられます。

HTML::Pipeline

最終的な目的はMarkdownからHTML (とそれに付随する幾つかのメタデータ) を生成することです。ここでHTMLを木構造のデータとして捉え、木の変換を以って期待するHTMLを生成するというアイデアは、ごく自然な発想だと思います。これを実現するための実装例として、HTML::Pipelineというライブラリが存在します。HTML::Pipelineの処理は、以下のような流れになります。

  1. Redcarpetを利用してMarkdownの文字列をHTML表現の文字列に変換する
  2. Nokogiriを利用してHTML表現の文字列を木構造のデータ表現に変換する
  3. Filterと呼ばれる単位の幾つかの操作を適用して木構造を変換する
  4. 木構造をHTML表現の文字列に変換する

HTML::Pipelineは、各種変換処理をFilterという単位で分割することで独立性を高め、これらをPipelineのように繋ぎ合わせ、そこを流れるデータを木構造として扱うことでデータの走査/操作を容易にしています。

Qiita::Markdown

Qiita::Markdownは前述したHTML::Pipelineを利用して実装されており、其の実いくつかのFilterを用意してHTML::Pipelineの機能を呼び出しているいるだけに過ぎません。この幾つかのFilterが、Qiita上での独自拡張を実現し、QiitaにおけるMarkdownのポリシーを規定している訳です。

例えばCodeフィルタではコードブロックに付けられたラベルからそのコードで利用されている言語を判定し、SyntaxHighlighterフィルタではコードの各部分に色付けするためのクラスを付与し、Mentionフィルタではmentionにユーザのプロフィールページへのリンクを付けています (余談ですが、このとき同時にmention先のユーザ名やコードもメタデータとして回収しておき、後に通知やGist連携で利用しています)。

正規表現の節で前述した絵文字処理はEmojiフィルタで対応しており、コードブロックに含まれた絵文字表現は「親にpre要素やcode要素が含まれていないテキストノード」のみ変換対象とすることで対応しています。

終わりに

Qiita::Markdownを作るまでに至った背景、経緯、そして設計と実装について簡単に説明しました。Markdown拡張ならHTML::Pipelineがわりと良いという旨が伝われば幸いです。

Qiita::Markdownは元々、機能拡張のために社内のMarkdownのコードを拝見した際に、これはもうダメかなと思って書き直したところから生まれたものです。もしかしたら他の場所でもこういう状況なのかもしれないなと思い、知見を共有すべくOSSとして公開するはこびとなりました。

Markdown記法の拡張に興味がある人や、Markdownを使って最強の情報共有サービスをつくるぞという人、煩雑化して手の付けられなくなったコードが転がっているプロジェクト、あるいは転がっている開発者の助けになれば幸いです。

参考