So I Wanted to Put Code in My Blog

I have been thinking about (re)starting a technical blog. I was looking at an old blog and I was reminded of my biggest frustration with blogging: I hate how blogging platforms handle code. As a developer, I want to share code in most of my posts and it always feels awkward. I would often just create GitHub gists to bypass the platform altogether.

These are the things I wanted:

  1. Server-side rendering. I want to use plain ol’ HTML and CSS if I can get away with it (although prismjs seems pretty solid).
  2. Syntax highlighting.
  3. Line numbers. I want to be able to easily refer to places in code
  4. A title. This could either be a file name or some other identifier.
  5. Comments. I want to be able to make asides on the code.
  6. Support for multiple programming languages.
  7. Easy to use.
  8. Selecting the code doesn’t select anything else (like line numbers)

Nothing quite matched my needs, so I decided to write my own. Jekyll just release version 4.0, so it was timely to try Jekyll again and make a plugin for it. I had a Rails helper attempt I presented to my local Ruby users group that I could reference.

I started by creating a _plugins folder in my blog and creating a code.rb file in it. I’m was going to create a Liquid::Block and naming it code seemed straightforward and simple.

I picked the rouge gem because it is great for syntax highlighting, it is pure Ruby, and it is already a requirement for Jekyll. I picked the tubby gem because judofyr (Magnus Holm) had mentioned it on ruby.social and it looked like a great way to build the HTML tags I wanted.

This is what I had so far:

initial stubbing
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# frozen_string_literal: true

require 'rouge'
require 'tubby'

class CodeBlock < Liquid::Block
  def initialize(tag_name, params, tokens)
    super
  end

  def render(context)
    super
  end
end

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

I knew that I wanted to have three parameters that I could pass in: the language, the title, and an aside for the margins. However, liquid blocks only give you a single parameter (everything after the tag name) as a string that you have to parse yourself. I immediately started considering string-to-hash schemes like YAML, JSON, etc., but they all seemed like a lot of noise for something simple. In the end I decided to make it look hash-like grab the keys and values out with a regular expression.

parameter example
1
2
{% code language: "foo", title: "bar", comment: "baz qux" %}

The method to parse out the parameters turned out to be pretty simple:

parsing
1
2
3
4
params.scan(/(.+?):\s"(.+?)",?/).each_with_object({}) do |(key, value), hash|
  hash[key.strip.to_sym] = value
end

That was all that I needed for my initialize method.

initialize
1
2
3
4
5
def initialize(_tag_name, params, _tokens)
  super
  @params = parse_params(params)
end

The render method was a bit more complicated, but not bad.

render
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
def render(_context)
  code = super
  language = @params[:language] || 'markdown'
  title = @params[:title] || ''
  formatter = Rouge::Formatters::HTMLTable.new(
    Rouge::Formatters::HTML.new,
    table_class: 'code',
    gutter_class: 'gutter',
    code_class: 'source ' + language
  )
  lexer = begin
    Rouge::Lexers.const_get(language.capitalize).new
  rescue
    Rouge::Lexers::Markdown.new
  end
  source = formatter.format(lexer.lex(code))

  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
end

As you can see on line 2, getting the code between the blocks is a simple call to super. I needed a default language and markdown seemed safe. The HTMLTable available in rouge turned out to be exactly what I was looking for. The rouge gem has a whole lot of lexers, so rather than delineate them all, I decided to try my luck with a const_get and fall back on markdown.

Tubby is pretty straightforward. Blocks represent tags inside of tags.

This is the final result for my 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
# frozen_string_literal: true

require 'rouge'
require 'tubby'

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

  def render(_context)
    code = super
    language = @params[:language] || 'markdown'
    title = @params[:title] || ''
    formatter = Rouge::Formatters::HTMLTable.new(
      Rouge::Formatters::HTML.new,
      table_class: 'code',
      gutter_class: 'gutter',
      code_class: 'source ' + language
    )
    lexer = begin
      Rouge::Lexers.const_get(language.capitalize).new
    rescue
      Rouge::Lexers::Markdown.new
    end
    source = formatter.format(lexer.lex(code))

    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
  end

  private

  def parse_params(params)
    params.scan(/(.+?):\s"(.+?)",?/).each_with_object({}) do |(key, value), hash|
      hash[key.strip.to_sym] = value
    end
  end
end

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

Thanks for reading!