McGarrah Technical Blog

Jekyll's Invisible Bug: When Code Fences Don't Protect Your Liquid Examples

· 9 min read

Jekyll’s rendering pipeline has a design decision that bites anyone who writes about template systems: Liquid processes every tag in your Markdown files before the Markdown processor sees them. Code fences, backtick spans, indented blocks — none of them protect Liquid syntax from execution. If you maintain a Jekyll-based documentation platform and your content includes template examples, this is a content integrity problem that silently corrupts output or crashes builds.

I discovered this when a blog post about reading time calculation crashed my build. The error pointed to a draft file, but the code it complained about was inside a Markdown code fence — supposedly safe, display-only text. It wasn’t.

Jekyll’s Liquid template engine processes every {{ }} and {% %} tag in your Markdown files before the Markdown processor ever sees them. Code fences, backtick spans, indented code blocks — none of them protect Liquid syntax from execution. If you write posts about Jekyll, Liquid, GitHub Actions, or anything else that uses double-curly-brace syntax, your examples are being silently eaten or actively breaking your build.

The Problem

Consider a blog post explaining how Jekyll’s reading time calculation works. You’d naturally include the implementation:

```liquid
{%- assign words_per_minute = 200 -%}
{%- assign number_of_words = include.post.content | number_of_words -%}
```

This looks safe. It’s inside a fenced code block. Every other language treats code fences as literal text.

Jekyll doesn’t. The Liquid engine runs first, sees the {% %} tags, and tries to execute them. In this case, include.post.content is nil (there’s no include context), so number_of_words gets nil input and the build crashes:

Liquid Exception: undefined method `split' for nil:NilClass

Why It Sneaks Up on You

The insidious part is that most unprotected Liquid doesn’t crash — it silently renders as empty text. A code example like:

```html
<meta property="og:url" content="{{ site.url }}{{ page.url }}">
```

Won’t crash because site.url and page.url are valid variables. Jekyll evaluates them, substitutes the values, and your “code example” now shows your actual URL instead of the template syntax. The Markdown renders, the page looks fine at a glance, and you don’t notice that your readers see https://mcgarrah.org/some-post/ where they should see {{ site.url }}{{ page.url }}.

You only discover the problem when:

  1. A variable is nil — build crashes with undefined method errors
  2. A tag is unbalancedLiquid syntax error: 'if' tag was never closed
  3. A reader reports that your code examples are blank or show wrong values
  4. You view source and notice the Liquid output instead of Liquid syntax

What Triggers Build Failures vs Silent Corruption

Pattern Result
{{ site.url }} Silent — renders as your actual URL
{{ page.title }} Silent — renders as the post’s title
{{ include.post.content }} Crash — nil when not inside an include
{% if paginator.previous_page %} with matching {% endif %} Silent — evaluates the condition, renders nothing
{% if condition %} without {% endif %} Crash — unclosed tag error
${{ secrets.GITHUB_TOKEN }} Silent — renders as empty string
{% seo %} Crash or unexpected output — executes the SEO plugin

The Fix: raw / endraw Tags

The {% raw %} and {% endraw %} tags tell Liquid to pass content through without processing. Wrap your code examples:

```liquid
{%- assign words_per_minute = 200 -%}
{%- assign number_of_words = include.post.content | number_of_words -%}
```

Place {% raw %} immediately after the opening code fence and {% endraw %} just before the closing fence.

Inline Code Too

Backtick inline code is equally unprotected. This in your Markdown:

The `{% seo %}` tag generates meta tags.

Needs to become:

The {% raw %}`{% seo %}`{% endraw %} tag generates meta tags.

The Nested raw/endraw Problem

You can’t show literal {% raw %} or {% endraw %} text inside a {% raw %} block — Liquid sees the inner {% endraw %} and terminates the block early. For posts that need to display the raw/endraw tags themselves (like this one), use HTML character entities:

<code>{&#37; raw %}</code> and <code>{&#37; endraw %}</code>

The &#37; entity renders as %, producing {% raw %} visually while avoiding Liquid parsing.

GitHub Actions Expressions

GitHub Actions uses ${{ }} syntax which Liquid also intercepts. Any workflow YAML in a code block needs the same treatment:

```yaml
{% raw %}
- name: Build
  run: bundle exec jekyll build --baseurl "${{ steps.pages.outputs.base_path }}"
{% endraw %}
```

Finding Every Affected File

After discovering this problem in one draft, I found it in a dozen more files. Here’s a script that scans your entire site for unprotected Liquid tags outside of raw blocks:

#!/usr/bin/env python3
"""Scan Jekyll posts/drafts for unprotected Liquid tags."""
import re, glob

LEGIT_TAGS = [
    '{% highlight', '{% endhighlight',
    '{% include', '{% post_url',
    '{% comment', '{% endcomment',
]

for f in sorted(glob.glob('_posts/*.md') + glob.glob('_drafts/*.md')):
    content = open(f).read()
    # Strip raw blocks
    cleaned = re.sub(
        r'\{%-?\s*raw\s*-?%\}.*?\{%-?\s*endraw\s*-?%\}',
        '', content, flags=re.DOTALL
    )
    # Strip front matter
    cleaned = re.sub(r'^---.*?---', '', cleaned, count=1, flags=re.DOTALL)

    found = []
    for i, line in enumerate(cleaned.split('\n'), 1):
        if '{%' in line or '{{' in line:
            s = line.strip()
            if any(x in s for x in LEGIT_TAGS):
                continue
            found.append(f'  L{i}: {s[:120]}')

    if found:
        print(f'\n=== {f} ===')
        print('\n'.join(found))

Run it from your Jekyll project root:

python3 find-unprotected-liquid.py

The script strips raw/endraw blocks and front matter first, then reports any remaining Liquid syntax. It skips legitimate Jekyll tags like {% highlight %} and {% include %} that are meant to execute.

Why This Happens

Jekyll’s rendering pipeline processes files in this order:

  1. Liquid template engine — evaluates all {{ }} and {% %} tags
  2. Markdown processor (Kramdown) — converts Markdown to HTML
  3. Layout rendering — wraps content in layout templates

Here’s the pipeline visualized — notice that Liquid runs before Markdown has any chance to identify code fences:

flowchart TD
    A["📄 Markdown Source File"] --> B["1️⃣ Liquid Template Engine"]
    B --> |"Evaluates ALL &#123;&#123; &#125;&#125; and &#123;% %&#125; tags"| C{"Tag inside<br/>raw/endraw?"}
    C --> |Yes| D["Pass through as literal text"]
    C --> |No| E{"Valid variable<br/>or tag?"}
    E --> |"Yes — e.g. site.url"| F["🔇 Silent substitution<br/>renders actual value"]
    E --> |"No — e.g. nil variable"| G["💥 Build crash<br/>undefined method error"]
    E --> |"Unbalanced tag"| H["💥 Build crash<br/>Liquid syntax error"]
    D --> I["2️⃣ Kramdown Markdown Processor"]
    F --> I
    I --> |"NOW identifies code fences<br/>but Liquid already ran"| J["HTML Output"]
    J --> K["3️⃣ Layout Rendering"]
    K --> L["📄 Final Page"]

    style B fill:#e74c3c,color:#fff
    style I fill:#3498db,color:#fff
    style K fill:#2ecc71,color:#fff
    style F fill:#f39c12,color:#fff
    style G fill:#c0392b,color:#fff
    style H fill:#c0392b,color:#fff
    style D fill:#27ae60,color:#fff

The key insight: Liquid has no concept of Markdown code fences. By the time Kramdown identifies your triple-backtick block, Liquid has already consumed or evaluated everything inside it.

This is documented in Jekyll’s Liquid processing docs, but it’s easy to miss because every other context where you write code (GitHub READMEs, Stack Overflow, documentation sites) treats code fences as sacred.

Posts That Commonly Need This

If you write about any of these topics, check your code examples:

Lessons Learned

  1. Code fences are not a security boundary for Liquid. Never assume content inside backticks is safe from template processing.
  2. Silent failures are worse than crashes. The posts that render “successfully” with wrong content are harder to catch than the ones that blow up the build.
  3. Scan proactively. After fixing one post, scan everything. The same pattern tends to appear in clusters — if you wrote one post about Jekyll internals, you probably wrote several.
  4. Keep the detection script. Run it as part of your pre-commit or CI workflow to catch new instances before they ship.

References


About the Author: Michael McGarrah is a Cloud Architect with 25+ years in enterprise infrastructure, machine learning, and system administration. He holds an M.S. in Computer Science (AI/ML) from Georgia Tech and a B.S. in Computer Science from NC State University, and is currently pursuing an Executive MBA at UNC Wilmington. LinkedIn · GitHub · ORCID · Google Scholar · Resume