The Template Language

Vein's markup: output, tags, filters, and loops.

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. (always available), a block's own values with b.settings., and global theme settings with theme.settings. (also always available — no ctx_needs needed). Other context data (shop, product, menus, …) must be declared in the section's ctx_needs before you can read it. Access nested fields with a dot, and array/map items with brackets: items[0], options[key]. Negative array indexes count from the end: items[-1].

vein
{{ 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

element, action path, and CSRF token (see The {% form %} tag).

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

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

vein
{% 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)

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

vein
{{ 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.) remain readable in the child, exactly as in the parent.

vein
{% 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 element with the correct action path, the session CSRF token as a hidden input, and multipart encoding when the action carries files. You write only the inputs that matter. The action kind must be a literal — an unknown kind fails at compile/review, never on a storefront. Sections using {% form %} receive csrf_token automatically; you do not declare it.

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

vein
{# 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.

vein
{% for pair in settings.attributes %}
  {{ pair[0] }}: {{ pair[1] }}
{% endfor %}