Vein's markup uses a tag-and-output syntax that is comfortable if you have authored storefront templates before. This page lists exactly what the engine supports — output, control-flow tags, filters, and loops — sourced from the engine so it is accurate. Anything not listed here is not supported and fails at review, never silently at render.
Output#
Print a value with {{ }}. Read instance settings with settings.
{{ settings.heading }}
{{ theme.settings.brand_color }}
{{ shop.name }}
{{ product.images[0].url }}
{{ product.tags[-1] }}
Tags#
Tag |
Purpose |
|---|---|
{% if %} / {% elsif %} / {% else %} / {% endif %} |
Conditional rendering. |
{% unless %} / {% endunless %} |
Render when a condition is falsy. |
{% case %} / {% when %} / {% else %} / {% endcase %} |
Switch on one value; a when may list several values (comma or or separated). |
{% for x in list %} … {% else %} … {% endfor %} |
Loop; the else branch renders when the list is empty. |
{% break %} / {% continue %} |
Stop the innermost loop / skip to its next item. Outside a loop they are an error. |
{% cycle 'a', 'b' %} |
Rotate through values on successive renders (zebra striping). |
{% assign name = value %} |
Bind a local variable (block-scoped). |
{% capture name %} … {% endcapture %} |
Render the body into a string local. |
{% render 'name' %} / {% include 'name' %} |
Render a child section (see Rendering children). |
{% form "kind" %} … {% endform %} |
A hosted-action form: emits the |
{% comment %} … {% endcomment %} |
A comment; its body is not rendered. |
Conditions#
Conditions use these operators: == != > < >= <= , combined with and / or, plus contains for substring/membership. == and != are loose (a number and its string form compare equal); the ordering operators < > <= >= require operands of the same kind (number-vs-number or string-vs-string) — comparing a number with a string is an error, not a silent result. The literals true, false, nil (null), and empty (blank) are recognised.
{% if settings.show_button and settings.button_label != empty %}
<a href="{{ settings.button_url | default: '/' }}">{{ settings.button_label }}</a>
{% elsif product.available_for_sale %}
<span>In stock</span>
{% else %}
<span>Sold out</span>
{% endif %}
{% unless b.disabled == true %}
...render the block...
{% endunless %}
Loops#
Iterate a list with {% for %}. Modifiers limit: N, offset: N, and reversed are supported. Inside the loop, forloop exposes: index (1-based), index0 (0-based), rindex (from the end, 1-based), rindex0 (from the end, 0-based), first, last, and length.
{% for b in settings.blocks %}
{% unless b.disabled == true %}
{% if b.type == 'slide' %}
<figure>{% if b.settings.image %}<img src="{{ b.settings.image }}">{% endif %}</figure>
{% endif %}
{% endunless %}
{% else %}
<p>No slides yet.</p>
{% endfor %}
{% for tag in product.tags limit: 3 %}
<span class="tag">{{ tag }}</span>{% unless forloop.last %}, {% endunless %}
{% endfor %}
Filters#
Transform a value in the output with the pipe. Chain filters left to right. The full built-in set:
Category |
Filters |
|---|---|
Default / fallback |
default (falls back on nil, false, or empty — empty string / empty array — keeps 0) |
Text case |
upcase, downcase, capitalize |
Text edit |
append, prepend, replace, replace_first, remove, remove_first, truncate, truncatewords, strip, lstrip, rstrip, strip_newlines, newline_to_br |
Escaping / output |
escape, escape_once, strip_html, json, url_encode, url_decode |
Arrays |
split, join, size, first, last, map, where, sort, sort_natural, uniq, concat, compact, reverse, sum, slice |
Numbers |
plus, minus, times, divided_by, modulo, abs, ceil, floor, round, at_least, at_most (round/ceil/floor take an OPTIONAL precision; divided_by/modulo by zero is an error) |
Dates |
date (strftime directives: %Y %m %d %e %B %b %H %M %S %p %A %a %y %j %s %Z %z; input must be a date — an unparseable value is an error, never a silently wrong date) |
{{ settings.title | default: shop.name | upcase }}
{{ product.description | strip_html | truncatewords: 30 }}
{{ product.tags | join: ', ' }}
{% assign colors = "red,green,blue" | split: "," %}
{{ colors | first }} … {{ colors | last }}
{% assign images = product.media | where: "media_type", "image" %}
{{ images | map: "url" | join: "\n" }}
{{ article.published_at | date: "%B %e, %Y" }}
{{ product.title | url_encode }}
Beyond these built-ins, the platform provides host filters — money, t, and placeholder_svg — documented on the Host Filters page. They format platform data (prices, translations, placeholder artwork); a theme cannot define its own filters.
Not supported#
Vein is a deliberate, focused language. It has no tablerow or pagination tag, no increment/decrement (mutable global counters), and no multi-statement {% liquid %} block. There is no raw filter and no {% raw %} block — output is always escaped. A theme cannot define its own filters; beyond the built-ins above, only platform-provided host filters exist, and only where they are documented (money, t, placeholder_svg — see Host Filters). If you need logic beyond what is listed, reshape the data your section reads (via ctx_needs) rather than computing it in the template — the template renders, it does not compute.
Variable access#
Access is strict at the root. The first segment of a path must be either a local (from assign or a for loop) or a context namespace your section declared in ctx_needs. Reading an undeclared namespace is a hard error at render — it tells you to add it to ctx_needs. A missing sub-field, by contrast, is simply nil (not an error), so deep paths degrade gracefully once the root is allowed.
{{ product.title }} {# ok if "product" is in ctx_needs #}
{{ widget.title }} {# ERROR: "widget" not declared in ctx_needs #}
{{ product.missing.deep }} {# nil — missing sub-fields are not an error #}
Truthiness#
Only nil, false, the empty/blank marker, and ABSENT platform data are falsy. Everything present is truthy — including 0, an empty string "", and an empty-but-present array. Absent platform data means a context object that simply is not there (a product with no price, a line with no media): {% if product.price %} correctly skips when the price is missing. The distinction that matters: an EMPTY array that exists is truthy; a missing one is falsy — so for "has items", test {% if x.items.size > 0 %}, which is safe in both cases.
Rendering children#
Render another section with {% render 'name' %} (include parses identically — there is no separate shared-scope include). The target name must be a string literal; a dynamic target like {% render settings.x %} is a hard error. The child renders in its OWN scope: named arguments become the child's locals (not its settings), and the child reads only its OWN declared ctx_needs — context is not inherited from the caller. Global theme settings (theme.settings.
{% render 'price-tag', amount: product.price.amount, currency: shop.currency %}
{# inside price-tag: {{ amount }} and {{ currency }} are locals; #}
{# price-tag must declare its own ctx_needs to read shop/product. #}
The {% form %} tag#
Hosted action forms are emitted by the {% form %} tag: it renders the
{% form "cart_add", class: "buy-box" %}
<input type="hidden" name="product_id" value="{{ product.id }}">
<input type="hidden" name="variant_id" value="{{ product.selected_variant.id }}">
<button type="submit">Add to cart</button>
{% endform %}
Action kinds: address_create, address_delete, address_set_default, address_update, auth_code_send, cart_add, cart_clear, cart_notify_me, cart_remove, cart_update, comment_submit, consent_give, consent_withdraw, contact_submit, discount_code_apply, discount_code_remove, localization_set, login, logout, newsletter_subscribe, profile_update, register, review_media_upload, review_submit, review_vote, wishlist_add, wishlist_clear, wishlist_remove, wishlist_toggle. Optional attributes (class: "...", id: "...") are attribute-escaped. Fields per action: see Hosted Actions.
and / or return a value#
and and or return one of their operands, not a plain true/false: a or b is a when a is truthy, otherwise b; a and b is b when a is truthy, otherwise a. Precedence is the usual one — and binds tighter than or, so a or b and c means a or (b and c). Note: this is about truthiness, and an empty string is TRUTHY in Vein, so or does NOT fall back on an empty setting. For "use this value, or a fallback when it is empty", use the default filter, not or.
{# fallback when a setting is empty → use default, NOT or (empty string is truthy): #}
{% assign label = settings.label | default: 'Shop now' %}
{{ label }}
{# or returns the first truthy operand — useful for nil/false, not for "" : #}
{% assign mode = settings.mode or 'light' %}
Looping a map#
Looping a map yields [key, value] pairs (keys sorted for stable output): use item[0] for the key and item[1] for the value. Looping an array yields its elements directly. A missing collection's .size is 0, so {% if X.items.size > 0 %} is safe even when X.items is absent.
{% for pair in settings.attributes %}
{{ pair[0] }}: {{ pair[1] }}
{% endfor %}