概要
GitBucket用のプラグイン GitBucket Markdown Enhanced Pluginを開発しています。
GitBucket Markdown Enhanced Plugin は、GitBucket 標準のマークダウンレンダリングエンジンを置き換えるプラグインです。
目標は、Visual Studio Code の Markdown Preview Enhanced 向けの markdown ファイルを軽易に Web で共有できる環境です。
今回は、以下の機能を実装しました。
- Graphvizの描画サポート
- URI を自動でハイパーリンクに変換する機能(メールアドレスは非対応)
前回の記事
Graphviz の描画機能の追加
当初、viz.js を使おうかと思いましたが、以下の理由から、同梱の PlantUML を利用することにしました。
- 私の知っている viz.js の正当な upstream が分からない
- 昔と使い方が大きく変わってしまった?
- あまりクライアント側に負荷を書けたくなかった
- PlantUML が Graphviz の描画に対応している。(元々、Graphviz に依存している。)
既に実装済みの PlantUML の描画部分を共通化して、独自処理が必要な部分を作成しました。
src\main\scala\io\github\yasumichi\gme\MarkdownEnhancedNodeRenderer.scala を修正しました。
/**
* Renders a Graphviz dot diagram from a fenced code block.
*
* @param html HtmlWriter to write the output.
* @param node FencedCodeBlock containing the dot code.
*/
private def renderDot(html: HtmlWriter, node: FencedCodeBlock, info: String): Unit = {
var text = "@startdot\n"
var seqs = node.getContentLines().toArray()
for (i <- 0 to seqs.length - 1) text = text + seqs(i).toString + "\n"
text = text + "@enddot\n"
val patternText = """\{\s*engine=\"(.*)\"\s*\}""".r
val matches = patternText.findFirstMatchIn(info)
var engine = ""
matches match {
case Some(m) =>
engine = m.group(1)
case None =>
engine = "dot"
}
logger.debug("Graphviz engine: " + engine)
if (!engine.equals("dot")) {
text = text.replace(
"{",
s"""{
| graph [layout="${engine}"];
|""".stripMargin
)
}
renderSVG(html, text)
}
Visual Studio Code の Markdown Preview Enhanced プラグインが実装しているエンジンの切替記法も使えるようにしました。
下記コードの {engine="circo"} です。
```dot {engine="circo"}
digraph G {
A -> B
B -> C
B -> D
}
```
念のため、SVG への変換部分を掲載します。
/**
* Renders SVG from the given PlantUML or dot text.
*
* @param html HtmlWriter to write the output.
* @param text The PlantUML or dot text to render.
*/
private def renderSVG(html: HtmlWriter, text: String): Unit = {
val reader = new SourceStringReader(text)
val os = new ByteArrayOutputStream()
reader.generateImage(os, new FileFormatOption(FileFormat.SVG))
os.close()
var svg = new String(os.toByteArray(), Charset.forName("UTF-8"))
var re = "^<\\?xml [^<>]+?\\>".r
svg = re.replaceFirstIn(svg, "")
html.tag("div")
html.append(svg)
html.tag("/div")
}
URI を自動でハイパーリンクに変換する機能
当初、flexmark-java の AutolinkExtension という拡張機能を使おうとしましたが、気になる記述を見つけたので自力実装しました。
current implementation has significant performance impact on large files.
翻訳すると「現在の実装は、大型ファイルに対して大きなパフォーマンスに影響を与える。」とのことです。
まずは、URI 部分を格納する AST Node を作成しました。
src\main\scala\io\github\yasumichi\gme\InlineUri.scala
package io.github.yasumichi.gme
import com.vladsch.flexmark.util.ast.Node
import com.vladsch.flexmark.util.sequence.BasedSequence
/**
* InlineUri class
*
* AST node that holds the inline URI syntax of flexmark-java.
*
* `http://example.com` is represented as InlineUri node.
*
* An instance is created by the InlineUriInlineParserExtension class.
*
* The conversion to HTML is handled by the MarkdownEnhancedNodeRenderer class.
*
* @param text
* @param source
*/
class InlineUri(val text: BasedSequence, val source: BasedSequence) extends Node {
override def getSegments: Array[BasedSequence] = Array(source)
}
次にインラインパーサーを作成しました。
src\main\scala\io\github\yasumichi\gme\InlineUriInlineParserExtension.scala
package io.github.yasumichi.gme
import com.vladsch.flexmark.parser.{
InlineParser,
InlineParserExtension,
InlineParserExtensionFactory,
LightInlineParser
}
import java.util
import java.util.regex.Pattern
/**
* InlineUriInlineParserExtension class
*
* Inline parser extension that parses inline URIs like `http://example.com` and creates InlineUri nodes.
*
* If you write `http://example.com`, it will be converted to an InlineUri node.
*
* The conversion to HTML is handled by the MarkdownEnhancedNodeRenderer class.
*/
class InlineUriInlineParserExtension() extends InlineParserExtension {
override def finalizeDocument(inlineParser: InlineParser): Unit = {}
override def finalizeBlock(inlineParser: InlineParser): Unit = {}
override def parse(inlineParser: LightInlineParser): Boolean = {
val input = inlineParser.getInput
val index = inlineParser.getIndex
// Define a regex pattern to match inline URIs
val pattern = """https?:\/\/[\w\/:%#\$&\?\(\)~\.=\+\-]+"""
val matches = inlineParser.matchWithGroups(Pattern.compile(pattern))
if (matches != null) {
inlineParser.flushTextNode()
val uriText = matches(0)
inlineParser.getBlock.appendChild(new InlineUri(uriText, uriText))
return true
}
false
}
}
/**
* Companion object for InlineUriInlineParserExtension
*
* Factory class to create instances of InlineUriInlineParserExtension.
*
* This factory is used to register the extension with the flexmark parser.
*
* @return InlineUriInlineParserExtension.Factory
*/
object InlineUriInlineParserExtension {
class Factory() extends InlineParserExtensionFactory {
override def getCharacters: CharSequence = "h"
override def apply(inlineParser: LightInlineParser): InlineParserExtension = new InlineUriInlineParserExtension()
override def getAfterDependents: util.Set[Class[_]] = null
override def getBeforeDependents: util.Set[Class[_]] = null
override def affectsGlobalScope(): Boolean = false
}
}
URI かどうかを判定する正規表現は、正規表現でマッチさせる(URLとメールアドレス編) - きままに記録箱のものを拝借しました。
作成したインラインパーサーを組み込むために既存のクラス MarkdownEnhancedExtention の extend メソッドを周囲しました。
src\main\scala\io\github\yasumichi\gme\MarkdownEnhancedExtention.scala
/**
* Extend the parser with custom inline parser extension
*
* @param parserBuilder The parser builder to extend
*/
override def extend(parserBuilder: Parser.Builder): Unit = {
parserBuilder.customInlineParserExtensionFactory(new MarkInlineParserExtension.Factory())
parserBuilder.customInlineParserExtensionFactory(new InlineUriInlineParserExtension.Factory())
}
実際にハイパーリンクに変換する部分は、既存のクラス MarkdownEnhancedNodeRenderer に追加しました。
src\main\scala\io\github\yasumichi\gme\MarkdownEnhancedNodeRenderer.scala
/**
* Renders an inline URI as a clickable link.
*
* If you write `http://example.com`, it will be converted to `<a href="http://example.com">http://example.com</a>`.
*
* @param node InlineUri node representing the inline URI.
* @param context NodeRendererContext for rendering context.
* @param html HtmlWriter to write the output.
*/
private def renderInlineUri(
node: InlineUri,
context: NodeRendererContext,
html: HtmlWriter
): Unit = {
val uri = node.text.toString()
html
.withAttr()
.attr("href", uri)
.tag("a")
html.text(uri)
html.tag("/a")
}
renderInlineUri メソッドが呼ばれるように getNodeRenderingHandlers メソッドに以下の記述を追加しました。
set.add(
new NodeRenderingHandler[InlineUri](
classOf[InlineUri],
this.renderInlineUri
)
)
2025/12/09 追記
InlineUriInlineParserExtension の parse メソッドにバグがあり、[アンカー] (URI) で生成されるリンクを崩してしまう場合がありました。
現在のコード では、以下のように修正しています。
override def parse(inlineParser: LightInlineParser): Boolean = {
val input = inlineParser.getInput
val index = inlineParser.getIndex
// Define a regex pattern to match inline URIs
val pattern = """(^|[^\(]+)(https?:\/\/[\w\/:%#\$&\?\(\)~\.=\+\-]+)"""
val matches = inlineParser.matchWithGroups(Pattern.compile(pattern))
if (matches != null) {
inlineParser.flushTextNode()
val uriText = matches(2)
inlineParser.getBlock.appendChild(new InlineUri(uriText, uriText))
return true
}
false
}
直前が ( 以外の文字か行頭である場合に変換するように修正しました。
2025/12/10 追記
HTML の埋め込みに対する考慮が漏れていました。直前の文字が " の場合も除外するように修正しました。
スクリーンショット
# test
http://localhost:8080/gitbucket/yasumichi/test/
example@example.com
```dot {engine="circo"}
digraph foo {
PlantUML -> Dot [label=use];
}
```
上記マークダウンが、スクリーンショットのように変換できました。
