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:
- Server-side rendering. I want to use plain ol’ HTML and CSS if I can get away with it (although prismjs seems pretty solid).
- Syntax highlighting.
- Line numbers. I want to be able to easily refer to places in code
- A title. This could either be a file name or some other identifier.
- Comments. I want to be able to make asides on the code.
- Support for multiple programming languages.
- Easy to use.
- 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:
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.
1 2 | {% code language: "foo", title: "bar", comment: "baz qux" %} |
The method to parse out the parameters turned out to be pretty simple:
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.
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.
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.
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!