Introduction

After using Jekyll for a number of years, I’ve had to discover or invent a number of Liquid “recipes” (cocktails?) for doing common tasks that should be easy in a sane language, but which are not obvious how to do in Liquid.

Since these (anti-?)patterns are under-documented elsewhere, I thought I’d collect them here in case this list is useful for others, especially those who are starting to use Jekyll / Liquid for serious development.

Format

Each entry below will show off some Liquid code and what that code outputs:

{% assign hello = "world" %}
{{ hello }}

world

It assumes you’re already familliar with the fundamentals of writing Liquid in Jekyll.

The Recipes: How to…

Comment out some broken code

The comment tag in Liquid unfortunately will parse and process the inner content block 😮‍💨

In order to temporarily comment out broken Liquid code, you need to double wrap it first in raw and then in comment to ensure Liquid doesn’t try to parse the code inside the comment:

{% comment %}{% raw %} {% broked {% endraw %}{% endcomment %}

Just beware of any {% endraw %}s inside the comment!

Make a literal string list

You cannot directly instantiate a list in liquid. To make a list of literal strings, you’ll have to use “split”:

{% assign mylist = "hello,world,you're,looking,great!" | split: "," %}
{{ mylist[0] }}

hello

Filter a list to elements containing a substring

Don’t be discouraged! contains works as you’d expect inside a where_exp:

{% assign mylist = "hello,world,you're,looking,great!" | split: "," %}
{% assign filtered = mylist | where_exp: "item", "item contains 'e'" %}
{{ filtered[2] }}

great!

Filter a list to elements containing a variable substring

The above is all well and good, but what if the substring you’re looking for is contained in a variable?

In that case, use {% capture %} to build the filter expression dynamically!1

{% assign mylist = "hello,world,you're,looking,great!" | split: "," %}
{% assign lookingfor = "r" %}
{% capture filterexp %}item contains '{{ lookingfor }}'{% endcapture %}
{% assign filtered = mylist | where_exp: "item", filterexp %}
{{ filtered[0] }}

world

Write test cases using capture

Yes, the capture tag is super powerful!

One of my favorite uses is to write test cases! You can simply capture the output of an include and then make sure it contains the expected string:

My test:
{% capture output %}{% include my_include.md value=5 %}{% endcapture %}
{% if output contains 'expected string' %}Passes!{% else %}Fails!{% endif %}

My test: Passes!

Using Liquid to generate non-html files

I usually will make a null layout file for this case:

_layouts/nil.html:

{{ content }}

Then, in whatever file you want Jekyll to process, just add the null layout:

create-dynamic-bash-file.html:

---
layout: nil
permalink: my-script.bash
---
# prints all the page paths to stdout
{% for page in site.pages %}
echo "{{ page.path }}"
{% endfor %}

Note that the template ends in .html so that Jekyll knows not to markdownify it. It will be renamed to my-script.bash in the output directory. This will always work, even to generate a markdown file, and it makes it clear that the source file is not (yet) a real e.g. bash script.

Strip your includes

When you call {% include something.md %}, newlines will be automatically added—even if they aren’t in the original file! Since this breaks certain markdown patterns, you may want to have a stripped include.

You could could capture the output, then call {{ captured_output | strip }} but this is cumbersome and ugly for the caller. To make the include self-stripping, just make sure to put a whitespace-erasing {%- -%}s at the ends of the include:

_includes/inline_bold.md:

{%- comment -%}This include can safely be used in an inline manner{%- endcomment -%}
**{{ include.text }}**
{%- comment -%}Just make sure the include ends in a %- comment -% like this one!{%- endcomment -%}

_somewhere/else.md:

- my
- {% include inline_bold.md text="amazing" %}
- list
  • my
  • amazing
  • list

Make a map

Sometimes you need a more advanced data structure than just a list.

To implement a Map, I usually use two parallel lists of keys and values.

{% assign mykeys = "foo,bar" | split: "," %}
{% assign myvalues = "baz,bat" | split: "," %}
{% assign mykeys = mykeys | push: "new key" %}
{% assign myvalues = myvalues | push: "new value" %}
{{ mykeys[2] }} => {{ myvalues[2] }}

new key => new value

See the next recipe for how to access my_map[some_key]

Return a value from an include

Sometimes you find yourself doing some messy calculation in multiple places, and (being a good programmer) you want to DRY out your code. But Liquid _includes don’t have return statements… so, can you actually write an _include which just performs a calculation?

Of course!

Your first thought might be to use {% capture returnvalue %}{% include my_func.liquid param="foo" %}{% endcapture %} and to just output your return value inside the include. And that will work! But that’s limited to returning strings, and usually it’ll come with lots of added whitespace.

To return arbitrary variables, leverage the fact that your local variables aren’t actually local!!

_includes/ArrayDict_lookup.liquid:

{% assign value = nil %}
{% for k in include.keys %}
  {% if k == include.key %}
    {% assign value = include.values[forloop.index0] %}
    {% break %}
  {% endif %}
{% endfor %}

_somewhere/else.markdown:

{% assign mykeys = "me,you,us,them" | split: "," %}
{% assign myvalues = "Alice,Bob,World,Mars" | split: "," %}
{% include ArrayDict_lookup.liquid values=myvalues keys=mykeys key="us" %}
Hello {{ value }}

Hello World

Note how in the above, value was set in the _include but then used by the calling template! This means (among other things) that you should never {% assign content = in an _include as this will nuke the parent page’s content!

Recurse in an include

You might think that local variables always leaking their scope would make recursive includes impossible. It certainly makes them tricky!

While general, “local” variables are a no-go in a recursive include, you can use:

  • include.parameter variables (as the include objects are indeed put on a stack)
  • for loop variables (as the loop tracks its own state)
  • Temporary variables (who are assigned and used on the same side of a recursive call)

For example, this _include takes a list of lists and outputs them as nested markdown:

_includes/recursive_lists.md:

{% for item in include.list -%}
{%- if item[0] %}{{ include.prefix }}Sublist:
{% assign p = '  ' | append: include.prefix %}{% include recursive_lists.md list=item prefix=p %}{% else %}{{ include.prefix }}{{ item }}
{% endif %}{%- endfor -%}

_somewhere/else.md:

{% assign list = 'Z,Y,X' | split: ',' %}
{% assign list = '1,2,3' | split: ',' | push: list | push: '5' %}
{% assign list = 'a,b,c' | split: ',' | push: list | push: 'e' %}
{% include recursive_lists.md list=list prefix="- " %}

Outputs:

- a
- b
- c
- Sublist:
  - 1
  - 2
  - 3
  - Sublist:
    - Z
    - Y
    - X
  - 5
- e

Check your understanding: What do you think would happen if that {%- endfor -%} at the end of the above _include was instead {% endfor %}?

Conclusion

Liquid can be a frustrating language at times, but you can usually convince it to do what you want. And if you can’t, you can always write a _plugin!

Footnotes:

  1. Yes, this is basically a hidden eval function. With great power…