9946
6
0
Markdownはなぜ拡張され続けるのか
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の独自拡張
- 絵文字記法:
:tada:
⇢ 🎉 - メンション記法:
@gfx
⇢ @gfx - Kibela内リンク:
https://bitjourney.kibe.la/@gfx/746
⇢ https://bitjourney.kibe.la/@gfx/746
- 絵文字記法:
- サービスごとにユーザーの要求が異なるので拡張は必要
- QiitaやesaといったMarkdownを中心に添えたサービスでは特に継続的な改善が欠かせない
- Kibelaでは、Markdown処理系自体はcommonmark(er)を使うが、拡張のためのフレームワークはスクラッチで開発することにした
KFMの実装
- 実装は bitjourney/mato というgemを利用
- Markdown処理系はcommonmarker gem
- commonmarkerは
github/cmark
のRubyバインディング
- commonmarkerは
- Markdown処理系はcommonmarker gem
- これは qiita-markdown / html-pipeline をKibelaの事情に合わせて再設計したもの
- KibelaにはACLがありログインユーザごとに見せられるものが違うので再設計が必要だった
Mato (メイト)
まずアイデアの源流である html-pipeline gemの設計
- 入力はテキスト(String)
- MarkdownFilterでテキストをHTMLに変換する
- HTMLをDOMに変換する
- DOMに対してさまざまなHTML filterで加工する
-
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
↓
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
- Standard Markdown Becomes Common Markdown then CommonMark (InfoQ, 2014)
- QiitaとMarkdownとコンテンツオーサリング - Qiita (mizchi, 2016)
- A formal spec for GitHub Flavored Markdown | GitHub Engineering (GitHub, 2017)
- pandoc-extended markdown (Pandoc)