Liquid & Jekyll Compatibility @ stwoo.net

Gohyde wraps osteele/liquid and layers
Jekyll's filters, tags, and include semantics on top. Most Jekyll templates run
unchanged. Where the underlying parser is stricter than Jekyll's Ruby Liquid,
Gohyde pre-processes the template to accept the lenient syntax.

Filters

Jekyll's filter set is implemented in internal/liquid/engine.go.
Common ones:

{{ "Hello World" | slugify }}              → hello-world
{{ page.date | date: "%B %-d, %Y" }}       → June 30, 2026
{{ post.content | strip_html | truncate: 160 }}
{{ site.posts | where: "category", "news" | first }}
{{ "a,b,c" | split: "," | join: " / " }}   → a / b / c
{{ items | sort: "weight" | reverse }}
{{ page.tags | array_to_sentence_string }}

split on "" returns [] (Ruby semantics, which Jekyll relies on) —
"" | split: "," | size is 0, so the common
{% for %}...{% else %} "is this empty" idiom works as expected.

where_exp / find_exp / group_by_exp see the full scope

These take a raw expression string, and — unlike a plain block-variable-only
evaluator — it sees everything already in scope: the loop variable, page,
site, and any {% assign %}ed variable:

{% assign target = "Alice" %}
{{ members | where_exp: "m", "m.name != target" }}
{{ items | where_exp: "i", "i.url != page.url" }}   {% comment %} exclude current page {% endcomment %}

Arithmetic: Ruby semantics

plus · minus · times · divided_by · modulo follow Ruby's rules, like
Jekyll: integer op integer → integer (divided_by floors toward −∞, modulo
takes the divisor's sign); any float operand switches to float math.

{{ 7 | divided_by: 2 }}      → 3       (integer division)
{{ 7 | divided_by: 2.0 }}    → 3.5     (float path)
{{ 108 | divided_by: 200.0 | ceil }}   → 1   (round-up division)

Integer division/modulo by zero fails the build — matching Ruby's
ZeroDivisionError, which Jekyll inherits ({{ 5 | divided_by: 0 }} is a
build error, not +Inf). Float division/modulo by zero don't error
({{ 5.0 | divided_by: 0 }}+Inf, same as Ruby's Float#/).

Dates: Ruby strftime → Go layout

date accepts Ruby strftime directives, including the no-leading-zero forms
Jekyll supports:

{{ page.date | date: "%Y-%m-%d" }}     → 2026-06-30
{{ page.date | date: "%-d %b %Y" }}    → 30 Jun 2026   (%-d = day, no zero pad)
{{ page.date | date: "%-m/%-d" }}      → 6/30

Tags

Standard Jekyll tags work: {% if %}/{% elsif %}/{% unless %},
{% for %}, {% assign %}, {% capture %}, {% include %},
{% highlight %}, {% raw %}.

{% for %} modifiers

limit, offset, and reversed combine in Shopify/Jekyll order — offset
and limit slice the original collection, and reversed only flips the
iteration order of that result:

{% for item in "apple,banana,cherry" | split: "," limit:2 offset:1 reversed %}
  {{ item }}
{% endfor %}
<!-- offset 1 → [banana, cherry] → limit 2 → [banana, cherry] → reversed → cherry, banana -->

Multiline tags

Tags and outputs may span lines — handy for includes with many parameters:

{% include card.html
   image=hero
   title="Featured"
   overlay=true %}

{{ site.posts
   | where: "category", "news"
   | first }}

Newlines inside the delimiters are collapsed before parsing; error line
numbers for the rest of the file are preserved.

Includes

{% include %} and {% include_relative %} are real Liquid tags, so they see
runtime state — values from {% assign %}, filter pipelines, dotted paths:

{% assign hero = page.images | first %}
{% include card.html image=hero title="Featured" overlay=true %}

Inside card.html, parameters arrive on the include object:

<div class="card">
  <img src="{{ include.image }}" alt="{{ include.title }}">
  {% if include.overlay %}<span class="overlay"></span>{% endif %}
</div>

Parameter value semantics match Jekyll:

Syntax Meaning
key="text" / key='text' string literal
key=variable resolved against the current context
key=true / key=false / key=nil the literal boolean/nil

Built-in "plugin" tags

Popular Jekyll gem plugins are built in — nothing to install:

{% toc %}

Renders a nested table of contents from the page's h2h4 headings
(<nav class="toc"> with anchor links). Works anywhere in a post body. In
layouts, use the filter form on the rendered content:

<aside>{{ content | toc }}</aside>

How it works: the tag drops a placeholder during the Liquid pass and the
builder swaps it for the generated TOC after Markdown rendering, when the
headings exist as HTML. Markdown headings get their anchor ids from the
renderer; hand-written <h2> HTML headings without ids get slugified ids
injected automatically by the tag. Notes:

{% seo %}

Emits a jekyll-seo-tag-style meta block in <head>: <title>, description,
canonical URL, Open Graph tags, and twitter:card. Pulls from site.title,
site.description, site.url + site.baseurl, page.title,
page.description/page.excerpt, and page.cover/page.image (→ og:image).
Posts get og:type: article.

{% youtube dQw4w9WgXcQ %}
{% youtube "https://youtu.be/dQw4w9WgXcQ" %}
{% youtube page.video %}

Responsive, privacy-friendly (youtube-nocookie.com) video embed. Accepts a
bare ID, any YouTube URL form, or a variable.

{{ content | reading_time }}        → "3 min read"
{{ content | reading_time: 180 }}   → custom words-per-minute

Core Liquid tags osteele's port omits

{% increment %}, {% decrement %}, and {% ifchanged %} are part of
standard Liquid (not Jekyll-specific) but missing from osteele/liquid
entirely — Gohyde adds them:

{% increment my_counter %}   → 0
{% increment my_counter %}   → 1
{% decrement my_counter %}   → -1

increment/decrement share one counter namespace per variable name,
independent of regular {% assign %} variables. increment outputs the
current value then increments (starts at 0). decrement decrements then
outputs (starts at 0, so the first call is -1).

{% for i in "1,1,2,2,3" | split: "," %}{% ifchanged %}{{ i }}{% endifchanged %}{% endfor %}
→ 123

{% ifchanged %} renders its block only when the output differs from the
last time it rendered — handy for suppressing repeated consecutive values
inside a loop.

Filters that were missing entirely

{{ "one,two,three" | split: "," | array_to_sentence_string }}  → "one, two, and three"
{{ "foo bar foo" | replace_last: "foo", "baz" }}                → "foo bar baz"
{{ "hello world" | remove_last: "o" }}                           → "hello wrld"

array_to_sentence_string matches Jekyll's exact output, including the
Oxford comma before the final item (3+ items only; 2 items = "a and b",
no comma).

Jekyll-lenient syntax (the compat preprocessor)

Jekyll's Ruby Liquid tolerates expressions that osteele/liquid rejects with
syntax error in "...". Rather than forcing you to rewrite templates ported
from a Jekyll site, Gohyde rewrites them before parsing
(internal/liquid/compat.go).

Filter result compared in a condition

Jekyll lets you pipe a filter and compare its result inside an if:

{% if include.url | startswith: 'http' == true %}
  <a href="{{ include.url }}">external</a>
{% endif %}

osteele/liquid chokes on 'http' == true as a filter argument. Gohyde rewrites
this automatically:

{% elsif %} can't be preceded by an assign, so a non-trivial filter-compare
in an elsif is left as-is (rewrite the template manually in that one case).

The upshot: lenient Jekyll templates generally render without edits. If you
hit a syntax error in "...", reproduce it with the smallest possible template
and check whether the compat preprocessor should handle it.

Custom filters and tags

Add your own via a plugin — see Writing Plugins. Plugin filter/tag
names are case-sensitive; built-ins win on a name collision.