5.5 KiB
Mustache Extensions
Thor's mustache engine implements the Mustache spec plus the following non-standard extensions. Extensions are opt-in via template syntax — vanilla mustache templates render identically to the spec.
Pipes
A pipe expression appears inside a section tag or an interpolation tag and transforms the resolved value before rendering. Pipes let templates request different views of the same data without privileged Go-side support.
Syntax
{{#<key> | <op> <arg> <arg>... | <op> <arg>... }} … {{/<key>}}
{{<key> | <op> <arg> <arg>... | <op> <arg>...}}
- The first whitespace-separated token after
|is the op name; remaining tokens are its arguments. - Whitespace around
|is optional:posts|group_by yearandposts | group_by yearare equivalent. - Multiple filters compose left-to-right.
- At most 8 filters may appear in a single tag (compile-time constant
MAX_PIPESinpipes.odin). Exceeding it is a parse error. - At most 2 args per filter (compile-time constant
MAX_PIPE_ARGS). Exceeding it is a parse error. - The close tag is the bare key only. Pipe expressions are not allowed in close tags —
{{/posts | group_by year}}is a parse error. - Pipes work in both section tags (
{{#…}}and{{^…}}) and interpolation tags ({{…}}and{{&…}}). In sections, the transformed value becomes the section's context. In interpolations, the transformed value is what gets rendered.
Example
{{#posts | group_by year}}
<section>
<h2>{{key}}</h2>
<ul>{{#items}}<li>{{title}}</li>{{/items}}</ul>
</section>
{{/posts}}
<!-- Interpolation pipe: format a date string for display -->
<time datetime="{{date}}">{{date | format}}</time>
Available ops
group_by <field>
Buckets each element of a list by the value of <field>. Returns a list of Group values, each shaped as:
Group :: struct {
key: string, // the distinct field value
items: [dynamic]any, // elements sharing that value
}
Group order preserves first-appearance order in the input list (not key-sorted).
Errors (returned as Data_Error at render time):
- Argument count is not 1.
- Input is not a list.
- Any element is missing the named field.
- Any element has an empty value for the named field.
format
Formats an ISO 8601 date string as a display string. Takes a string, returns a string (e.g. "2026-03-15T08:49:54-04:00" → "15 Mar 2026"). Invalid input (empty, too-short, non-string, or unparsable) returns a Data_Error. Templates that need to skip dateless pages should gate with a section — {{#date}}<time datetime="{{.}}">{{. | format}}</time>{{/date}} — so the section's truthiness check catches empty before the filter runs. Commonly used inline as {{date | format}} to render a display string while keeping the raw ISO available via {{date}} for the datetime= attribute.
Internally: parses the invariant YYYY-MM-DD prefix by char offset, stringifies time.Month(month_num) and slices [:3] for the abbreviation. Accepts any of these ISO 8601 forms (the date prefix is what matters): 2023-10-15T13:18:50-07:00, 2023-10-15T13:18:50-0700, 2023-10-15T13:18:50Z, 2023-10-15T13:18:50, 2023-10-15.
Takes an optional arg for the Go reference-date layout to use:
- A double-quoted literal, spaces allowed:
{{date | format "Mon Jan 2 2006"}}. - A bare key, resolved from context like any other field:
{{date | format long}}uses the value oflong(e.g. a site-config field) as the layout. - No arg: falls back to the
date_formatcontext key (typicallydate.formatfromthor.json).
Timezone conversion
When the timezone context key is set (typically date.timezone from thor.json, an IANA name like "America/New_York"), the format pipe converts dates to that timezone before formatting:
- Date has offset + target tz: adjusts to true UTC, then converts to the target timezone (DST-aware).
- Date has no offset + target tz: assumes the date is already in the target timezone — no conversion, only resolves the abbreviation.
- Date has offset + no target tz: displays in the source offset.
- Date has no offset + no target tz: displays as-is, assumes UTC.
The MST token reflects the active timezone:
| Config tz | ISO has offset | MST output |
|---|---|---|
"America/New_York" |
yes | "EST" or "EDT" (DST-aware) |
"America/New_York" |
no | "EST" or "EDT" |
| not set | yes | "UTC-04:00" (from source offset) |
| not set | no | "UTC" |
Timezone data is loaded once by init_site via core:time/timezone.region_load, using the site arena allocator. The resolved ^datetime.TZ_Region pointer is stored on Site.tz and passed to templates through Base_Data.timezone. The arena frees it automatically on destroy_site.
Memory ownership
- Parsed pipe filters (
Pipe_Filtervalues) are stored inline on eachNodevia[dynamic; MAX_PIPES]Pipe_Filter, andargsis inline on eachPipe_Filtervia[dynamic; MAX_PIPE_ARGS]string. Both use Odin's fixed-capacity dynamic array type, so no per-tag heap allocations occur at parse time. The storage dies with theTemplatewhendelete_templateis called. - Filter results (e.g. the
[dynamic]Groupreturned bygroup_by, or the display string returned byformat) are render-scoped allocations incontext.temp_allocator. They die with the render call. No caller-side cleanup is needed. - The string data inside
Pipe_Filter(op names, args) is borrowed from the template source — no cloning.