Before the filter list, three renderer facts you need to know:
Output is HTML-encoded by default. Templates render with HtmlEncoder.Default(ThemeV2RenderService.RenderAsync), so every {{ expression }} is escaped. Anything that emitsmarkup — icon SVGs from attr_icon, the layout's content_for_layout / theme_css / theme_js— must go through the built-in raw filter:
{{ Attribute.Name | attr_icon | raw }}
<style>{{ theme_css | raw }}</style>
<script>{{ theme_js | raw }}</script>
<main>{{ content_for_layout | raw }}</main>
Resource limits. Tenant-authored templates run with MaxRecursion = 100 andMaxSteps = 100_000 (see BuildTemplateOptions). A runaway {% for x in (1..100000) %} will becut off rather than pinning a CPU. Keep loops bounded.
Section settings are plain values. Section/block settings arrive as JSON blobs; a valueconverter unwraps Newtonsoft JTokens and System.Text.Json.JsonElements into plain CLR typesbefore Fluid sees them, so Section.Settings.itemsPerPage, arrays likeSection.Settings.categoryIds, and nested objects all iterate and compare normally.
Filters are registered per request in RegisterFilters(TemplateOptions options); the memberaccess strategy (which theme context types are visible to templates) is a static singleton.
Custom Fluid filters
All custom filters are tolerant of null / missing / non-numeric input — a broken value renders asafe fallback rather than throwing, so theme previews don't blow up on incomplete data.
dollar
Signature: {{ value | dollar }}Behaviour: Parses the input as a decimal (invariant culture) and formats it as"$" + v.ToString("#,##0.00"). Anything unparseable (null, empty, text) renders $0.00.
165— Output:$165.001234.5— Output:$1,234.50null/""/"abc"— Output:$0.00
<span class="space-card__price-amount">{{ item.Price | dollar }}</span>
The origin theme deliberately relies on the $0.00 fallback — a priceless space must notcollapse the price block (see the comment in snippets/space-card.fluid). There is also asnippets/money.fluid wrapper: {{ amount | dollar }}.
plural
Signature: {{ noun | plural }}Behaviour: Light English pluraliser. s/x/ch/sh endings get es; consonant + ybecomes ies; everything else gets s. Case-insensitive ending checks.
Space— Output:SpacesBox— Output:BoxesFacility— Output:FacilitiesBus— Output:Buses
<a href="/t/spaces">Browse {{ Organisation.SpaceName | plural }}</a>
Note the templates special-case Annual themselves because Annual | plural would give"Annuals": {% if Space.DurationType == "Annual" %}Years{% else %}{{ Space.DurationType | plural }}{% endif %}.
multiply
Signature: {{ a | multiply: b }}Behaviour: Emits a * b rounded to 1 decimal place. If either operand fails to parse,emits an empty string (unlike the built-in times, which does no rounding).
{{ 3.2 | multiply: 2.5 }}— Output:8{{ 2.33 | multiply: 3 }}— Output:7(6.99 → 7){{ "abc" | multiply: 2 }}— Output: `` (empty)
<span>{{ Space.Width | multiply: Space.Length }}m<sup>2</sup></span>
The space card computes the same area with built-ins instead({{ item.Width | times: item.Length | round: 0 }}) — whole square metres on the card, onedecimal on the detail page.
cycle_abbrev
Signature: {{ durationType | cycle_abbrev }}Behaviour: Maps a billing cycle name to its short suffix. Mirrors the public site's AngularCycleAbbrevPipe; the default is always mo (never a dangling raw value).
week,weekly— Output:wfortnight,fortnightly— Output:fnday,daily— Output:dyear,yearly,annual,annually— Output:yrquarter,quarterly— Output:qtrhalfyearly,half-yearly— Output:6mo- anything else (incl.
month) — Output:mo
<span class="space-card__price-cycle">/{{ item.DurationType | cycle_abbrev }}</span>
theme.js carries the same map twice (the quote modal's cycleAbbrev object and theCYCLE_MONTHS normalisation table) — if you add a new duration type, update all three.
attr_icon
Signature: {{ attributeName | attr_icon | raw }}Behaviour: Keyword-matches the (lowercased) attribute name against the keywords map inschema/attribute-icons.json and returns the matching SVG string from its icons map. Firstkeyword hit wins. Falls back to the schema's default icon key, and if the schema is missing ormalformed, to a hard-coded generic check-mark SVG. Because it returns markup, it must be pipedthrough raw.
{{ "Drive-up access" | attr_icon | raw }}
{# → the "car" SVG, because the keywords map routes names containing "drive" to it #}
The maps are loaded once per render by LoadAttributeIconMaps and stitched onto both the page andlayout contexts (the filter runs in both render passes).
attr_icon_key
Signature: {{ iconKey | attr_icon_key | raw }}Behaviour: Direct lookup of an icon SVG by its key in schema/attribute-icons.json'sicons object (e.g. lock, thermometer, car). No keyword matching, no fallback — an unknownkey renders empty. Use this when the admin explicitly chose an icon; use attr_icon forautomatic matching by attribute name.
The precedence pattern from snippets/attribute-tile.fluid:
{% if iconBlock and iconBlock.Settings.image and iconBlock.Settings.image != "" %}
<img class="attribute-card__img" src="{{ iconBlock.Settings.image }}" alt="" loading="lazy">
{% elsif iconBlock and iconBlock.Settings.icon and iconBlock.Settings.icon != "" %}
{{ iconBlock.Settings.icon | attr_icon_key | raw }}
{% else %}
{{ Attribute.Name | attr_icon | raw }}
{% endif %}
safe_video_url
Signature: {{ url | safe_video_url }}Behaviour: Whitelist for <iframe src>. The URL must be absolute, https, and its host mustbe one of: youtube.com, www.youtube.com, youtube-nocookie.com, www.youtube-nocookie.com,youtu.be, vimeo.com, player.vimeo.com, dailymotion.com, www.dailymotion.com, loom.com,www.loom.com. Anything else — including plain http:// — renders about:blank.
<iframe src="{{ Section.Settings.videoUrl | safe_video_url }}" width="100%" height="100%"
frameborder="0" allowfullscreen loading="lazy"
sandbox="allow-scripts allow-same-origin" referrerpolicy="no-referrer"></iframe>
Never put a tenant-supplied URL into an iframe without this filter.
external_url
Signature: {{ url | external_url }}Behaviour: For social/outbound links. http(s)://, mailto: and tel: URLs pass throughuntouched; a bare domain gets https:// prepended (leading slashes stripped). Empty input rendersempty.
facebook.com/mybrand— Output:https://facebook.com/mybrandhttps://x.com/mybrand— Output:https://x.com/mybrandmailto:hi@example.com— Output:mailto:hi@example.com
{% assign facebookUrl = Footer.FacebookUrl | default: Settings.FacebookUrl %}
{% if facebookUrl %}<a href="{{ facebookUrl | external_url }}" target="_blank" rel="noopener">…</a>{% endif %}
storefront_url
Signature: {{ url | storefront_url }}Behaviour: For internal menu URLs, which are stored site-relative (/spaces). Thestorefront serves pages under /t/, so:
/spaces→/t/spaces(any leading-/path not already under/t/or/account)/account/...and absolute URLs pass through untouched- a bare phone number (
0412 345 678) becomestel:0412345678 - a bare email (
hi@example.com) becomesmailto:hi@example.com - empty input →
/t/
<a href="{{ item1.Url | storefront_url }}">{{ item1.Label }}</a>
Use this on every menu-item URL (header, footer, top bar) — never hand-prefix /t/ onto storedmenu URLs.
dateformat
Signature: {{ date | dateformat }}Behaviour: Parses the input as a DateTimeOffset and formats it dd/MM/yyyy; unparseableinput renders empty. Registered but currently unused by the origin theme — available forcustom themes.
{{ Space.AvailableFrom | dateformat }} {# → 07/07/2026 #}
asset_url
Signature: {{ "file.ext" | asset_url }}Behaviour: Emits the public theme-asset path: /api/public/theme-v2/assets/{path}. Note theorigin theme doesn't use it for theme.css/theme.js — those are inlined into the layout astheme_css / theme_js (see §4). Use asset_url for any additional asset files your theme ships.
<img src="{{ 'hero-fallback.jpg' | asset_url }}" alt="">
{# → <img src="/api/public/theme-v2/assets/hero-fallback.jpg" alt=""> #}
json
Signature: {{ obj | json }}Behaviour: Serialises any Fluid value back to JSON via System.Text.Json. Intended forinlining server data into <script> blocks. Registered but currently unused by the origin theme.
<script>window.__SPACE__ = {{ Space | json | raw }};</script>
(You need raw here too — without it the quotes get HTML-encoded.)
Built-in Fluid/Liquid filters the theme relies on
Fluid ships the standard Liquid filter set. These are the ones the origin theme actually uses —real usages shown so you can copy the idiom:
times— What it does: Multiply (no rounding) — Real usage:{{ item.Width | times: item.Length | round: 0 }}m²— card area (snippets/space-card.fluid)round— What it does: Round to N decimals — Real usage:{{ Space.Width | round: 1 }}m × {{ Space.Length | round: 1 }}msplit— What it does: String → array on a separator — Real usage:{% assign placeParts = listingUrl | split: "query_place_id=" %}then{{ placeParts[1] | split: "&" | first }}— extracting a Google place id (sections/google-reviews.fluid)url_encode— What it does: Percent-encode for URLs — Real usage:<a href="/t/locations/{{ Space.LocationName | url_encode }}/spaces">slice— What it does: Substring / sub-array — Real usage:{{ item.Settings.name | slice: 0, 2 | upcase }}— two-letter avatar initials (sections/testimonials.fluid)upcase/downcase— What it does: Case conversion — Real usage:per {{ item.DurationType | downcase }}default— What it does: Fallback when nil/empty — Real usage:{{ Settings.Design.primaryColor | default: Settings.PrimaryColor | default: "#2563eb" }}— the layout's CSS-variable cascadeplus— What it does: Numeric add — also the standard trick to coerce a string setting to a number — Real usage:{% assign avg = Section.Settings.averageRating | plus: 0 %}then{% if avg >= 4.5 %}(sections/google-reviews.fluid)replace— What it does: String replace — Real usage:{{ fontParts[1] | replace: ' ', '+' }}— building a Google Fonts URL (layouts/main.fluid/embed.fluid)first— What it does: First element — Real usage:{{ placeId | split: "&" | first }}append— What it does: String concat — Real usage: used inlayouts/main.fluidfor meta/canonical URL assemblystrip— What it does: Trim whitespace — Real usage:partials/announcement-bar.fluidraw— What it does: Suppress HTML encoding — Real usage: required for every filter/value that returns markup (see §1)
Also useful, not filters: the .size property ({% if Spaces.size > 0 %} — an empty list istruthy in Fluid, so always compare size, per the comment in snippets/space-listing.fluid),the contains operator ({% if listingUrl contains "query_place_id=" %}), forloop.first /forloop.index, and {% capture %} for reusable HTML blocks.