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.

Environment

{% if gohyde.environment == "development" %}
  <!-- dev-only helper, e.g. a live-reload banner or debug panel -->
{% endif %}

gohyde.environment — real Jekyll’s jekyll.environment equivalent. Reads the GOHYDE_ENV env var, defaulting to "development" when unset. Like real Jekyll, gohyde serve and gohyde build do not set this automatically — both default to "development" either way, so set it explicitly when you want "production":

GOHYDE_ENV=production gohyde build

Available in page/layout templates, .liquid assets, and Sass entry points.

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

date implements Ruby’s full strftime — every directive and flag (%-d, %^b, %I:%M %p, %z, %F, %j, …), and text around the directives is left alone. Inputs can be a date, a Unix timestamp, or a string in the common shapes Ruby’s Time.parse reads (2026-06-30, 2026-06-30 14:07:09 +0200, June 30, 2026, …); anything else comes back unchanged.

{{ page.date | date: "%Y-%m-%d" }}                 → 2026-06-30
{{ page.date | date: "%-d %b %Y" }}                → 30 Jun 2026
{{ page.date | date: "%I:%M %p" }}                 → 02:07 PM
{{ page.date | date_to_string: "ordinal", "US" }}  → Jun 30th, 2026
{{ page.date | date_to_long_string: "ordinal" }}   → 30th June 2026

Dates render in the site’s timezone: (see Configuration).

Unknown filters

As in Jekyll, a filter that doesn’t exist passes its input through unchanged ({{ x | nosuchfilter }} renders x). Set liquid: {strict_filters: true} to make it a build error instead.

Tags

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

{% link %} and {% post_url %} prepend baseurl, and fail the build when the target doesn’t exist. {% link %} takes the path relative to the site source, collection dir included ({% link _posts/2024-01-05-x.md %}), and may contain Liquid ({% link {{ page.target }} %}).

{% highlight %}

Like Jekyll’s, the block’s body is rendered as Liquid first — wrap literal Liquid in {% raw %}. Output is Rouge’s shape, with Chroma supplying the token classes (Pygments/Rouge names, so Rouge CSS themes apply):

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby">…</code></pre></figure>

linenos adds Rouge’s rouge-table gutter. mark_lines="2 3" (wraps lines in <span class="hll">) and linenostart=N are gohyde extensions; Jekyll 4.3’s Rouge path ignores both.

{% 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 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:

{% 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.