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.
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 scopeThese 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 %}
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#/).
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
Standard Jekyll tags work: {% if %}/{% elsif %}/{% unless %},
{% for %}, {% assign %}, {% capture %}, {% include %},
{% highlight %}, {% raw %}.
{% for %} modifierslimit, 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 -->
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.
{% 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 |
Popular Jekyll gem plugins are built in — nothing to install:
{% toc %}
Renders a nested table of contents from the page's h2–h4 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:
h2–h4. Override anywhere in the cascade — later wins:toc: {min_level: 2, max_level: 3} in _config.yml → layouttoc_depth: 2 once in _layouts/post.html and everytoc_min, toc_max, ortoc_depth = levels below min). Filter arguments{{ content | toc: 2, 3 }}) bypass the cascade.| toc filter reads ids but can't add them; if your headings are rawid= attributes, use the {% toc %} tag form instead.{% 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
{% 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.
{{ "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'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).
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:
== true, != false) are stripped — the filter{% if include.url | startswith: 'http' %}
assign:
{% if x | filter: 'arg' == "value" %}
↓ becomes
{% assign __gohyde_cmp_0 = x | filter: 'arg' %}{% if __gohyde_cmp_0 == "value" %}
{% 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.
Add your own via a plugin — see Writing Plugins. Plugin filter/tag
names are case-sensitive; built-ins win on a name collision.