Jekyll's Invisible Bug: When Code Fences Don't Protect Your Liquid Examples
· 9 min readJekyll’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:
- A variable is nil — build crashes with
undefined methoderrors - A tag is unbalanced —
Liquid syntax error: 'if' tag was never closed - A reader reports that your code examples are blank or show wrong values
- 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>{% raw %}</code> and <code>{% endraw %}</code>
The % 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:
- Liquid template engine — evaluates all
{{ }}and{% %}tags - Markdown processor (Kramdown) — converts Markdown to HTML
- 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 {{ }} and {% %} 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:
- Jekyll configuration —
{{ site.* }},{{ page.* }}variables - Liquid templates —
{% if %},{% for %},{% assign %}tags - GitHub Actions workflows —
${{ secrets.* }},${{ env.* }},${{ steps.* }} - Jinja2 templates — same
{{ }}syntax as Liquid - Ansible playbooks —
{{ variable }}syntax - Mustache/Handlebars —
{{ }}and{{{ }}}syntax - Vue.js templates —
{{ }}interpolation syntax
Lessons Learned
- Code fences are not a security boundary for Liquid. Never assume content inside backticks is safe from template processing.
- 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.
- 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.
- Keep the detection script. Run it as part of your pre-commit or CI workflow to catch new instances before they ship.
Related Posts
- How the Sausage Is Made: Every Feature Powering This Jekyll Blog — Complete feature reference including Liquid escaping
- Mermaid Diagram Rendering Challenges — Another case where Jekyll’s rendering pipeline causes surprises
References
- Jekyll Liquid Processing — Official docs on how Liquid interacts with content
- Liquid raw Tag — Shopify’s Liquid documentation
- Jekyll Rendering Order — The pipeline that explains why Liquid runs before Markdown