ヌーラボブログリレー2024 for Tech Advent Calendar 2024の10日目の記事です。
Backlog課、課題チームのsakaiです。
先日、Backlogの書式ルール「Markdown記法」をアップデートし、「GitHub Flavored Markdown」に基づいた記法のβ版をリリースしました。
本記事では、BacklogのMarkdown記法をアップデートするに至った背景や、その実装について詳しくご紹介します。
目次
初めに
これまでBacklogが提供していた記法
アップデートされたMarkdown記法の説明をする前に、Backlogがこれまで提供していた記法をご紹介します。
Backlogでこれまでサポートしていた記法は下記の2つです。
Backlog記法はサービス開始の2005年6月に出来たもので、これはMarkdown記法がデファクトスタンダードになる以前になります。そのため、Backlog記法は見出しやリストなど多くの記法が現在主流のMarkdownと異なります。
これまでの記法による不利益
Backlogが初めて軽量マークアップ言語としてBacklog記法を提供したときは、今のようにデファクトスタンダードとなるものがありませんでした。そのため、Backlog記法は現在多く使用されているMarkdown記法とは大きく異なります。
また、BacklogがサポートしているMarkdown記法も現在のデファクトスタンダードであるGFM準拠のMarkdown記法とは少し記法の解釈が異なります。
採用した当初は問題は大きくなかったものの、GFM準拠のMarkdownが広く使用されるようになった現在ではこれらの記法の違いによる問題が顕在化しつつありました。
マークダウン記法の流れと変遷
そもそもMarkdown記法は2004年にジョングルーバー氏とアーロンシュワルツ氏が軽量マークアップ言語として開発したのが始まりです。ただ、このオリジナルのMarkdown記法は最低限の記法しかサポートされていないことや、仕様が明確に決まっていないことを背景として多くの拡張や派生系が登場しました。これが原因で、あるシステムでレンダリングされるドキュメントが別のシステムでレンダリングを行うと別の処理結果を返すことがよくありました。そこで、誕生したのがCommonMarkというMarkdownの仕様です。これは乱立するMarkdownの派生系を統一するために、かつて曖昧だった仕様を明確にして標準化したものです。現在デファクトスタンダードであるGFMはこのCommonMarkの拡張仕様です。
BacklogがサポートしていたMarkdown記法はCommonMarkに準拠しないライブラリを使用して実装されています。これが原因で他サービスとMarkdown記法に解釈の差異が生まれて、ユーザーに使いづらい印象を与えていました。
この問題を解消するためにBacklogでは、CommonMarkやそれの拡張であるGFMを含む、多数の派生系をサポートするflexmark-javaライブラリの導入を行いました。
新マークダウン記法を支えるライブラリ
flexmark-javaはCommonMarkの仕様をベースにしたcommonmark-javaのフォークであり、下記の特徴を持ったJava製のMarkdownパーサーライブラリです。
- 高速な処理速度
- 異常な入力ケースがない場合、平均的な解析パフォーマンスはpegdownの30倍以上
 
 - 優れた柔軟性と拡張性
- AST(抽象構文木)でMarkdownの解析結果を保持することで各要素が追跡可能
 - プラグインによる高度な機能拡張が可能
 
 - 幅広い派生記法のサポート
- CommonMark、FixedIndent、GFM、Kramdown、Markdown.plなどの派生記法をサポート
 
 
flexmark-javaでMarkdownパーサーを実装してみる
デフォルト記法の実装
flexmark-javaで実装できる最低限の機能を有したMarkdownパーサーが下記です。
import com.vladsch.flexmark.util.data.MutableDataSet
import com.vladsch.flexmark.parser.Parser
import com.vladsch.flexmark.html.HtmlRenderer
class ExampleRenderingEngine {
  def render(source: String): String = {
    val options = new MutableDataSet()
    val parser = Parser.builder(options).build()
    val renderer = HtmlRenderer.builder(options).build()
    val document = parser.parse(source)
    renderer.render(document)
  }
}
flexmark-javaはデフォルトでCommonMarkに準拠しているのでこの状態でも基本的な記法には対応しています。もし、拡張やオプションを追加したい場合は上記のoptionsに設定をします。今はoptionsに何も設定していないので素のCommonMark準拠のMarkdownレンダラーです。
オプションの追加
GFM準拠のオプションとフットノートの拡張を有効化したMarkdownパーサーが下記です。
import com.vladsch.flexmark.util.data.MutableDataSet
import com.vladsch.flexmark.parser.{Parser, ParserEmulationProfile}
import com.vladsch.flexmark.html.HtmlRenderer
import com.vladsch.flexmark.ext.footnotes.FootnoteExtension
import com.vladsch.flexmark.util.misc.Extension
class ExampleRenderingEngine {
  def render(source: String): String = {
    val options = new MutableDataSet()
    val extension: Seq[Extension] = Seq(FootnoteExtension.create())
    options.setFrom(ParserEmulationProfile.GITHUB)
    options.set(Parser.EXTENSIONS, extension.asJava)
    val parser = Parser.builder(options).build()
    val renderer = HtmlRenderer.builder(options).build()
    val document = parser.parse(source)
    renderer.render(document)
  }
}
先ほど説明したoptionsにGFMとフットノートの設定を追加するだけで有効化が出来ます。これだけでも十分リッチなMarkdownパーサーができますが、flexmark-javaは用意されていない拡張も柔軟に追加が可能です。
独自記法を実装してみる
さて、今回Backlogは新Markdownパーサーにこれまでサポートしていた課題リンク記法や、Wikiページリンク記法、Gitリビジョンリンク記法、絵文字記法など様々な独自記法を実装しました。この章では新たに独自のカスタム記法を実装することでflexmark-javaの仕組みを紐解いていきます。
今回は実装例として注釈記法を独自のカスタム記法として実装してみます。下記のMarkdown記法のテキストを下記のHTMLに出力するパーサーを考えます。
この単語には注釈^(注釈内容)があります。
<p>この単語には注釈<span class="tooltip" title="注釈内容">ⓘ</span>があります。</p>
ノードの作成
そもそもflexmark-javaはMarkdownテキストの処理を下記のステップで行います。
- ブロック記法をブロックノードへパース
 - パースされた段落ノードからテキストとして出力すべきでない行を削除
 - ブロックノードの加工
 - インライン記法をインラインノードへパース
 - ブロックノード、インラインノードの加工
 - ASTノードからHTMLを出力
 - 外部ファイルとの依存関係を解決
 
今回実装する注釈記法はインライン記法ですので、上記ステップの4と6の処理をカスタマイズします。
まずはインライン記法のパース先のノードを用意します。
import com.vladsch.flexmark.util.ast.Node
import com.vladsch.flexmark.util.sequence.BasedSequence
class Annotation(val text: BasedSequence, val source: BasedSequence) extends Node {
  override def getSegments: Array[BasedSequence] = Array(source)
}
ノードを作成するにはflexmark-javaの用意するNodeを継承します。textはHTMLに出力する際にspanタグのtitle属性に挿入するためのもので、sourceは継承するNodeクラスのgetSegmentsメソッドに与えて、ASTにAnnotationノードがMarkdown内のどこの範囲に対応しているかを知らせるためのものです。
インラインパーサーの作成
今回実装する注釈記法はインライン記法なので、これをパースするためにインラインパーサーを用意します。これは前述のステップの4、「インライン記法をインラインノードへパース」の処理に相当します。
import com.vladsch.flexmark.parser.{InlineParser, InlineParserExtension, InlineParserExtensionFactory, LightInlineParser}
import java.util
import java.util.regex.Pattern
class AnnotationInlineParserExtension() extends InlineParserExtension {
  override def finalizeDocument(inlineParser: InlineParser): Unit = {}
  override def finalizeBlock(inlineParser: InlineParser): Unit = {}
  override def parse(inlineParser: LightInlineParser): Boolean = {
    val patternText = """\^\((.+?)\)"""
    val matches = inlineParser.matchWithGroups(Pattern.compile(patternText))
    if (matches != null) {
      inlineParser.flushTextNode()
      val annotationText = matches(1)
      inlineParser.getBlock.appendChild(new Annotation(annotationText, matches(0)))
      return true
    }
    false
  }
}
object AnnotationInlineParserExtension {
  class Factory() extends InlineParserExtensionFactory {
    override def getCharacters: CharSequence = "^"
    override def apply(inlineParser: LightInlineParser): InlineParserExtension = new AnnotationInlineParserExtension()
    override def getAfterDependents: util.Set[Class[_]] = null
    override def getBeforeDependents: util.Set[Class[_]] = null
    override def affectsGlobalScope(): Boolean = false
  }
}
インラインパーサーのファクトリークラス、AnnotationInlineParserExtension.FactoryのgetCharactersメソッドでは注釈記法の先頭文字^を指定します。これに引っ掛かればインラインパーサーのインスタンスを生成してパースを行います。インラインパーサーであるAnnotationInlineParserExtensionのparseメソッドで注釈記法の正規表現に引っ掛かれば先ほど実装した注釈ノード(Annotationノード)を生成して現在のブロックノードに追加します。
インラインパーサーによる注釈記法のパースが完了しましたので、現在のASTは下記の図のようになります。
ノードレンダラーの作成
先ほどパースしたノードをHTMLに変換します。ここは前述のステップの6、「ASTノードからHTMLを出力」の処理に相当します。
import com.vladsch.flexmark.html.HtmlWriter
import com.vladsch.flexmark.html.renderer.{NodeRenderer, NodeRendererContext, NodeRendererFactory, NodeRenderingHandler}
import com.vladsch.flexmark.util.data.DataHolder
import java.util
class AnnotationNodeRenderer() extends NodeRenderer {
  override def getNodeRenderingHandlers: util.Set[NodeRenderingHandler[_]] = {
    val set: util.HashSet[NodeRenderingHandler[_]] = new util.HashSet[NodeRenderingHandler[_]]
    set.add(new NodeRenderingHandler[Annotation](classOf[Annotation], this.render))
    set
  }
  private def render(node: Annotation, context: NodeRendererContext, html: HtmlWriter): Unit = {
    html
      .withAttr()
      .attr("class", "tooltip")
      .attr("title", node.text.toString)
      .tag("span")
    html.text("ⓘ")
    html.tag("/span")
  }
}
object AnnotationNodeRenderer {
  class Factory() extends NodeRendererFactory {
    override def apply(options: DataHolder): NodeRenderer = new AnnotationNodeRenderer()
  }
}
getNodeRenderingHandlersメソッドはノードの種類ごとに対応するレンダリング処理を登録するためのセットを返します。set.addメソッドで特定のノードに対してどのレンダリング処理を行うかの情報を登録します。ここではAnnotationクラスのインスタンスに対してプライベートメソッドのrenderメソッドをレンダリング処理として行うよう指定しています。
拡張の作成
import com.vladsch.flexmark.html.HtmlRenderer
import com.vladsch.flexmark.html.HtmlRenderer.HtmlRendererExtension
import com.vladsch.flexmark.parser.Parser
import com.vladsch.flexmark.parser.Parser.ParserExtension
import com.vladsch.flexmark.util.data.MutableDataHolder
class AnnotationExtension extends ParserExtension with HtmlRendererExtension {
  override def parserOptions(options: MutableDataHolder): Unit = {}
  override def rendererOptions(options: MutableDataHolder): Unit = {}
  override def extend(parserBuilder: Parser.Builder): Unit =
    parserBuilder.customInlineParserExtensionFactory(new AnnotationInlineParserExtension.Factory())
  override def extend(htmlRendererBuilder: HtmlRenderer.Builder, rendererType: String): Unit =
    htmlRendererBuilder.nodeRendererFactory((new AnnotationNodeRenderer.Factory()))
}
object AnnotationExtension {
  def create(): AnnotationExtension = new AnnotationExtension()
}
先ほど実装したインラインパーサーとノードレンダラーをまとめます。今後拡張記法をオンオフする際は上記のAnnotationExtensionを取り回すことで実現します。
拡張記法の有効化
import com.vladsch.flexmark.util.data.MutableDataSet
import com.vladsch.flexmark.parser.{Parser, ParserEmulationProfile}
import com.vladsch.flexmark.html.HtmlRenderer
import com.vladsch.flexmark.ext.footnotes.FootnoteExtension
import com.vladsch.flexmark.util.misc.Extension
import com.example.app.flexmark.extensions.annotation.AnnotationExtension
class ExampleRenderingEngine {
  def render(source: String): String = {
    val options = new MutableDataSet()
    val extension: Seq[Extension] = Seq(FootnoteExtension.create(), AnnotationExtension.create())
    options.setFrom(ParserEmulationProfile.GITHUB)
    options.set(Parser.EXTENSIONS, extension.asJava)
    val parser = Parser.builder(options).build()
    val renderer = HtmlRenderer.builder(options).build()
    val document = parser.parse(source)
    renderer.render(document)
  }
}
「flexmark-javaでMarkdownパーサーを実装してみる」の章で実装した、GFMとフットノートだけを有効化したコードに注釈記法を追加したコードが上記です。optionsに拡張記法を追加するだけで拡張記法が有効化でき、柔軟性に富んだ運用が可能です。これで新たに独自記法として注釈記法を実装できました。
例えば、今回Backlogがリリースしたβ版の新Markdownに含まれるGitリビジョンリンク記法もインライン記法ですので、先ほど説明した注釈記法と同じくステップ4、「インライン記法をインラインノードへパース」の処理とステップ6、「ASTノードからHTMLを出力」の処理をカスタマイズすることで実装しています。
ただ、Gitリビジョンリンクを含むドメイン独自の記法はデータソースへの問い合わせが発生することがあります。その際は可用性を担保するための設計など考えることは増えていきます。
終わりに
BacklogがMarkdownパーサーを新しくするにあたっての背景と、それを実現するflexmark-javaについて解説をしました。本記事がMarkdownパーサーを選定する上で少しでも参考になれば幸いです。
