So I Still Want to Put Code in My Blog

A few years ago I wrote about creating a custom Jekyll plugin for code blocks. It served me well, but recently I dusted off this blog and decided to revisit the code. I found a few issues worth fixing.

The original plugin used the tubby gem to build HTML. Tubby is a nice little library, but it hasn’t been updated since January 2021 and I didn’t really need it. A heredoc works just as well and removes a dependency.

The original Tubby code:

original HTML generation
1
2
3
4
5
6
7
8
Tubby.new do |tag|
  tag.figure(class: 'code-example') do
    tag.figcaption(title, class: 'title')
    tag.aside(@params[:comment]) if @params[:comment]
    tag.div(class: 'overflow') { tag.raw! source }
  end
end.to_s

Replaced with a simple heredoc:

new HTML generation
1
2
3
4
5
6
7
8
<<~HTML
  <figure class="code-example">
    #{title_html}
    #{aside_html}
    <div class="overflow">#{source}</div>
  </figure>
HTML

I also discovered a bug in my lexer lookup. The original code used const_get with manual capitalization:

original lexer lookup
1
2
3
4
5
6
7
8
9
10
11
12
13
14
formatter = Rouge::Formatters::HTMLTable.new(
  Rouge::Formatters::HTML.new,
  table_class: 'code',
  gutter_class: 'gutter',
  code_class: 'source ' + language
)
language = language.capitalize if language == language.downcase
lexer =
  begin
    Rouge::Lexers.const_get(language).new
  rescue NameError
    Rouge::Lexers::Markdown.new
  end

I was using const_get with a fallback which was fine, but Rouge::Lexer.find(language) is a much better approach.

new lexer lookup
1
2
lexer = Rouge::Lexer.find(language&.downcase) || Rouge::Lexers::PlainText.new

Rouge::Lexer.find handles aliases and returns nil if the language isn’t found, which is cleaner than rescuing a NameError.

The original code always rendered a <figcaption> even when no title was provided. Now, title and aside elements only render when they have content:

conditional rendering
1
2
3
4
5
6
7
8
9
10
def title_html
  return '' if @params[:title].to_s.empty?
  %(<figcaption class="title">#{@params[:title]}</figcaption>)
end

def aside_html
  return '' if @params[:comment].to_s.empty?
  %(<aside>#{@params[:comment]}</aside>)
end

Here’s the refactored plugin:

code.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# frozen_string_literal: true

require 'rouge'

class CodeBlock < Liquid::Block
  def initialize(_tag_name, params, _tokens)
    super
    @params = parse_params(params)
  end

  def render(_context)
    code = super
    language = @params[:language]

    <<~HTML
      <figure class="code-example">
        #{title_html}
        #{aside_html}
        <div class="overflow">#{highlight(code:, language:)}</div>
      </figure>
    HTML
  end

  private

  def highlight(code:, language:)
    lexer = Rouge::Lexer.find(language&.downcase) || Rouge::Lexers::PlainText.new
    formatter = Rouge::Formatters::HTMLTable.new(
      Rouge::Formatters::HTML.new,
      table_class: 'code',
      gutter_class: 'gutter',
      code_class: "source #{language}"
    )
    formatter.format(lexer.lex(code))
  end

  def title_html
    return '' if @params[:title].to_s.empty?
    %(<figcaption class="title">#{@params[:title]}</figcaption>)
  end

  def aside_html
    return '' if @params[:comment].to_s.empty?
    %(<aside>#{@params[:comment]}</aside>)
  end

  def parse_params(params)
    params.scan(Liquid::TagAttributes).to_h { |k, v| [k.to_sym, v.delete('"')] }
  end
end

Liquid::Template.register_tag('code', CodeBlock)

The changes:

  1. Removed the tubby dependency—one less gem to worry about
  2. Used Rouge::Lexer.find for better language lookup
  3. Extracted highlight, title_html, and aside_html methods for readability
  4. Empty elements no longer render
  5. Simplified parse_params using Liquid::TagAttributes and .to_h
  6. Changed the default language from 'markdown' to 'plaintext'

While I was at it, I noticed that inline code references were inconsistent with the code blocks in my blocks. When I mention Rouge::Lexer.find in a sentence, it should have the same syntax highlighting as it does in a code block—Rouge and Lexer as constants, find as a method name.

So I created a companion plugin for inline code:

inline_code.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# frozen_string_literal: true

require 'rouge'

class InlineCode < Liquid::Block
  def initialize(_tag_name, params, _tokens)
    super
    @params = parse_params(params)
  end

  def render(_context)
    code = super.strip
    language = @params[:language]
    token = @params[:token]
    if token
      manual(code:, language:, token:)
    else
      auto(code:, language:)
    end
  end

  private

  def parse_params(params)
    params.scan(Liquid::TagAttributes).to_h { |k, v| [k.to_sym, v.delete('"')] }
  end

  def manual(code:, language:, token:)
    %(<code class="highlight #{language}"><span class="#{token}">#{code}</span></code>)
  end

  def auto(code:, language:)
    lexer = Rouge::Lexer.find(language) || Rouge::Lexers::PlainText.new
    formatter = Rouge::Formatters::HTML.new
    highlighted = formatter.format(lexer.lex(code))
    %(<code class="highlight #{language}">#{highlighted}</code>)
  end
end

Liquid::Template.register_tag('ic', InlineCode)

The usage is similar to the code block plugin:

usage
1
2
{% ic language: "ruby" %}Rouge::Lexer.find{% endic %}

Sometimes Rouge doesn’t have enough context to identify a token correctly. For example, find on its own is identified as a local variable, not a method. The optional token parameter lets you override the styling:

token override
1
2
{% ic language: "ruby", token: "nf" %}find{% endic %}

The syntax highlighting classes are shared between the block and inline plugins via a Sass mixin, so they stay in sync. Now when I reference a method like parse_params in the copy, it looks the same as it does in a code example.