← All sections · part of the machine-readable /all/ index.
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.
pagelove:template mechanism: enabling templates, the variables in scope, and how rendered output replaces the element.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.
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.
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.
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 execution is scoped to the annotated element, side-effect free, and deterministic.
Templates cannot:
<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:
<section class="debug" pagelove:template="text/liquid">
<pre>{{ request | json: 2 }}</pre>
</section>
<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.
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.
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.
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
Random generation — random strings and passphrases
Data and JSON — serialise values
Expression-variant array — query arrays with a full expression
find_exp · find_index_exp · group_by_exp · has_exp · reject_exp · where_exp
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.
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.
Lowercases the string.
{{ "Hello, World" | downcase }} <!-- hello, world -->
Uppercases the string.
{{ "Hello, World" | upcase }} <!-- HELLO, WORLD -->
Uppercases the first character and lowercases the rest.
{{ "hello WORLD" | capitalize }} <!-- Hello world -->
Trims whitespace from both ends.
{{ " hi " | strip }} <!-- hi -->
Trims whitespace from the start.
{{ " hi " | lstrip }} <!-- "hi " -->
Trims whitespace from the end.
{{ " hi " | rstrip }} <!-- " hi" -->
Removes every \n and \r.
{{ "a\nb\nc" | strip_newlines }} <!-- abc -->
Inserts an HTML <br /> before each newline.
{{ "a\nb" | newline_to_br }} <!-- a<br />\nb -->
(extension) Collapses every run of whitespace to a single space and trims.
{{ "a b\n c" | normalize_whitespace }} <!-- a b c -->
HTML-escapes < > & " '. The result is marked safe, so it is not escaped again.
{{ "<a href='x'>" | escape }} <!-- <a href='x'> -->
Like escape, but leaves existing entities intact (does not double-escape).
{{ "1 < 2 & 3" | escape_once }} <!-- 1 < 2 & 3 -->
Form-URL-encodes the string (space → +).
{{ "a b&c" | url_encode }} <!-- a+b%26c -->
Reverses url_encode.
{{ "a+b%26c" | url_decode }} <!-- a b&c -->
(extension) Produces an application/x-www-form-urlencoded value (space → +). Jekyll/LiquidJS-compatible.
{{ "a b & c" | cgi_escape }} <!-- a+b+%26+c -->
(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 -->
(extension) Escapes & < > " ' to their XML/HTML entities. Jekyll/LiquidJS-compatible.
{{ "<a>'x'</a>" | xml_escape }} <!-- <a>'x'</a> -->
Removes HTML tags (and the contents of <script>/<style>).
{{ "<b>hi</b><script>x()</script>" | strip_html }} <!-- hi -->
Replaces every occurrence of a substring.
{{ "a-b-c" | replace: "-", "+" }} <!-- a+b+c -->
Replaces the first occurrence.
{{ "a-b-c" | replace_first: "-", "+" }} <!-- a+b-c -->
(extension) Replaces the last occurrence. LiquidJS-compatible.
{{ "a-b-c" | replace_last: "-", "+" }} <!-- a-b+c -->
Removes every occurrence of a substring.
{{ "a-b-c" | remove: "-" }} <!-- abc -->
Removes the first occurrence.
{{ "a-b-c" | remove_first: "-" }} <!-- ab-c -->
(extension) Removes the last occurrence. LiquidJS-compatible.
{{ "a-b-c" | remove_last: "-" }} <!-- a-bc -->
Appends a string to the end.
{{ "/page" | append: ".html" }} <!-- /page.html -->
Prepends a string to the start.
{{ "world" | prepend: "hello " }} <!-- hello world -->
(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 -->
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 -->
Splits a string into an array on a separator (an empty separator splits per character).
{{ "a,b,c" | split: "," | join: " · " }} <!-- a · b · c -->
Truncates to N characters (including the suffix, default ...).
{{ "The quick brown fox" | truncate: 9 }} <!-- The qu... -->
Keeps the first N words, then the suffix (default ...).
{{ "The quick brown fox" | truncatewords: 2 }} <!-- The quick... -->
The length of a string (characters), array (elements), or object (keys); otherwise 0.
{{ "hello" | size }} <!-- 5 -->
(extension) Counts whitespace-separated words (returns an integer). Jekyll/LiquidJS-compatible.
{{ "the quick brown fox" | number_of_words }} <!-- 4 -->
Returns the argument when the input is blank, empty, or false.
{{ user.nickname | default: "Anonymous" }}
(extension) Lowercases, collapses each run of non-alphanumeric characters to a single -, and trims leading/trailing -. Jekyll/LiquidJS-compatible.
{{ "Hello, World!" | slugify }} <!-- hello-world -->
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.
Adds the argument.
{{ 10 | plus: 5 }} <!-- 15 -->
Subtracts the argument.
{{ 10 | minus: 3 }} <!-- 7 -->
Multiplies by the argument.
{{ 6 | times: 7 }} <!-- 42 -->
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 -->
The remainder of division. Modulo by zero returns the input unchanged.
{{ 13 | modulo: 5 }} <!-- 3 -->
Absolute value.
{{ -8 | abs }} <!-- 8 -->
Rounds up to the nearest integer.
{{ 3.2 | ceil }} <!-- 4 -->
Rounds down to the nearest integer.
{{ 3.8 | floor }} <!-- 3 -->
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 -->
Clamps the value up to a minimum — max(input, arg).
{{ 3 | at_least: 5 }} <!-- 5 -->
{{ 8 | at_least: 5 }} <!-- 8 -->
Clamps the value down to a maximum — min(input, arg).
{{ 8 | at_most: 5 }} <!-- 5 -->
{{ 3 | at_most: 5 }} <!-- 3 -->
(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 -->
Filters for ordering, transforming, and querying lists — including the microdata items a resource binding yields. Standard Shopify Liquid unless marked extension.
The first element of an array (or first character of a string). nil if empty.
{{ items | first }}
The last element of an array (or last character of a string).
{{ items | last }}
Reverses the array.
{{ "a,b,c" | split: "," | reverse | join: "," }} <!-- c,b,a -->
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" %}
Case-insensitive lexical sort.
{{ "b,A,c" | split: "," | sort_natural | join: "" }} <!-- Abc -->
Removes duplicate elements, preserving order.
{{ "a,b,a,c" | split: "," | uniq | join: "," }} <!-- a,b,c -->
Removes nil elements.
{{ list | compact | join: ", " }}
Extracts a field from every object in the array.
{{ people | map: "name" | join: ", " }}
Joins the elements into a string with a separator (default a space).
{{ tags | join: ", " }}
Concatenates a second array onto the input.
{% assign all = drafts | concat: published %}
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.
The sub-array of matching items. (Standard Liquid.)
{{ people | where: "role", "admin" | map: "name" | join: ", " }}
(extension) The sub-array of non-matching items — the inverse of where.
{% assign active = users | reject: "suspended", true %}
(extension) The first matching element (or nil).
{{ products | find: "sku", "A-1" | map: "title" }}
(extension) The index of the first matching element (or nil).
{{ products | find_index: "sku", "A-1" }} <!-- 0 -->
(extension) A boolean — whether any element matches.
{% if products | has: "onsale", true %}Sale on now!{% endif %}
(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 %}
(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 -->
Each returns a new array — the input is never mutated — so they compose in a pipeline or an {% assign %}. All are extensions.
(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 [""].
(extension) Prepends a value, returning a new array. LiquidJS-compatible.
{% assign crumbs = crumbs | unshift: "Home" %}
(extension) Returns a new array without the last element. LiquidJS-compatible.
{% assign rest = items | pop %}
(extension) Returns a new array without the first element. LiquidJS-compatible.
{% assign tail = items | shift %}
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:
2026-04-12) — midnight UTC;Z (2026-04-12T13:45:00Z) or without (interpreted as UTC);"now" or "today".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 -->
(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 -->
(extension) Converts a Unix timestamp (seconds since the epoch) to an ISO 8601 UTC string.
{{ 1767225600 | unix_to_iso }} <!-- 2026-01-01T00:00:00Z -->
Four fixed-format filters matching Jekyll / LiquidJS. Each takes no argument.
(extension) Short date — %d %b %Y.
{{ "2026-07-07T13:07:59Z" | date_to_string }} <!-- 07 Jul 2026 -->
(extension) Long date — %d %B %Y.
{{ "2026-07-07T13:07:59Z" | date_to_long_string }} <!-- 07 July 2026 -->
(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 -->
(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.
Cryptographic digests and password hashes. All are Pagelove extensions.
Produces a hex-encoded SHA-256 hash of the input string.
{{ "hello" | sha256 }}
Output: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
Produces a bcrypt hash of the input string. The optional argument sets the cost (default 12).
{{ "password" | bcrypt }}
{{ "password" | bcrypt: 10 }}
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.
Cryptographically random strings and passphrases, drawn from the OS RNG. Both are Pagelove extensions.
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 }}
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.
Serialise a template value to JSON. All are Pagelove extensions.
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 -->
A Jekyll alias of json — identical output, including the optional indent argument.
{{ request | jsonify }}
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 }}
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.
The sub-array of items whose expression is truthy.
{{ users | where_exp: "u", "u.age >= 18" | map: "name" | join: ", " }}
The sub-array of items whose expression is falsy — the inverse of where_exp.
{% assign upcoming = events | reject_exp: "e", "e.cancelled" %}
The first item whose expression is truthy (or nil).
{{ products | find_exp: "p", "p.price < 10" | map: "title" }}
The index of the first item whose expression is truthy (or nil).
{{ steps | find_index_exp: "s", "s.done == false" }}
A boolean — whether any item's expression is truthy.
{% if events | has_exp: "e", "e.starts > now" %}Upcoming events{% endif %}
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 %}