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:
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:
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:
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.
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:
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:
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:
- Removed the
tubbydependency—one less gem to worry about - Used
Rouge::Lexer.findfor better language lookup - Extracted
highlight,title_html, andaside_htmlmethods for readability - Empty elements no longer render
- Simplified
parse_paramsusingLiquid::TagAttributesand.to_h - 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:
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:
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:
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.