Logo
Logo

Programming by Stealth

A blog and podcast series by Bart Busschots & Allison Sheridan.

PBS 179 of X: Introducing Liquid Templates (GitHub Pages)

10 May 2025

We ended the previous instalment with a teaser — we’d just hard-coded a list of our site pages on the home page, and realised that surely can’t be the right thing to do!

In this instalment, we learn to do this simple task the right way — using Jekyll’s support for the Liquid templating engine.

Matching Podcast Episode

You can also Download the MP3

Read an unedited, auto-generated transcript with chapter marks: PBS_2025_05_10

Instalment Resources

What’s the Problem to be Solved?

When building sites with a static site generator like Jekyll, you need a mechanism for injecting information into the otherwise static source files using some kind of placeholders. That could be site-wide information like a list of all pages that exist, page-specific information like the date it was published, theme-specific information, or some other form of data being added to the site. In short, static site generators need some kind of templating engine, and rather than inventing their own, the Jekyll developers chose to use an existing open source templating engine, Liquid.

Introducing Liquid Templates

Liquid was developed as part of the very first release of the Shopify e-commerce platform way back in 2006 and has always been shared with the world as an open source project. To this day, Shopify customers still use Liquid to customise their store fronts on the platform!

Something that can get a little confusing is that there are three official variants of Liquid. Thankfully, the relationship between them is very clearly defined, but it does mean that when you search for Liquid, you need to be conscious of the fact that the code you find may not work in the variant you’re using!

The most fundamental variant is the core open source project maintained by Shopify. This contains the language’s core features and its most generic set of standard utilities. The other two variants both extend this common core by adding their own domain-specific utilities. The first variant to extend the core is the one for use on Shopify’s platform, and the second is the one maintained by Jekyll.

For us, that means there are two relevant variants — the core and the Jekyll variant. Here are the links to the official documentation for both:

Something else to bear in mind is that all flavours of Liquid have evolved significantly over the years, so the documentation for both our variants contain annotations to tell you in what version they were introduced. Jekyll is sometimes behind the core, and GitHub Pages uses an older version of Jekyll, so this is especially important for us. Remember that we can use the bundle command within our local copy of our site to check the versions we’re running of both, for example, in May 2025, this is what I see in my copy of the demo site for this series:

$ bundle show | egrep 'jekyll |liquid'
  * jekyll (3.10.0)
  * liquid (4.0.4)

Note the space after jekyll, this is important because if you leave it out, the regular expression matches every sub-package that makes up Jekyll as well as the master package, which is the only one we want to see!)

Finally, when you use Jekyll with GitHub Pages (like we are), there is one final source of extended Liquid functionality to be aware of. The github-metadata Jekyll plugin is included automatically as a dependency of the github-pages gem. The official (very limited) documentation for that is available here. It’s a very simple plugin, though, its only role is to allow you to insert Git URLs, etc., into your site dynamically. That way, you can add links that will always point to the correct Issues URL, etc., even in forked copies.

Liquid’s Architecture

If you zoom out to the conceptual level, Liquid really is just a typical templating engine like Mustache (the one we used for building client-side JavaScript web apps earlier in this series).

Liquid takes as input template strings that contain placeholders and a collection of data. It produces as output new strings with the placeholders replaced with the appropriate data.

Where things differ from how we used Mustache is in the proverbial plumbing. We invoked Mustache by calling a two-argument function to generate our output strings. We passed that function two inputs: a template string with placeholders, and a data structure, and the function returned the rendered string with the placeholders replaced with the appropriate data.

With Jekyll, it’s our source files that act as the template strings, and Jekyll builds the data structures needed as part of the site rendering process. So we don’t explicitly call Liquid — instead, Jekyll uses Liquid to do its work, and we include the Liquid placeholders in our input files. The results of the substitutions then appear in the generated website.

In other words: input files with Liquid tags + Jekyll variables → output website

There are, of course, some subtleties, but the big-picture rule is that HTML and Markdown files in the input folder always get processed with Liquid, and so do other text-based files in the input folder with front matter present. Jekyll’s documentation describes the exceptions to this general rule and how you can add explicit exclusions of your own using your site’s _config.yml file. We’re not going to confuse things in this introduction by diving into these edge cases right now.

Jekyll’s Liquid Variables

Under the hood, the data Jekyll presents to Liquid is actually stored in Ruby variables, but it’s presented to the world as generic data compatible with both JSON and YAML. In other words, the data available for use in Liquid templates consists of arbitrarily nested combinations of simple values, lists, and dictionaries.

The core version of Liquid provides no built-in data structures, while the Shopify and Jekyll variants both provide domain-specific sets of data, which are, of course, completely different in the two variants. This can lead to confusion when reading search results!

All the data Jekyll makes available within Liquid templates is organised into a well-named and well-documented collection of dictionaries. The documentation refers to these dictionaries as variables. Some of these variables are available in all our input files, but some are only available in specific contexts. They are all so sensibly named that you’re unlikely to try to use the wrong variable in the wrong context.

You’ll find a full list of the variables in the Jekyll documentation, but some of the most important ones are:

We’ll learn more about these variables later as we start to use them.

The Basic Liquid Syntax — Objects, Tags, and Filters

Before we dive in, just a reminder that in this series, we always use the technically correct jargon as per the relevant official documentation. We do this even when that jargon is, shall we say, suboptimal. While this can be a little irritating, bear in mind that not doing so would be disempowering, because it would hinder independent research. That would, of course, fly in the face of our primary goal with this series — to empower you to scratch your own digital itches!

With that said, let’s look at the three main components of the Liquid syntax — objects, tags, and filters.

Liquid Objects

Were the Shopify developers to have asked for my opinion, I would definitely not have advised them to name their data placeholders objects, but, alas, they didn’t care about the opinions of a neophyte sysadmin when they made that choice way back in 2006 😉

Anyway, the Liquid documentation refers to data placeholders as Liquid objects. Thankfully, while the name is confusing, the syntax is very straightforward! Simply surround the reference to some data with doubled curly braces (yes, just like Mustache)!

Liquid also uses the same basic JavaScript-like syntax for referencing nested variables — periods (.) to descend into dictionaries, and square braces ([]) to access list elements via their zero-based indexes. So, to insert the site description from the Jekyll site variable, you would use {{ site.description }}, and to get the title of the site’s first page, you would use {{ site.pages[0].title }}.

Liquid Tags

If you want to use Liquid for anything more than simply inserting some data, you need to use Liquid tags. The syntax for these is also very simple — wrap the tag name and the appropriate additional information for the specific tag {% and %}.

Each tag has its own syntax, but to give you a flavour, you can implement conditions like so:

This site {% if site.title %}is titled '{{ site.title }}'{% else %}has no title 🙁{% endif %}

And basic iteration like so:

# Site Pages:
{% for p in site.pages %}
- [{{ p.title }}]({{ p.url }})
{% else %}
*This site has no pages 🙁*
{% endfor %}

When using Jekyll, the tags available for use are a superset of those provided by the core Liquid variant and those provided by the Jekyll variant.

I usually find the appropriate tag to solve my specific problem in the core variant, and in general, the examples in the official docs are all I need to successfully use the tags.

Liquid Filters

Liquid filters are used to transform data when it’s being rendered using Liquid objects (double curly-braced placeholders). Filters take values as inputs and produce new values as outputs. You direct values into filters using the pipe symbol (|), and you can chain multiple filters together by adding more pipes.

Simple filters don’t require arguments, so you invoke them by just placing their name after the pipe symbol, e.g. {{ site.pages | size }} will output the number of pages in the site (pages is a list).

More complex filters expect one or more arguments, and those have to have a : placed after their name, followed by a comma-delimited list of arguments. A very useful example of a filter that takes an argument is default. You use this filter to provide a default value for a variable that may or may not be defined. For example, not all pages have IDs, so you could use the following example to show a page’s ID or a human-friendly message:

**Page ID:** {{ page.id | default: "this page has no ID" }}

Note that with a few exceptions, filters can’t be used inside tags.

One exception is the assign tag, which is used to assign a value to a variable (more on that in a moment).

Another interesting exception is the size filter, which returns the number of items in a list or the number of characters in a string. This filter can be invoked using the dot notation, which is usually reserved for descending into dictionaries. For example, we can use the size of the site.pages list in the condition part of an if tag, like so:


{% if site.pages.size > 10 %}
This is a big site!
{% else %}
This is a small site.
{% endif %}
{%- endraw %}

Like with tags, the filters available to us when working within Jekyll are a superset of those provided by the core Liquid variant, and those provided by the Jekyll variant.

Saving the Output from a Filter

As mentioned previously, you can’t loop over a filtered array (list) in one step because you can’t use a filter within a regular tag. The one exception is the special tag for assigning values to variables, the perfectly named assign tag. This means that we can loop over the results generated by a filter, but we need to do it in two steps — first, save the filter’s output to a new variable, then loop over that new variable.

For example, To loop over a list of all static Javascript files in a site we can use the assign tag to create a new variable named js_files. We do this by filtering the list of all static files in the site (site.static_files) down to just those with a file extension (available via the extname key) with the value js using the where filter.

Note that the where filter takes two arguments — the name of the key whose value should be checked, and a required value for that key. Only items in the input list where the value of the given key matches the given value will be returned by the filter.

{% raw -%}
# Site JavaScript Files

{%- assign js_files = site.static_files | where: "extname", ".js" %}
{%- for file in js_files %}
- `{{ file.path }}`
{%- endfor %}
{%- endraw %}

Note: for just a few more paragraphs, please ignore the - symbols at the start of the tags in this example — they part of Liquid’s white space handling features which are described later in the instalment.

If you add this section to index.md in our demo site, you’ll see it only has one JavaScript file ATM, /assets/js/bootstrap.bundle.min.js.

Liquid Data Types

As mentioned previously, Liquid variables only support the data types supported by JSON and YAML, so it really is just the basics:

Type Description
Booleans The two Boolean literals are simply true & false.
Numbers Numbers are simply entered as regular numbers without any kind of quotations, e.g. 42 & 3.1417.
Strings Strings can be quoted with single or double quotes, e.g. 'A string' & "Another String".
Lists (Arrays) The Docs refer to lists as arrays. Indexes are zero-based and specified inside square braces. Negative indexes are supported, with -1 being the index of the last element in an array. E.g. myArray[0], myArray[42] & myArray[-1].
Dictionaries These are not listed as a separate data type in the official docs. They appear to be considered simply nested variable names by the developers. Each level of nesting is separated with a period symbol (.), e.g. myDictionary.someKey.

Liquid also supports some speical literals, specifically nil to represent the absence of any value at all (what Javascript represents with null), and empty to represent a list or string which exists but has no content.

A Note on Truthiness

In Liquid, just about everything evaluates to true when coerced to a Boolean — the only things that coerce to false are false itself and nil. Everything else, including the number zero, empty strings, empty lists (arrays), and empty dictionaries, all coerce to true. This can really catch developers who usually code in more traditional languages like JavaScript by surprise!

Operators in Liquid (For Comparisons Only!)

This might come as a surprise, but Liquid does not support any arithmetic operators! The only operators Liquid does support are comparison operators!

That doesn’t mean arithmetic and string manipulations are impossible; you just use filters rather than operators.

Anyway, these are the operators you can use when making comparisons in Liquid:

Operator Description
== Is equal to, e.g. myString == "some string" or myNumber == 42.
!= Is not equal to, e.g. myString != "some string" or myNumber != 42.
< Is less than, e.g. myNumber < 42.
> Is greater than, e.g. myNumber > 42.
<= Is less than or equal to, e.g. myNumber <= 42.
>= Is greater than or equal to, e.g. myNumber >= 42.
and Logical and, e.g. myNumber > 0 and myNumber <= 12.
or Logical or, e.g. myString == "ok" or myString == "yes".
contains Substring containment check, e.g. myString contains "something". Only works on strings and lists (arrays) of strings.

For complex comparisons, round brackets (()) can be used to group sub-expressions.

How Liquid Handles White Space

To stop Liquid objects and tags from affecting the layout of the rendered text in unwanted ways, you can specify that all white space should be stripped before or after any object or tag by adding a minus symbol (-) directly inside any opening or closing delimiter.

If we assume the value of the variable dessert is WAFFLES, then the following input text:

I like {{ dessert }} very much 🙂
I like {{- dessert }} very much 🙂
I like {{ dessert -}} very much 🙂
I like {{- dessert -}} very much 🙂

Is rendered as:

I like WAFFLES very much 🙂
I likeWAFFLES very much 🙂
I like WAFFLESvery much 🙂
I likeWAFFLESvery much 🙂

Worked Example — Making Our List of Pages Dynamic

As promised, our example real-world task is to replace the hard-coded list of pages on our demo site’s home page with a dynamically generated list that will update itself automatically as new pages are created.

To do this, we need to make use of the site variable, which is a dictionary. That dictionary contains a list named pages, which contains dictionaries representing all the files that will appear in the output site that have been transformed by Jekyll (rather than just being passed through unchanged). You might expect this to be exactly what we need, but it actually contains too much information. Why? Because our style sheet is built by Jekyll from the original SASS code, so it appears in the site.pages list too. Don’t worry, though, there is a more tightly constrained list named site.html_pages, which contains only the items that get rendered to HTML by Jekyll. This is the list we’ll use.

Even this list actually has one more superfluous item — the home page itself! After all, we don’t need a link to the home page on the list of pages rendered on the home page!

This need to filter out the home page provides the perfect opportunity to use one of the most powerful additional filters provided by Jekyll — where_exp. The built-in where filter can only filter by checking a specific key against a specific value, but the where_exp filter lets us filter by any valid Liquid condition.

If you’d like to play along, our starting point will be our demo site as we left it at the end of the previous instalment. You can download a snapshot of the site at the appropriate commit here, or you can clone the repo and checkout the commit with the tag pbs179-startingPoint.

Once you have your starting point, you actually only need to make one change to achieve our goal! Simply replace the following lines in index.md

## Site Pages

* [About](about)
* [Links](links)

With these lines:

{% raw -%}
## Site Pages

{%- assign list_pages = site.html_pages | where_exp: "item", "item.title != 'Home'" %}
{%- for p in list_pages %}
- [{{ p.title }}]({{ p.url | relative_url }})
{%- else %}
- *No pages yet*
{%- endfor %}

(For convenience, you’ll find an updated version of the complete index.md file in the instalment ZIP.)

That’s not many lines of code, but there is a lot going on there, so let’s focus in on some key points.

Firstly, note the use of the white space control minus symbols to stop the generated markdown filling with blank lines.

Next, I used an assign tag to store a filtered copy of dictionaries representing the input files that will become HTML pages in the rendered site. The full list of those dictionaries is available as site.html_pages) and we pass these to the where_exp filter to remove the dictionary representing the home page.

Let’s have a closer look at this filter’s syntax. Like the more basic where filter from the core variant, the where_exp filter also takes two arguments. But, but instead of specifying a key name and an exact required value, you provide a name of your choosing, and string containing a Liquid condition.

Think of the first argument as being like the i in a traditional C/Javascript for loop — we could use any name, but to make our code easier for us an others to read later, we stick to the commonly adopted convention of naming it i. In this case the convention is to use the name item as the first argument.

So, how does where_exp work? The input must be a list (an array). The filter applies the condition passed as the second argument to each item in the input list, using the name provided in the first argument. The output is a new list (array) containing only the items where the condition evaluated to true.

So, in our example, each dictionary representing a file in our site that will be rendered to HTML gets named item, and only those for which item.title != 'Home' evaluates to true appear in the outputted list. In other words, what comes out of the where_exp filter is a list of dictionaries representing all our markdown files except index.md which has the title Home. This list is then saved to a new variable named list_pages by the assign tag.

We now have a list of just the pages we want to appear in on our home page, so we use a for tag to loop over it. Within the for tag’s body we use two Liquid objects (placeholders) to inject each page’s title and URL into (from the title and url keys in the dictionaries representing each file) into the markdown syntax for a bulleted list item containing a link.

Actually, there is one more nuance that needs to be pointed out — we don’t actually insert the raw URL, we pass it through another filter — relative_url. This is another filter provided by the Jekyll variant rather than the core variant, and it’s an extremely important one!

When building a site with Jekyll, the internal links need to work regardless of the base URL the generated site will be published at. You can’t simply assume the file part of the final URL will start at the domain’s root level (/). That assumption does hold true when you’re testing locally because the base URL is http://127.0.0.1:4000/, but it’s not true when you publish the site to the internet with GitHub Pages (unless you use a custom domain) — the default base URL for a GitHub Pages site is https://USERNAME.github.io/REPONAME/, e.g. https://bartificer.github.io/pbs-jekyll-demoSite/.

What this all means is that if you don’t filter all relative URLs through relative_url your site will work just fine when testing locally but it will break when published to GitHub Pages!

Final Thoughts

Believe it or not, we’ve actually introduced the basics of almost every important Liquid feature. Almost, but one very substantial feature remains to be explored — how Jekyll uses nested Liquid templates to define the layouts that define a site’s theme. That’s going to be our focus next time!

Join the Community

Find us in the PBS channel on the Podfeet Slack.

Podfeet Slack