bitjourney team
9737
6
0

Markdownはなぜ拡張され続けるのか

Published at August 26, 2017 12:22 p.m.

Markdown Night 2017 Summer - connpass

自己紹介

  • 藤吾郎(@__gfx__
  • Bit Journeyのエンジニア
  • Kibela を開発中
    • WikiとBlogが別れているのが特徴の情報共有ツール
    • 最近はKibela MarkdownのCommonMark化をしたり非公開boardの実装をしたりした

第一部: Markdown小史

Markdown

  • 多くのサービスで利用できる軽量マークアップ言語
    • GitHub, Qiita, esa.io, Slack, stackoverflow, Reddit, Confluence...
    • KibelaもMarkdown採用サービスのひとつ
  • 処理系ごとの 方言が非常に多い ことで知られる
    • 構文そのものの違い
      • たとえばテーブル記法をサポートしている処理系は比較的少ない
    • 構文の微妙な差の方言
      • スペースの要不要はエッジケースを含めるとかなり違う
      • たとえば こんにちは*markdown* はSlackではNGで、 こんにちは *markdown* としなければならない
  • 方言が多いのは処理系の実装者、ひいてはユーザーが多いことを示しているが…
  • 相互運用性が下がるので、方言はできれば少ない方がいい

Markdown処理系の歴史

  • markdown.pl (2004)
    • Markdownのオリジナル
  • Pandoc (2006~)
    • あるマークアップから別のマークアップに変換するツール
    • Markdownに対しても意欲的な拡張を数多く行っている
  • Redcarpet (2011~)
    • 非常に広く使われるようになったrubygem
    • 仕様策定前のレガシーGFM: GitHub Flavored Markdown はおそらくRedcarpet
    • 現在のQiita MarkdownもRedcarpet系
      • IncrementsはRedcarpetをforkしてGreenmatを開発した
    • GitLabもRedcarpetの採用を明言

CommonMark (2014~)

  • Markdownの一方言
  • 主に仕様の執筆&実装しているのがjgm氏で、氏はpandocの作者でもある
  • authorsにはその他GitHubやReddit, StackExchangeのメンバーが並ぶ
  • とはいえ採用サービスはまだ少ない
    • GitHub (2017年3月より採用)
    • Kibela (2017年8月より採用)
  • 現在のバージョンは v0.28.0
  • 2017年に v1.0.0 をリリース予定

github/cmark (2017~)

  • commonmark/cmarkのforkで、GitHubが独自の拡張をいれたもの
    • 打ち消し線、テーブル記法、チェックリストなど
  • rubygemの commonmarker は実際には github/cmark を利用
  • Kibelaも commonmarker を使ってほとんどのGFM拡張を有効にしている
  • GFMは世界的にもっとも広く使われるMarkdownの方言の一つ
  • GFMがCommonMark準拠を発表した以上、CommonMark = GFMと言っても過言ではない
  • 以降「CommonMark」といったらGFMを指します

Markdownの現状まとめ

  • Markdownは方言の多い軽量マークアップ言語
  • Markdownの標準化を目指してCommonMark working groupが発足した
  • GitHubはCommonMark互換でGFMの仕様を策定した
  • KibelaもCommonMarkに移行した
  • 採用サービス自体はまだ少ない

第二部: KibelaがCommonMarkを採用してその後

  • KibelaはCommonMarkを採用した
    • GFMと互換性があること
    • 参照実装としてCとJSがあるため、多くのプラットフォームで利用できること
  • しかし問題は山積み…
    • 過去のKFMや他サービスとの互換性の問題
    • 将来的な拡張の問題

CommonMarkの互換性の問題

  • CommonMarkはわりと個性豊か
    • vs Redcarpet, Pandoc, and Qiita (≒ Redcarpet)
  • 非CommonMark代表としてRedcarpetと比較していくつか例示してみる

非互換事例: 見出し記法のスペース

  • CommonMarkば見出しのための # のあとにスペースを要求する
    • こまかな非互換はだいたいスペースの要不要の差が多い
commonmark
# これは見出しです
redcarpet
#これは見出しです
  • これは旧KFMからの移行でも問題になったので移行スクリプトを書いた
  • お問い合わせを受けたら移行スクリプトを走らせるという運用でいく

非互換事例: _ の扱い

  • __gfx__ をそのまま書ける処理系と書けない処理系がある 💢
  • Redcarpetは書けない(バックスラッシュでエスケープはできる)
  • CommonMarkは前後にスペースを空けなければ書ける
    • しかし @__gfx__ は @gfx になるので不可💢
      • これはバグだと思うが…

非互換事例: fenced code blocks

CommonMarkにはPandoc由来の ~~~ によるfenced code blocksがある。

~~~markdown
Hello, **CommonMark**!
~~~

markdown
Hello, **CommonMark**!
  • このfenced code blocksのラベルも処理系によって扱いが違う

CommonMarkの直し方

  • 他のmarkdown処理系と異なり、手元で修正して終わりというわけに行かない
  • バグ修正するにも commonmark/CommonMark(仕様)と commonmark/cmark (Cの参照実装)と commonmark/commonmark.js (JSの参照実装)をいじらないといけなくてハードルが高い

互換性まとめ

  • CommonMarkはわりと個性的で他の処理系と互換性がない部分がある
  • CommonMarkは仕様と参照実装が2つあり関わるのはちょっと大変

Kibela Flavored MarkdownにおけるMarkdownの拡張について

なぜ拡張が必要なのか

  • たとえば以下はすべてKFMの独自拡張
  • サービスごとにユーザーの要求が異なるので拡張は必要
    • QiitaやesaといったMarkdownを中心に添えたサービスでは特に継続的な改善が欠かせない
  • Kibelaでは、Markdown処理系自体はcommonmark(er)を使うが、拡張のためのフレームワークはスクラッチで開発することにした

KFMの実装

  • 実装は bitjourney/mato というgemを利用
    • Markdown処理系はcommonmarker gem
      • commonmarkerは github/cmark のRubyバインディング
  • これは qiita-markdown / html-pipeline をKibelaの事情に合わせて再設計したもの
  • KibelaにはACLがありログインユーザごとに見せられるものが違うので再設計が必要だった

Mato (メイト)

まずアイデアの源流である html-pipeline gemの設計

  1. 入力はテキスト(String)
  2. MarkdownFilterでテキストをHTMLに変換する
  3. HTMLをDOMに変換する
  4. DOMに対してさまざまなHTML filterで加工する
  5. result という Hash が返ってくるのでその中身から欲しいデータを参照する
    • 結果のHTML
    • メンション一覧など

Usage:

ruby
# pipelineインスタンス生成時にすべてのフィルタを用意する
pipeline = HTML::Pipeline.new [
  HTML::Pipeline::MarkdownFilter, # フィルタクラスを与える
  HTML::Pipeline::SyntaxHighlightFilter
]
result = pipeline.call <<-MD
This is *great*.
MD

puts result[:output] # => "<p>This is <em>great</em>.</p>"

html-pipelineのイケてるところ

  • プレインテキストに対するフィルタではなく、HTML(=構造化されたテキスト)に対するフィルタを適用するという設計により、安全かつ簡単にMarkdownの拡張を書けるようになった
    • HTMLをDOMに変換してCSS selectorやXPathでデータを抽出する nokogiri gem の使い勝手がいいというのもある

html-pipelineのイケてないところ

  • フィルタインスタンスではなくフィルタクラスを与えるインターフェイスのため、フィルタクラスにパラメータを与えるのに context という何でも入る Hash を使うしかなかった
    • すべてのフィルタがcontextを参照するので開発・保守が大変だった
    • CDNのURLのような静的なパラメータとログインユーザのような動的なパラメータを区別できないのも保守性的にイマイチだった
  • HTML::Pipeline インスタンス生成時にすべてのフィルタが定まっていなければならなかった
    • Kibelaのようにログインユーザによって振る舞いを変えるというのを実装しにくい
  • キャッシュを考慮した設計になっていないため、結果のキャッシュキーにログインユーザの情報をいれることになった
    • 結果、異なるユーザー間でキャッシュを共有できなくなってしまった

Matoの設計

  • 基本的にはhtml-pipelineの思想は踏襲する
  • HTMLフィルタはクラスではなくインスタンスを与える
  • ログインユーザに依存しない処理をすべて終えた後、ログインユーザに依存する処理の前にキャッシュできるようにした
  • さらに用途(本文 or 目次)別に分岐する前にキャッシュするようにした
  • Markdown ⇢ フィルタ ⇢ Mato::Document ⇢ cache ⇢ 追加フィルタ ⇢ 本文, ToC, mension抽出

Kibelaのコードから抜粋

フィルタの定義

kibela_markdown.rb
module KibelaMarkdown
  class << self

    # @return [Mato::Processor]
    def mato # rubocop:disable Metrics/AbcSize
      @mato ||= Mato.define do |config|
        # 省略

        # Mentionフィルタにブロックパラメータを与えてカスタマイズ
        config.append_html_filter(Mato::HtmlFilters::MentionLink.new do |mention_candidate_map|
          candidate_accounts = mention_candidate_map.keys.map { |name| name.gsub(/^\@/, '') }
          User.alive.where(account: candidate_accounts).each do |user|
            mention = "@#{user.account}"
            mention_candidate_map[mention].each do |node|
              node.replace(user.decorate.account_with_avatar_link_tag(
                             size: :small,
                             class_name: 'user-mention',
                             data: { mention: user.account },
              ))
            end
          end
        end)
      end
    end

matoインスタンスを使って処理をするところ

kibela_markdown.rb

    # @return [Mato::Document]
    def process(content, trusted:, ability:)
      with_view_context do
        Rails.cache.fetch("#{self}#process/#{Digest::SHA1.hexdigest(content)}") do
          mato.process(content) # ここで String -> Mato::Document にしたあとキャッシュ
        end.apply_html_filters(
         # sanitizeとinternal linkはコンテキストに依存するフィルタなので結果はキャッシュ不可
          Mato::HtmlFilters::Sanitization.new(sanitize: KibelaMarkdown::SanitizationRule.get(trusted: trusted)),
          KibelaMarkdown::MatoFilters::InternalLink.new(ability: ability),
        )
      end
    end

    # Mato::DocumentをHTMLにする
    def render(content, trusted: false, ability: Ability.default)
      process(content, trusted: trusted, ability: ability).render_html.html_safe
    end

    # Mato::DocumentをToCのHTMLにする
    def render_toc(content, trusted: false, ability: Ability.default)
      process(content, trusted: trusted, ability: ability).render_html_toc.html_safe
    end

なぜMatoを開発したかまとめ

  • Markdownを拡張しやすくするため
  • Markdownを拡張したときにキャッシュしやすくするため

拡張の事例: PlantUML記法

  • PlantUML: テキストでUML図を書ける描画ツール
PlantUML
Alice -> Bob: Authentication Request
Bob --> Alice: Authentication Response

Alice -> Bob: Another authentication Request
Alice <-- Bob: another authentication Response
PlantUML diagram

KibelaのPlantUML記法

```{plantuml}
source...
```

esa.ioのPlantUML記法

```uml
source...
```

GitLabのPlantUML記法

```plantuml
source...
```

※ KibelaはGitLab形式のPlantUML記法もサポート(ただし非奨励)

この違いはどうしたらいいのか

  • 違うのはある程度仕方がない
  • ユーザーが意識しないようにできるとよい
    • 補完
    • WYSIWYG

拡張事例: LaTeX記法(a.k.a. MathJax記法)

  • LaTeXで数式を書くための記法

Kibela

```{latex}
\sum_{i=0}^n i^2 = \frac{(n^2+n)(2n+1)}{6}
```

Qiita, esa, GitLab

```math
\sum_{i=0}^n i^2 = \frac{(n^2+n)(2n+1)}{6}
```
  • Kibelaも互換性のためこの形式をサポートしている

LaTeX記法の難しいところ

  • TeXは \ (バックスラッシュ)を多用するので、Markdownのバックスラッシュエスケープとの相性が非常に悪い
  • なので、コードブロックに書くか、独自の工夫をする必要がある
  • 人気のある処理系がMathJaxとKaTeXの2つあり、サービスによって使えるものが異なる
  • GitHubでサポートされていないため判断が難しい

これからのMarkdown

  • Markdownについて語ったり意見をすり合わせる場所が日本にはなかった
  • このMarkdown Nightを通じて開発者同士が交流する機会になればいいと思う
  • Markdownはまだまだこれから、CommonMark 1.0が出てからが本番

See Also