This blog post is part of my Advanced Jekyll series. My last post I wrote about Building APIs with Jekyll which I highly recommend reading when you get a chance.

Having built quite a few static sites over the past few years with Jekyll, I can tell you about how nice it is to work with. I love it. However, there’s one thing that I bet you also find bizzare about Jekyll. Or rather specifically the Liquid templating language that’s baked in; creating or manipulating objects.

Using fruits and Jekyll Blocks, we’ll walk through how we can make life easier when handling objects in Jekyll!

Creating Objects

If I told you to create an object containing fruits in English and Swedish in Jekyll, this is one way you might do it:

---
layout: default
title: Hi world
fruits:
  en:
    - Orange
    - Apple
    - Grape
    - Pineapple
    - Banana
  se:
    - Orange
    - Äpple
    - Druva
    - Ananas
    - Banan
---

We have fruits! Hey, you could even use Jekyll Datafiles if you wanted.

If we were trying to do that with Liquid, we would struggle a little bit - there is no current (and unlikely future) support for creating objects. Though, for manipulate our page.fruits object inside of Liquid, both Liquid and Jekyll have plenty of useful filters for objects that we can use:

{{ page.fruits | order | jsonify }}

// this would return ↓
{"en":["Orange","Apple","Grape","Pineapple","Banana"],"se":["Orange","Äpple","Druva","Ananas","Banan"]}

Wait, why do I care about objects in Liquid?

Good question. In many cases, sticking your data in the YAML Front-Matter or in the _data/ folder will suffice; this is more for advanced use cases. Especially when you have lots of data and want to keep your components clean.

I’ll use fruits to explain.

Let’s say we wanted to create a component that displays to a user what a fruit is called in English and Swedish. We’d start off with an _includes/fruit.html component accepting name_english and name_swedish parameters:

// index.md
{% assign fruit_names = page.fruits.en %}
{% for name_english in fruit_names %}
  {% assign index = forloop.index0 %}
  {% assign name_swedish = page.fruits.se[index] %}
  {% include fruit.html name_english=name_english name_swedish=name_swedish %}
{% endfor %}

// _includes/fruit.html
<div class="fruit">
  <p>How do you pronounce {{ include.name_english }} in Swedish?</p>
  <ul>
    <li>{{ include.name_swedish }}</li>
  </ul>
</div>

That’s pretty convoluted. It’d start to be painful if we added, say, 10 more languages.

The other options are:

  • Send the whole page.fruits to _includes/fruit.html. This isn’t great for maintainability as components should be as dumb as possible, mostly to avoid unexpected side effects.
  • Write a specific Jekyll Plugin that will accept an input, the particular languages, and return it. This is also fine but would be very case-specific.

Both are good viable options, but I think this is an opportunity to look at Liquid::Block and what it can do for us and our fruits.

Hello, Liquid::Block!

You’ve probably never heard of it before. It’s not mentioned in the Jekyll plugin docs which is strange given how extensive it already is. Nonetheless, it’s exactly what you think - the API for writing custom blocks in Jekyll.

The most popular example of a block is the {% capture %} block:

// index.md
{% capture header %}
<h1>Welcome! Want to learn fruits in new languages?</h1>
{% endcapture %}

...

{{ header }}

It’s pretty great. And here’s an example of how to create the above command in Ruby:

# _plugins/blocks/capture_block.rb
module Jekyll::Plugin::Block
  class CaptureBlock < Liquid::Block
    Syntax = /(#{Liquid::VariableSignature}+)/o

    def initialize(tag_name, markup, options)
      super
      if markup =~ Syntax
        @to = $1
      else
        raise SyntaxError.new("Variable name is invalid")
      end
    end

    def render(context)
      output = super # the contents of our block
      context.scopes.last[@to] = output # write to variable
      context.resource_limits.assign_score += output.length
      ''.freeze
    end
  end
end

Liquid::Template.register_tag('capture'.freeze, Jekyll::Plugin::Block::CaptureBlock)

And that’s it. Jekyll will autoload all files inside of _plugins/ because they love us like that. To summarize, we can create a block and have it parse any kind of information we want - how could we use that to benefit from objects? Sounds like JSON to me!

How could we use a JSON block?

Let’s go back to the fruits example. Let’s say we just wanted to get all of the languages we support with a potential JSON block.

Here’s an option I have:

{% json languages %}
  [
    {% for fruit in page.fruits %}
      {% assign lang = fruit[0] %}
      {{ lang | jsonify }}{% unless forloop.last %},{% endunless %}
    {% endfor %}
  ]
{% endjson %}

{{ languages | jsonify }}

// this would return ↓
["en","se"]

Now let’s take {{ languages }} and use that to generate our small fruit payloads:

{% assign english_fruit_names = page.fruits.en %}
{% json fruits %}
[
  {% for english_fruit_name in english_fruit_names %}
  {% assign index = forloop.index0 %}
  [
    {% for language in languages %}
      {
        "lang": {{ language | jsonify }},
        "name": {{ page.fruits[language][index] | jsonify }}
      }
      {% unless forloop.last %},{% endunless %}
    {% endfor %}
  ]
  {% unless forloop.last %},{% endunless %}
  {% endfor %}
]
{% endjson %}

This should return an array with tiny objects:

[
  [
    {"lang": "en", "name": "Orange"},
    {"lang": "se", "name": "Orange"}
  ],
  [
    {"lang": "en", "name": "Apple"},
    {"lang": "se", "name": "Äpple"}
  ],
  [
    {"lang": "en", "name": "Grape"},
    {"lang": "se", "name": "Druva"}
  ],
  [
    {"lang": "en", "name": "Pineapple"},
    {"lang": "se", "name": "Ananas"}
  ],
  [
    {"lang": "en", "name": "Banana"},
    {"lang": "se", "name": "Banan"}
  ]
]

Now it’s easy to send that to our _includes/fruit.html for consumption:

// index.md
{% for fruit in fruits %}
  {% include fruit.html fruit=fruit %}
{% endfor %}

// _includes/fruit.html
<div class="fruit">
  <p>How do you pronounce {{ include.fruit | filter: 'lang', 'en' | name }} in Swedish?</p>
  <ul>
    {% for entry in include.fruit %}
      <li>{{ entry.lang }}: {{ include.name }}</li>
    {% endfor %}
  </ul>
</div>

Looks good. Only one problem — we haven’t written our Liquid::Block plugin yet!

Implementing our JSON as a Liquid::Block

Given the code above, we just need to add a JSON.parse around our block contents. We don’t need to install any JSON-related gems as the json gem is loaded inside of the core Jekyll build. Let’s take a look at what our block could look like:

# _plugins/blocks/json_block.rb

require "json"

module Jekyll::Plugin::Block
  class JsonBlock < Liquid::Block
    Syntax = /(#{Liquid::VariableSignature}+)/o

    def initialize(tag_name, markup, options)
      super
      if markup =~ Syntax
        @to = $1
      else
        raise SyntaxError.new("There is an error with your JSON block")
      end
    end

    def render(context)
      output = JSON.parse(super.strip)
      context.scopes.last[@to] = output
      context.resource_limits.assign_score += output.length
      ''.freeze
    end
  end
end

Liquid::Template.register_tag('json'.freeze, Jekyll::Plugin::Block::JsonBlock)

That’s it! Be sure to restart your Jekyll server so it’s working all good. Due to a lack of support for custom plugins, this will not work if you're hosting your site with GitHub Pages.

Possible Optimizations

There’s a little bit of polish we could do. Particularly around the pointless {% unless forloop.last %},{% endunless %} block, so here’s a workaround I have found:

  • Replace ],} with ]}, ignoring spaces in between.
  • And, replace ],] with ]], ignoring spaces in between.
  • And, replace },} with }}, ignoring spaces in between.
  • And, replace },] with }], ignoring spaces in between.

What would that look like in code? Something like this:

ARRAY_CLOSE_HASH_CLOSE_REGEX = /\][ \t\n]*,[ \t\n]*\}/
ARRAY_CLOSE_ARRAY_CLOSE_REGEX = /\][ \t\n]*,[ \t\n]*\]/
HASH_CLOSE_HASH_CLOSE_REGEX = /\}[ \t\n]*,[ \t\n]*\}/
HASH_CLOSE_ARRAY_CLOSE_REGEX = /\}[ \t\n]*,[ \t\n]*\]/

output = output.gsub(ARRAY_CLOSE_HASH_CLOSE_REGEX, "]}")
               .gsub(ARRAY_CLOSE_ARRAY_CLOSE_REGEX, "]]")
               .gsub(HASH_CLOSE_HASH_CLOSE_REGEX, "}}")
               .gsub(HASH_CLOSE_ARRAY_CLOSE_REGEX, "}]")

After adding this logic, this is what our _plugins/block/json_block.rb should look like:

require "json"

module Jekyll::Plugin::Block
  class JsonBlock < Liquid::Block
    Syntax = /(#{Liquid::VariableSignature}+)/o

    def initialize(tag_name, markup, options)
      super
      if markup =~ Syntax
        @to = $1
      else
        raise SyntaxError.new("There is an error with your JSON block")
      end
    end

    ARRAY_CLOSE_HASH_CLOSE_REGEX = /\][ \t\n]*,[ \t\n]*\}/
    ARRAY_CLOSE_ARRAY_CLOSE_REGEX = /\][ \t\n]*,[ \t\n]*\]/
    HASH_CLOSE_HASH_CLOSE_REGEX = /\}[ \t\n]*,[ \t\n]*\}/
    HASH_CLOSE_ARRAY_CLOSE_REGEX = /\}[ \t\n]*,[ \t\n]*\]/

    def render(context)
      output = super.strip
      output = output.gsub(ARRAY_CLOSE_HASH_CLOSE_REGEX, "]}")
                     .gsub(ARRAY_CLOSE_ARRAY_CLOSE_REGEX, "]]")
                     .gsub(HASH_CLOSE_HASH_CLOSE_REGEX, "}}")
                     .gsub(HASH_CLOSE_ARRAY_CLOSE_REGEX, "}]")
      context.scopes.last[@to] = JSON.parse(output)
      context.resource_limits.assign_score += output.length
      ''.freeze
    end
  end
end

Liquid::Template.register_tag('json'.freeze, Jekyll::Plugin::Block::JsonBlock)

And when re-running our {% json fruits %} blocks earlier, we can now omit the {% unless forloop.last %} logic:

{% assign english_fruit_names = page.fruits.en %}
{% json fruits %}
[
  {% for english_fruit_name in english_fruit_names %}
  {% assign index = forloop.index0 %}
  [
    {% for language in languages %}
      {
        "lang": {{ language | jsonify }},
        "name": {{ page.fruits[language][index] | jsonify }}
      },
    {% endfor %}
  ],
  {% endfor %}
]
{% endjson %}

Great! Now we can send slightly invalid JSON and our block can handle it. This means we can focus more on the important things rather than having valid JSON. Note a trailing comma in a list is actually valid JSON syntax in JavaScript, but not Ruby.

Final Notes

There’s a lot of power that using objects in Jekyll has. I believe this allows us to use objects inside of our codebase to help us be more expressive, and use Jekyll in new and exciting ways. And I’m all for that!