Liquid

← All sections · part of the machine-readable /all/ index.

People

Liquid

Liquid

Liquid is the template language Pagelove uses to render data-driven markup inside a page. Attach a template to any element with pagelove:template="text/liquid"; the server renders the element's subtree against the page's bindings and the request context, then replaces the element with the output.

Pages

See also

Templating

Templating

The pagelove:template attribute enables server-side Liquid rendering inside an HTML element. The template engine processes the element's subtree, replacing it with rendered output.

When to reach for it

Use templating to render dynamic content from bound data — lists, conditionals, formatted output. Templates operate over site-graph resources and the active HTTP request. They do not mutate documents or perform I/O.

Enabling templating

Attach pagelove:template to any element. The attribute value is the template engine MIME type.

<section pagelove:template="text/liquid">
    ...
</section>
Engine MIME type
Liquid text/liquid

Only the subtree rooted at the annotated element is processed. The rest of the document is unchanged.

Data sources

Templates access four categories of data:

Source Description
Resource bindings Site-wide CSS selector queries
Expression bindings Computed values using Sessel
request object The active HTTP request
Template-local variables Variables created with assign, capture, etc.

Template scope

Template execution is scoped to the annotated element, side-effect free, and deterministic.

Templates cannot:

Examples

Listing with resource binding

<ul pagelove:template="text/liquid"
    resource:users="[id][itemtype='http://example.com/TeamMember']">
    {% assign users = users | sort: 'fullname' %}
    {%- for user in users -%}
    <li>
        <a href="{{ user['@id'] }}">{{ user.fullname }}</a> ({{ user.email }})
    </li>
    {%- endfor -%}
</ul>

Processing steps:

  1. The selector evaluates across the site graph.
  2. Matching elements are materialized as resource objects.
  3. The Liquid engine renders the subtree.
  4. Rendered HTML replaces the original template subtree.

Request object

<section class="debug" pagelove:template="text/liquid">
<pre>{{ request | json: 2 }}</pre>
</section>

Includes and templates together

<pagelove:include selector="#site header"></pagelove:include>
<section pagelove:template="text/liquid">
    ...
</section>
<pagelove:include selector="#site footer"></pagelove:include>

<pagelove:include> handles structural composition (server-side DOM inclusion). pagelove:template handles data-driven rendering. They are often combined.

Worked example: listing people

Store two documents that describe people using HTML Microdata, then a listing page that uses a Liquid template with a resource binding to query all Person items:

When the listing page is requested, the template evaluates and the bound data renders:

GET /sspi-tpl-listing.html
HTTP/1.1 200

<!DOCTYPE html>
<html>
<body>
  <h1>People</h1>
  <ul>
    
    <li>Anna</li>
    
    <li>Ben</li>
    
  </ul>
</body>
</html>

The resource binding queried every Person across the site. The Liquid template rendered the list. All SSPI namespaces and binding attributes have been stripped from the output.

See also

Template Filters

Template Filters

Pagelove's Liquid engine provides the standard Shopify Liquid filters, a set of Jekyll-compatible filters, and Pagelove-specific extensions for hashing, random generation, date arithmetic, data conversion, and querying arrays. Every filter is available inside any element with pagelove:template="text/liquid".

Filters are documented by category. Each page describes its filters with an example for every one; filters marked extension are Pagelove or Jekyll/LiquidJS additions beyond the standard Shopify Liquid set.

Filters by category

String — text manipulation: case, trimming, escaping, replacing, slugs

append · array_to_sentence_string · capitalize · cgi_escape · default · downcase · escape · escape_once · lstrip · newline_to_br · normalize_whitespace · number_of_words · prepend · remove · remove_first · remove_last · replace · replace_first · replace_last · rstrip · size · slice · slugify · split · strip · strip_html · strip_newlines · truncate · truncatewords · upcase · uri_escape · url_decode · url_encode · xml_escape

Number — arithmetic and rounding

abs · at_least · at_most · ceil · divided_by · floor · minus · modulo · plus · round · times · to_integer

Array — ordering, mapping, and querying lists

compact · concat · find · find_index · first · group_by · has · join · last · map · pop · push · reject · reverse · shift · sort · sort_natural · sum · uniq · unshift · where

Date and time — formatting and arithmetic on dates

date · date_add · date_to_long_string · date_to_rfc822 · date_to_string · date_to_xmlschema · unix_to_iso

Hashing and security — cryptographic digests and password hashes

argon2 · bcrypt · sha256

Random generation — random strings and passphrases

diceware · random

Data and JSON — serialise values

inspect · json · jsonify

Expression-variant array — query arrays with a full expression

find_exp · find_index_exp · group_by_exp · has_exp · reject_exp · where_exp

When a filter errors

If a filter raises an error on a particular value — for example, date given an unparseable string, or a hashing filter given an out-of-range parameter — only that one {{ … }} output expression is affected. The expression renders an inline error marker (an https://pagelove.org/Error Microdata element) and the rest of the page composes normally; a single bad value never blanks the whole page. In an attribute value (e.g. href="{{ … }}"), where markup can't go, the expression renders empty instead.

<!-- One bad value degrades locally; the rest of the page is unaffected -->
<p>Published {{ article.date | date: "%B %d, %Y" }}</p>
<p>{{ article.title }}</p>

This applies to value-output expressions. An error in a control-flow tag's expression (the condition of an {% if %}/{% unless %}, or a {% for %} range) is not degraded — there is no safe partial meaning for a failed condition or loop, so it surfaces as a composition error.

See also

String filters

String filters

Text-manipulation filters. Most are standard Shopify Liquid; those marked extension are Jekyll/LiquidJS-compatible additions. Every filter coerces its input to a string first.

Case

downcase

Lowercases the string.

{{ "Hello, World" | downcase }}   <!-- hello, world -->

upcase

Uppercases the string.

{{ "Hello, World" | upcase }}   <!-- HELLO, WORLD -->

capitalize

Uppercases the first character and lowercases the rest.

{{ "hello WORLD" | capitalize }}   <!-- Hello world -->

Trimming and whitespace

strip

Trims whitespace from both ends.

{{ "  hi  " | strip }}   <!-- hi -->

lstrip

Trims whitespace from the start.

{{ "  hi  " | lstrip }}   <!-- "hi  " -->

rstrip

Trims whitespace from the end.

{{ "  hi  " | rstrip }}   <!-- "  hi" -->

strip_newlines

Removes every \n and \r.

{{ "a\nb\nc" | strip_newlines }}   <!-- abc -->

newline_to_br

Inserts an HTML <br /> before each newline.

{{ "a\nb" | newline_to_br }}   <!-- a<br />\nb -->

normalize_whitespace

(extension) Collapses every run of whitespace to a single space and trims.

{{ "a   b\n c" | normalize_whitespace }}   <!-- a b c -->

Escaping

escape

HTML-escapes < > & " '. The result is marked safe, so it is not escaped again.

{{ "<a href='x'>" | escape }}   <!-- &lt;a href=&#39;x&#39;&gt; -->

escape_once

Like escape, but leaves existing entities intact (does not double-escape).

{{ "1 &lt; 2 &amp; 3" | escape_once }}   <!-- 1 &lt; 2 &amp; 3 -->

url_encode

Form-URL-encodes the string (space → +).

{{ "a b&c" | url_encode }}   <!-- a+b%26c -->

url_decode

Reverses url_encode.

{{ "a+b%26c" | url_decode }}   <!-- a b&c -->

cgi_escape

(extension) Produces an application/x-www-form-urlencoded value (space → +). Jekyll/LiquidJS-compatible.

{{ "a b & c" | cgi_escape }}   <!-- a+b+%26+c -->

uri_escape

(extension) Escapes for use in a URI while preserving reserved characters (;/?:@&=+$,); space → %20. Jekyll/LiquidJS-compatible.

{{ "http://x/a b?q=1&r=2" | uri_escape }}   <!-- http://x/a%20b?q=1&r=2 -->

xml_escape

(extension) Escapes & < > " ' to their XML/HTML entities. Jekyll/LiquidJS-compatible.

{{ "<a>'x'</a>" | xml_escape }}   <!-- &lt;a&gt;&#39;x&#39;&lt;/a&gt; -->

strip_html

Removes HTML tags (and the contents of <script>/<style>).

{{ "<b>hi</b><script>x()</script>" | strip_html }}   <!-- hi -->

Replacing and removing

replace

Replaces every occurrence of a substring.

{{ "a-b-c" | replace: "-", "+" }}   <!-- a+b+c -->

replace_first

Replaces the first occurrence.

{{ "a-b-c" | replace_first: "-", "+" }}   <!-- a+b-c -->

replace_last

(extension) Replaces the last occurrence. LiquidJS-compatible.

{{ "a-b-c" | replace_last: "-", "+" }}   <!-- a-b+c -->

remove

Removes every occurrence of a substring.

{{ "a-b-c" | remove: "-" }}   <!-- abc -->

remove_first

Removes the first occurrence.

{{ "a-b-c" | remove_first: "-" }}   <!-- ab-c -->

remove_last

(extension) Removes the last occurrence. LiquidJS-compatible.

{{ "a-b-c" | remove_last: "-" }}   <!-- a-bc -->

Adding and joining

append

Appends a string to the end.

{{ "/page" | append: ".html" }}   <!-- /page.html -->

prepend

Prepends a string to the start.

{{ "world" | prepend: "hello " }}   <!-- hello world -->

array_to_sentence_string

(extension) Joins an array into a sentence with an Oxford comma. The optional argument overrides the "and" connector. Jekyll/LiquidJS-compatible.

{{ tags | array_to_sentence_string }}         <!-- a, b, and c -->
{{ tags | array_to_sentence_string: "or" }}   <!-- a, b, or c -->

Slicing and splitting

slice

Extracts a substring: an offset (negative counts from the end) and an optional length (default 1). Also works on arrays.

{{ "hello" | slice: 1, 3 }}   <!-- ell -->
{{ "hello" | slice: -1 }}      <!-- o -->

split

Splits a string into an array on a separator (an empty separator splits per character).

{{ "a,b,c" | split: "," | join: " · " }}   <!-- a · b · c -->

truncate

Truncates to N characters (including the suffix, default ...).

{{ "The quick brown fox" | truncate: 9 }}   <!-- The qu... -->

truncatewords

Keeps the first N words, then the suffix (default ...).

{{ "The quick brown fox" | truncatewords: 2 }}   <!-- The quick... -->

Inspection

size

The length of a string (characters), array (elements), or object (keys); otherwise 0.

{{ "hello" | size }}   <!-- 5 -->

number_of_words

(extension) Counts whitespace-separated words (returns an integer). Jekyll/LiquidJS-compatible.

{{ "the quick brown fox" | number_of_words }}   <!-- 4 -->

default

Returns the argument when the input is blank, empty, or false.

{{ user.nickname | default: "Anonymous" }}

slugify

(extension) Lowercases, collapses each run of non-alphanumeric characters to a single -, and trims leading/trailing -. Jekyll/LiquidJS-compatible.

{{ "Hello, World!" | slugify }}   <!-- hello-world -->

See also

Number filters

Number filters

Arithmetic and rounding filters. All are standard Shopify Liquid except to_integer. Numeric inputs preserve integer type when both operands are integers, otherwise the result is a float.

Arithmetic

plus

Adds the argument.

{{ 10 | plus: 5 }}   <!-- 15 -->

minus

Subtracts the argument.

{{ 10 | minus: 3 }}   <!-- 7 -->

times

Multiplies by the argument.

{{ 6 | times: 7 }}   <!-- 42 -->

divided_by

Divides by the argument. When both operands are integers this is integer (floor) division; a float operand produces a float. Division by zero returns the input unchanged.

{{ 7 | divided_by: 2 }}       <!-- 3   (integer division) -->
{{ 7 | divided_by: 2.0 }}     <!-- 3.5 -->

modulo

The remainder of division. Modulo by zero returns the input unchanged.

{{ 13 | modulo: 5 }}   <!-- 3 -->

Rounding

abs

Absolute value.

{{ -8 | abs }}   <!-- 8 -->

ceil

Rounds up to the nearest integer.

{{ 3.2 | ceil }}   <!-- 4 -->

floor

Rounds down to the nearest integer.

{{ 3.8 | floor }}   <!-- 3 -->

round

Rounds to the given number of decimal places (default 0). With 0 places the result is an integer.

{{ 3.14159 | round }}      <!-- 3 -->
{{ 3.14159 | round: 2 }}   <!-- 3.14 -->

Bounds

at_least

Clamps the value up to a minimum — max(input, arg).

{{ 3 | at_least: 5 }}   <!-- 5 -->
{{ 8 | at_least: 5 }}   <!-- 8 -->

at_most

Clamps the value down to a maximum — min(input, arg).

{{ 8 | at_most: 5 }}   <!-- 5 -->
{{ 3 | at_most: 5 }}   <!-- 3 -->

Coercion

to_integer

(extension) Coerces any value to a real integer, usable in arithmetic. Float values truncate toward zero, numeric strings parse, true/false become 1/0, and nil or non-numeric values become 0. Values beyond the 64-bit range saturate rather than erroring; NaN becomes 0.

{{ "3.9" | to_integer }}         <!-- 3 -->
{{ "42" | to_integer | plus: 8 }}  <!-- 50 -->

See also

Array filters

Array filters

Filters for ordering, transforming, and querying lists — including the microdata items a resource binding yields. Standard Shopify Liquid unless marked extension.

Ordering and elements

first

The first element of an array (or first character of a string). nil if empty.

{{ items | first }}

last

The last element of an array (or last character of a string).

{{ items | last }}

reverse

Reverses the array.

{{ "a,b,c" | split: "," | reverse | join: "," }}   <!-- c,b,a -->

sort

Sorts the array. With a field name, sorts an array of objects by that field.

{{ "banana,apple,cherry" | split: "," | sort | join: ", " }}   <!-- apple, banana, cherry -->
{% assign newest = posts | sort: "date" %}

sort_natural

Case-insensitive lexical sort.

{{ "b,A,c" | split: "," | sort_natural | join: "" }}   <!-- Abc -->

uniq

Removes duplicate elements, preserving order.

{{ "a,b,a,c" | split: "," | uniq | join: "," }}   <!-- a,b,c -->

compact

Removes nil elements.

{{ list | compact | join: ", " }}

Transforming and joining

map

Extracts a field from every object in the array.

{{ people | map: "name" | join: ", " }}

join

Joins the elements into a string with a separator (default a space).

{{ tags | join: ", " }}

concat

Concatenates a second array onto the input.

{% assign all = drafts | concat: published %}

Querying an array of objects

These operate on an array of objects and let a template filter, find, and test them without a {% for %} loop.

Each takes a field name and an optional expected value: array | filter: "field", value matches items whose field equals value; with the value omitted, array | filter: "field" matches items whose field is truthy. Non-object elements never match. For a full expression rather than field equality, see the expression-variant filters.

where

The sub-array of matching items. (Standard Liquid.)

{{ people | where: "role", "admin" | map: "name" | join: ", " }}

reject

(extension) The sub-array of non-matching items — the inverse of where.

{% assign active = users | reject: "suspended", true %}

find

(extension) The first matching element (or nil).

{{ products | find: "sku", "A-1" | map: "title" }}

find_index

(extension) The index of the first matching element (or nil).

{{ products | find_index: "sku", "A-1" }}   <!-- 0 -->

has

(extension) A boolean — whether any element matches.

{% if products | has: "onsale", true %}Sale on now!{% endif %}

group_by

(extension) Groups the array by a field, returning an array of { name, items } objects in first-seen key order.

{% assign by_year = posts | group_by: "year" %}
{% for group in by_year %}
  <h2>{{ group.name }}</h2>
  <ul>{% for post in group.items %}<li>{{ post.title }}</li>{% endfor %}</ul>
{% endfor %}

sum

(extension) Totals the array. With no argument it sums the array's numbers; with a field name it sums that property across an array of objects. Non-numeric values count as 0; the result is an integer when whole, otherwise a float.

{{ prices | sum }}                <!-- total of the numbers -->
{{ line_items | sum: "price" }}   <!-- total of each item's price -->

Mutation

Each returns a new array — the input is never mutated — so they compose in a pipeline or an {% assign %}. All are extensions.

push

(extension) Appends a value, returning a new array. Standard Liquid has no array-append filter — concat only joins two arrays — so push fills the gap. The input is coerced to an array first: an array is appended to; nothing (an unassigned variable) becomes a one-element array; any other single value is promoted to a one-element array first.

Pushing onto an unassigned variable is the idiomatic way to build a list in a loop:

{% for term in terms %}
  {% assign wanted = wanted | push: term %}
{% endfor %}
{{ wanted | join: ", " }}

wanted is never assigned before the loop, so on the first iteration it is nil and push returns a one-element array; each later iteration appends. This avoids a common footgun: {% assign a = "" | split: "," %} does not produce an empty array — it produces [""].

unshift

(extension) Prepends a value, returning a new array. LiquidJS-compatible.

{% assign crumbs = crumbs | unshift: "Home" %}

pop

(extension) Returns a new array without the last element. LiquidJS-compatible.

{% assign rest = items | pop %}

shift

(extension) Returns a new array without the first element. LiquidJS-compatible.

{% assign tail = items | shift %}

See also

Date and time filters

Date and time filters

Formatting and arithmetic on dates. date is standard Shopify Liquid; the rest are extensions. Every filter here accepts the same inputs and normalises them to UTC:

date

Formats a date with a strftime-style format string. The format argument defaults to %Y-%m-%d. Supported directives: %Y %m %d %H %M %S %B %b %A %a %j %p %Z %z %% (unknown directives pass through unchanged).

{{ "2026-04-12" | date: "%B %Y" }}          <!-- April 2026 -->
{{ "2026-04-12T13:45:00Z" | date: "%H:%M" }}   <!-- 13:45 -->
{{ "now" | date: "%Y" }}                        <!-- current year -->

date_add

(extension) Adds a number of seconds to a date, returning an ISO 8601 UTC string.

{{ "now" | date_add: 172800 }}                 <!-- 48 hours from now -->
{{ "2026-01-01T00:00:00Z" | date_add: 3600 }}   <!-- 2026-01-01T01:00:00Z -->

unix_to_iso

(extension) Converts a Unix timestamp (seconds since the epoch) to an ISO 8601 UTC string.

{{ 1767225600 | unix_to_iso }}   <!-- 2026-01-01T00:00:00Z -->

Jekyll date filters

Four fixed-format filters matching Jekyll / LiquidJS. Each takes no argument.

date_to_string

(extension) Short date — %d %b %Y.

{{ "2026-07-07T13:07:59Z" | date_to_string }}   <!-- 07 Jul 2026 -->

date_to_long_string

(extension) Long date — %d %B %Y.

{{ "2026-07-07T13:07:59Z" | date_to_long_string }}   <!-- 07 July 2026 -->

date_to_rfc822

(extension) RFC 822 date-time — the form RSS feeds use.

{{ "2026-07-07T13:07:59Z" | date_to_rfc822 }}   <!-- Tue, 07 Jul 2026 13:07:59 +0000 -->

date_to_xmlschema

(extension) ISO 8601 / XML-schema date-time — the form Atom feeds and sitemaps use.

{{ "2026-07-07T13:07:59Z" | date_to_xmlschema }}   <!-- 2026-07-07T13:07:59+00:00 -->

The RFC 822 and XML-schema forms make it straightforward to emit valid RSS/Atom feeds and sitemaps from a template.

See also

Hashing and security filters

Hashing and security filters

Cryptographic digests and password hashes. All are Pagelove extensions.

sha256

Produces a hex-encoded SHA-256 hash of the input string.

{{ "hello" | sha256 }}

Output: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824

bcrypt

Produces a bcrypt hash of the input string. The optional argument sets the cost (default 12).

{{ "password" | bcrypt }}
{{ "password" | bcrypt: 10 }}

argon2

Produces an Argon2id hash of the input string. Returns a PHC-format string by default.

{{ "password" | argon2 }}
{{ "password" | argon2: memory: 65536, time: 3 }}
Parameter Default Description
format "phc" Output format: "phc" (standard PHC string) or "raw" (hex-encoded)
salt Salt to use in "raw" mode. Required when format: "raw"; at least 8 bytes. Ignored in PHC mode.
memory 19456 Memory cost in KiB (minimum 8)
time 2 Time cost / iterations (minimum 1)
length 32 Output hash length in bytes (4–64)

The default PHC output is self-describing: the string embeds the salt and parameters, so it can be verified later (re-hash the candidate and compare). format: "raw" returns only the hex-encoded digest bytes, which have nowhere to carry a salt — so raw mode requires an explicit salt: (otherwise the random salt would be discarded, leaving a digest that can never be reproduced). Supply a stable salt so the value is deterministic:

{{ "password" | argon2: format: "raw", salt: "per-user-unique-salt" }}

For password storage, prefer the default PHC format unless you are managing salts yourself.

See also

Random generation filters

Random generation filters

Cryptographically random strings and passphrases, drawn from the OS RNG. Both are Pagelove extensions.

random

Generates a random string whose length is the input.

{{ 32 | random }}

Output: a 32-character alphanumeric string.

Parameter Values Description
upper true / false / integer Include uppercase letters; an integer sets a minimum count
lower true / false / integer Include lowercase letters
digits true / false / integer Include digits
symbols true / false / integer Include symbols (!@#$%^&*()-_=+[]{}…)
alphanumeric true / false / integer Include letters and digits
url_safe true / false Use the URL-safe alphabet (A-Za-z0-9-_)
chars string Custom character set
chars_min integer Minimum count drawn from the custom character set
<!-- 24-char string with at least 3 uppercase, 3 lowercase, 2 digits -->
{{ 24 | random: upper: 3, lower: 3, digits: 2 }}

<!-- 16-char lowercase + digits only -->
{{ 16 | random: lower: true, digits: true }}

diceware

Generates a hyphen-joined slug from random words drawn from a curated word list. The input is the word count (clamped to 1–10; default 3).

{{ 3 | diceware }}

Output: a slug like fuzzy-blue-wombat. The word list has over 1,600 common English adjectives, nouns, and colours; three words yield roughly 4.8 billion combinations.

See also

Data and JSON filters

Data and JSON filters

Serialise a template value to JSON. All are Pagelove extensions.

json

Converts a value to a JSON string. Pass an optional indent for pretty-printing.

{{ request | json }}       <!-- compact -->
{{ request | json: 2 }}    <!-- pretty-printed, 2-space indent -->

jsonify

A Jekyll alias of json — identical output, including the optional indent argument.

{{ request | jsonify }}

inspect

Debug-serialises a value. Template values are acyclic, so this is compact JSON — equivalent to json with no indent. (The name matches Jekyll / LiquidJS, where inspect differs from json only in handling circular references, which do not arise here.)

{{ some_value | inspect }}

See also

Expression-variant array filters

Expression-variant array filters

The array query filters match on a single field equality. The _exp variants instead take an item-variable name and a predicate expression evaluated for each element, so the test can be any Liquid expression — comparisons, boolean logic, property paths, even nested filters. All are Pagelove/LiquidJS extensions.

Signature: collection | filter_exp: "<var>", "<expression>". The element is bound to <var> for the expression (as a {% for %}-loop variable would be), and the expression is a Liquid expression string.

Nested expression filters are supported and terminate normally, so a predicate may itself query a collection:

{{ groups | where_exp: "g", "g.items | has_exp: 'i', 'i.active'" }}

One limit applies: expression-filter evaluation may nest at most 32 levels deep. Beyond that the filter reports an error rather than continuing.

The limit exists because a predicate is re-resolved each time it is evaluated, so a predicate that refers to itself would otherwise recurse forever:

{% assign pred = "users | where_exp: 'x', pred" %}
{{ users | where_exp: "u", pred }}

That is refused. Ordinary nesting is nowhere near the limit — one or two levels is typical — so a template that hits it is almost certainly self-referential.

Where the error appears depends on context, as it does for every Liquid error: inside {{ }} output the page still renders and the failure appears in place as an error item; inside {% assign %} the error stops the render, so the page does not appear at all.

where_exp

The sub-array of items whose expression is truthy.

{{ users | where_exp: "u", "u.age >= 18" | map: "name" | join: ", " }}

reject_exp

The sub-array of items whose expression is falsy — the inverse of where_exp.

{% assign upcoming = events | reject_exp: "e", "e.cancelled" %}

find_exp

The first item whose expression is truthy (or nil).

{{ products | find_exp: "p", "p.price < 10" | map: "title" }}

find_index_exp

The index of the first item whose expression is truthy (or nil).

{{ steps | find_index_exp: "s", "s.done == false" }}

has_exp

A boolean — whether any item's expression is truthy.

{% if events | has_exp: "e", "e.starts > now" %}Upcoming events{% endif %}

group_by_exp

Groups by the value of the expression, returning { name, items } objects in first-seen key order. Because the key is an expression, you can group by a computed value — here, the year of each order:

{% assign by_year = orders | group_by_exp: "o", "o.date | date: '%Y'" %}
{% for group in by_year %}
  <h3>{{ group.name }}</h3>
  <ul>{% for order in group.items %}<li>{{ order.ref }}</li>{% endfor %}</ul>
{% endfor %}

See also