Jekyll v4 の syntax highlight に Rouge::Formatters::HTMLLinewise を使えるようにする

GitHub Pages が Actions からデプロイできるようになって ruby/setup-ruby を使えば自由に _plugins の使用ができるようになったので、素の Kramdown だと利用できない Hash ではない引数が必要な Rouge::Formatters を使えるようにモンキーパッチをあてる。

デフォルトで使われる Rouge::Formatters::HTMLLegacy は行番号をつけると Rouge::Formatters::HTMLTable を呼び出し <table> でレイアウトするため、コードブロックが pre > code table tbody pre のような階層になり、行番号が要素の属性ではなくテキストとして含まれてしまいマークアップとして汚くなるので、 Rouge::Formatters::HTMLLinewise を使いたい。

Table of Contents
  1. 環境
  2. マークアップ
    1. 最初の設定
    2. Kramdown::Converter::SyntaxHighlighter::Rouge にモンキーパッチをあてる
    3. Rouge::Formatters::HTMLPygments で包む
    4. 未対応の言語でも行番号だけ表示する
  3. スタイリング
    1. 行番号
      1. 行番号表示の切替
  4. まとめ

環境

マークアップ

最初の設定

markdown: kramdown
highlighter: rouge

kramdown:
  input: GFM
  syntax_highlighter: rouge

  syntax_highlighter_opts:
    css_class: highlight
    block:
      formatter: HTMLLinewise
      tag_name: span
      class: line
---
layout: default
---

```
sample
code block
```

行番号が必要なのは block だけなので :kramdown, :syntax_highlighter_opts, :block:formatter に HTMLLinewise を指定して :tag_name:class を設定する。

$ bundle exec jekyll build --trace
Configuration file: ./sample-jekyll/_config.yml
            Source: ./sample-jekyll
       Destination: ./sample-jekyll/_site
 Incremental build: disabled. Enable with --incremental
      Generating... 
  Conversion error: Jekyll::Converters::Markdown encountered an error while converting 'index.md':
                    undefined method `stream' for {:css_class=>"highlight", :default_lang=>"plaintext", :guess_lang=>true, :formatter=>"HTMLLinewise", :tag_name=>"span", :class=>"line"}:Hash @formatter.stream(line_tokens) {|formatted| yield formatted } ^^^^^^^ Did you mean? store
bundler: failed to load command: jekyll (/usr/local/lib/ruby/gems/3.0.0/bin/jekyll)
/usr/local/lib/ruby/gems/3.0.0/gems/rouge-3.30.0/lib/rouge/formatters/html_linewise.rb:16:in `block in stream': undefined method `stream' for {:css_class=>"highlight", :default_lang=>"plaintext", :guess_lang=>true, :formatter=>"HTMLLinewise", :tag_name=>"span", :class=>"line"}:Hash (NoMethodError)

          @formatter.stream(line_tokens) {|formatted| yield formatted }
                    ^^^^^^^
Did you mean?  store
	from /usr/local/lib/ruby/gems/3.0.0/gems/rouge-3.30.0/lib/rouge/formatter.rb:99:in `block (2 levels) in token_lines'
// ...
	from /usr/local/lib/ruby/gems/3.0.0/gems/kramdown-2.4.0/lib/kramdown/converter/syntax_highlighter/rouge.rb:34:in `call'

Rouge::Formatters::HTMLLinewise.new/1:syntax_highlighter_opts を渡してしまうため使えない。

Kramdown::Converter::SyntaxHighlighter::Rouge にモンキーパッチをあてる

Kramdown::Converter::SyntaxHighlighter::Rouge.call/5(rouge.rb#L24-L35) で formatter_class(opts).new(opts) している部分を変更して .singleton_class.prepend/1 する。

require 'kramdown/converter/syntax_highlighter/rouge'

module KramdownSyntaxHighlighterFix
  def call(converter, text, lang, type, call_opts)
    opts = options(converter, type)
    call_opts[:default_lang] = opts[:default_lang]
    return nil unless lang || opts[:default_lang] || opts[:guess_lang]

    lexer = ::Rouge::Lexer.find_fancy(lang || opts[:default_lang], text)
    return nil if opts[:disable] || !lexer || (lexer.tag == "plaintext" && !opts[:guess_lang])

    opts[:css_class] ||= 'highlight'
    formatter = new_formatter(formatter_class(opts), opts) # initialize 呼び出しを包む
    formatter.format(lexer.lex(text))
  end

  # 既知の Rouge:Formatters の initialize 引数の形式で出し分ける
  def new_formatter(formatter, opts)
    case
    when "Rouge::Formatters::HTMLInline" === formatter.to_s
      formatter.new(opts.fetch([:inline_theme], 'github'))

    when ["Rouge::Formatters::Terminal256",
          "Rouge::Formatters::TerminalTruecolor"].include?(formatter.to_s)
      opts[:theme] ? formatter.new(opts[:theme]) : formatter.new()

    when "Rouge::Formatters::HTMLPygments" === formatter.to_s
      formatter.new(base_formatter(opts), opts.fetch(:css_class, 'codehilite'))

    when ["Rouge::Formatters::HTMLTable",
          "Rouge::Formatters::HTMLLinewise",
          "Rouge::Formatters::HTMLLineTable",
          "Rouge::Formatters::HTMLLineHighlighter"].include?(formatter.to_s)
      formatter.new(base_formatter(opts), opts)

    else
      # Rouge::Formatter
      # Rouge::Formatters::HTML
      # Rouge::Formatters::HTMLLegacy
      # Rouge::Formatters::Tex
      # Rouge::Formatters::Null
      formatter.new(opts)
    end
  end

  def base_formatter(opts)
    opts[:inline_theme] ? Rouge::Formatters::HTMLInline.new(opts[:inline_theme])
                        : Rouge::Formatters::HTML.new
  end
end

Kramdown::Converter::SyntaxHighlighter::Rouge.singleton_class.prepend(KramdownSyntaxHighlighterFix)

これで各行が <span class=line> \n</span> で囲われてマークアップされた index.html が生成される

$ bundle exec jekyll build --trace
Configuration file: ./sample-jekyll/_config.yml
            Source: ./sample-jekyll
       Destination: ./sample-jekyll/_site
 Incremental build: disabled. Enable with --incremental
      Generating... 
     Build Warning: Layout 'default' requested in index.md does not exist.
                    done in 0.134 seconds.
 Auto-regeneration: disabled. Use --watch to enable.
<div class="language-plaintext highlighter-rouge"><span class="line">sample
</span><span class="line">code block
</span></div>

Rouge::Formatters::HTMLPygments で包む

<pre><code> で囲うために HTMLPygments を使う。 Rouge::Formatters::HTMLLegacy:wrap を設定すると HTMLPygments を一番最後に使うので同じようにする。

--- a/_config.yml
+++ b/_config.yml
@@ -11,3 +11,4 @@
       formatter: HTMLLinewise
       tag_name: span
       class: line
+      wrap: true
--- a/_plugins/kramdown-syntax-highlighter-rouge-patch.rb
+++ b/_plugins/kramdown-syntax-highlighter-rouge-patch.rb
@@ -11,6 +11,9 @@
 
     opts[:css_class] ||= 'highlight'
     formatter = new_formatter(formatter_class(opts), opts) # initialize 呼び出しを包む
+    if opts[:wrap] && !formatter.is_a?(Rouge::Formatters::HTMLPygments)
+      formatter = Rouge::Formatters::HTMLPygments.new(formatter, opts.fetch(:css_class, 'codehilite'))
+    end
     formatter.format(lexer.lex(text))
   end
 

div.highlight pre.highlight code で囲われた結果が入る

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="line">sample
</span><span class="line">code block
</span></code></pre></div></div>

未対応の言語でも行番号だけ表示する

--- b/_plugins/kramdown-syntax-highlighter-rouge-patch.rb
+++ c/_plugins/kramdown-syntax-highlighter-rouge-patch.rb
@@ -7,7 +7,9 @@
     return nil unless lang || opts[:default_lang] || opts[:guess_lang]
 
     lexer = ::Rouge::Lexer.find_fancy(lang || opts[:default_lang], text)
-    return nil if opts[:disable] || !lexer || (lexer.tag == "plaintext" && !opts[:guess_lang])
+    return nil if !lexer && !lang
+    return call(converter, text, nil, type, call_opts) unless lexer
+    return nil if opts[:disable] || (lexer.tag == "plaintext" && !opts[:guess_lang])
 
     opts[:css_class] ||= 'highlight'
     formatter = new_formatter(formatter_class(opts), opts) # initialize 呼び出しを包む

未対応の言語で Rouge::Lexernil を返した時に :default_lang でフォールバックする。

スタイリング

HTML の出力はこれで完成なのであとは CSS で行番号などの表示を調整する。

行番号

以前、preに行番号を追加してスクロールで動かないようにする方法を記事にした時に

各行を span で囲ってカウンターで行数を表示する方が行数を調べる手間がかからないので楽だろうと思う

と言っていたことそのままの CSS で実装できる。入れ子要素のセレクタはめんどくさいので SCSS 形式で記述する

$padding-y: 0.3em;
$padding-x: 1ex;

div.highlighter-rouge > div.highlight > pre.highlight {
  counter-reset: line-number; // コードブロックになるたびにカウンタをリセット

  overflow: auto;
  overscroll-behavior: none; // 端までスクロールして行番号が動かないように

  > code > span.line {
    counter-increment: line-number; // 行毎にカウンタをインクリメント
    padding-right: $padding-x;

    &::before {
      content: counter(line-number); // カウンタを出力する

      text-align: right;

      position: sticky; // 行番号を固定表示
      inset: 0;

      display: inline-block;
      box-sizing: border-box;
      width: 5ex;
      padding-right: 1ex;

      margin-right: $padding-x;
    }

    &:first-child::before {
      padding-top: $padding-y;
    }

    &:last-child::before {
      padding-bottom: $padding-y;
    }
  }
}

一切 CSS がかかってない環境ではこれで行番号が適切につき、 prespan.line::beforebackground-color, color を指定すれば隙間なく綺麗な表示になる。

行番号表示の切替

しかし、 .language-console などのようにファイルではない環境でも行番号が付くのは微妙なので先に行番号なしのスタイリングを行い、不要な言語を :not() 擬似クラスで無視して適用するようにする。

$padding-y: 0.3em;
$padding-x: 1ex;

div.highlighter-rouge {
  > div.highlight > pre.highlight {
    padding: $padding-y $padding-x;

    overflow: auto;

    > code {
      padding: 0;
      margin: 0;

      > span.line {
        padding-right: $padding-x;
      }
    }
  }

  &:not(.language-console):not(.language-terminal) > div.highlight > pre.highlight {
    counter-reset: line-number; // コードブロックになるたびにカウンタをリセット

    padding: 0; // padding をリセット。行番号に padding をつけない。

    overscroll-behavior: none; // 端までスクロールして行番号が動かないように

    > code > span.line {
      counter-increment: line-number; // 行毎にカウンタをインクリメント

      &::before {
        content: counter(line-number); // カウンタを出力する

        text-align: right;

        position: sticky; // 行番号を固定表示
        inset: 0;

        display: inline-block;
        box-sizing: border-box;
        width: 5ex;
        padding-right: 1ex;

        margin-right: $padding-x;
      }

      &:first-child::before {
        padding-top: $padding-y;
      }

      &:last-child::before {
        padding-bottom: $padding-y;
      }
    }
  }
}

これで console のときだけ行番号を表示しなくなる。

まとめ

Kramdown で HTMLLinewise を使えるようにパッチをあてることで Jekyll でも可変長の行番号を静的に出力できるようになった。 GitHub Actions で bundle exec jekyll build の結果をそのままデプロイするようになって、雑に解決できる方法が使えるようになったのが嬉しい。

モンキーパッチでどうにかしていくのはあまり良くないと思うので正攻法というか一番いいのは Formatter のプロトコルをちゃんと実装して Rouge に投げることだとは思うから気が向いたらやってみよう。