View Source Liquid Compatibility
Alembic implements a Liquid-compatible subset of the template language. This document lists what's supported, where Alembic intentionally deviates from upstream Liquid, what's entirely out of scope for this MVP, and what Alembic adds beyond Liquid.
Note on methodology: this session had no network access to fetch and port
Shopify's actual github.com/Shopify/liquid test-suite fixtures. Coverage
of the same categories the official suite exercises (variables, if/for,
assign, filters, whitespace control, comments, raw blocks) is instead
hand-written in test/integration/liquid_compat_test.exs.
Supported Liquid features
- Output tags —
{{ user.name }}, dot-path and bracket (user["name"]) variable access, both interchangeable. The base may also be a literal ({{ 42 }},{{ "hi" }}) or a filter chain over a literal/variable ({{ "hi" | upcase }}); comparison and logical bases ({{ x > 1 }}) are still rejected. Filters — full pipe chain syntax (
{{ x | a | b: 1, 2 }}); seeAlembic.Filtersfor the complete catalog (string, array, number, misc).sliceslices both strings and arrays (positive/negative start, optional length). For arrays, an out-of-range start returns[]; for strings it returns""(viaString.slice/3).- Control flow —
{% if %}/{% elsif %}/{% else %}/{% endif %},{% for %}/{% else %}/{% endfor %}with fullforloopmetadata (index,index0,rindex,rindex0,first,last,length). - Unless —
{% unless expr %}/{% else %}/{% endunless %}, desugared to a negated{% if %}.{% elsif %}inside an unless is a parse error, matching Liquid. - Case/when —
{% case subject %}with{% when a, b, c %}(multiple values per when) and an optional{% else %}, terminated by{% endcase %}. Matching uses the same==semantics as{% if %}. - Capture —
{% capture x %}...{% endcapture %}renders its body into a flattened string stored inx, with the same visibility as{% assign %}. - Loop control —
{% break %}and{% continue %}inside a{% for %}body; both are parse errors anywhere else.breakin a nested loop exits only the innermost loop. - Cycle —
{% cycle "a", "b" %}round-robins its values on each render;{% cycle "rows": "a", "b" %}shares state across same-named groups. - Range iterables —
{% for i in (1..5) %}with integer literal or variable endpoints. Descending ranges iterate zero times and trigger{% else %}, matching Liquid rather than Elixir's descending ranges. - Operators —
==,!=,>,<,>=,<=,contains,and,or,not, withnot > and > orprecedence (seedocs/grammar.md§5.4). empty/blankkeywords —x == empty,x != blank(and reversed operand order), including{% when empty %}.emptymatches"",[], and%{}(nilis not empty);blankalso matchesnil,false, and whitespace-only strings. Equality-only: other operators raise{:keyword_requires_equality, _, _}.- Assignment —
{% assign var = expr %}, visible to every node evaluated after it, including across{% for %}/{% if %}boundaries. - Whitespace control —
{{-,-}},{%-,-%}in any combination. - Comments —
{% comment %} ... {% endcomment %}, content fully discarded, including anything that looks like Liquid syntax inside it. - Raw blocks —
{% raw %} ... {% endraw %}, content preserved verbatim as plain text. - Template inheritance —
{% extends %}/{% block %}/{% endblock %}, multi-level chains,{{ block.super }}, circular- and max-depth detection. - Includes —
{% include "partial.html" %}and{% include "partial.html" with key: val, key2: val2 %}, sharing the including template's scope (classicincludesemantics, not an isolatedrender). - Render —
{% render "card.html" %}and{% render "card.html", title: post.title %}render a partial in an isolated scope: only the explicitly passed variables are visible, parent scopes/assigns never leak in, and anything the partial assigns does not leak out.loader_fn,strict, andcustom_filterscarry over from the parent. Thefor ... asvariant is not supported. - Liquid truthiness — only
nilandfalseare falsy;0,"", and[]are all truthy, matching Liquid (not Elixir's own truthiness rules).
Intentional deviations
| Area | Alembic | Upstream Liquid | Why |
|---|---|---|---|
join with no argument | separator "" | separator " " | Issue 1.4.3's own task list specified "" explicitly |
url_encode / url_decode | URI.encode_www_form/1 (space → +) | same (+ for space) | Matches Liquid; URI.encode/1 (percent-encodes space as %20) would not have |
ceil / floor | return integers | return integers | Matches Liquid; Elixir's own Float.ceil/1 returns a float, so this required an explicit trunc/1 |
date filter | Elixir Calendar.strftime/2 format strings | Ruby strftime format strings | No Ruby-compatible formatter available without a dependency; the two format-string dialects are similar but not identical |
| Cache hit/miss telemetry | Logger.debug/1 | n/a | :telemetry is a separate Hex package; issue 1.1.1's zero-runtime-deps policy (ex_doc only) rules it out |
Unsupported features (out of MVP scope)
{% tablerow %}tag{% paginate %}tag (and the wider Shopify pagination object)- Liquid's built-in request/shop/theme objects (
request,shop,theme, etc.) — Alembic has no notion of these; all context comes from theassignsmap passed torender/3 {% increment %},{% decrement %}tags- Liquid's
{% liquid %}shorthand block syntax - Multi-argument
{% assign %}expressions beyond a single filter chain
Extension features (Alembic adds beyond Liquid)
base64_encode/base64_decodefiltersAlembic.Filterbehaviour for registering custom filters, globally viaconfig :alembic, custom_filters: [MyApp.Filters.Money]or per call viarender/3'scustom_filters:option (the latter takes precedence on a name collision)strict: truerender option — errors on any undefined variable instead of silently rendering"", useful for catching typos during development- ETS-backed compiled-template cache with mtime-based invalidation,
sweep/0for pruning stale entries, and acache: falseper-call opt to bypass it - Path-traversal protection built into the file loader (
{:path_traversal_detected, _})