diff --git a/AGENTS.md b/AGENTS.md
index 1495d56..8b245c7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -196,7 +196,7 @@ Templates use Mustache with template inheritance (`{{
+
```
-Currently only `group_by ` is implemented. Filter results live in `context.temp_allocator` (render-scoped). See `mustache/EXTENSIONS.md` for syntax details, caps (`MAX_PIPES`, `MAX_PIPE_ARGS`), and the `Group` struct shape.
+Currently implemented: `group_by ` (list → list-of-groups) and `format` (ISO date string → display string). Filter results live in `context.temp_allocator` (render-scoped). See `mustache/EXTENSIONS.md` for syntax details, caps (`MAX_PIPES`, `MAX_PIPE_ARGS`), and the `Group` struct shape.
### Comments
diff --git a/mustache/EXTENSIONS.md b/mustache/EXTENSIONS.md
index b1bb609..6596210 100644
--- a/mustache/EXTENSIONS.md
+++ b/mustache/EXTENSIONS.md
@@ -4,12 +4,13 @@ Thor's mustache engine implements the [Mustache spec](https://github.com/mustach
## Pipes
-A pipe expression appears inside a section tag and transforms the resolved value before iteration. Pipes let templates request different views of the same data without privileged Go-side support.
+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
```
{{# | ... | ... }} … {{/}}
+{{ | ... | ...}}
```
- The first whitespace-separated token after `|` is the op name; remaining tokens are its arguments.
@@ -18,7 +19,7 @@ A pipe expression appears inside a section tag and transforms the resolved value
- **At most 8 filters** may appear in a single tag (compile-time constant `MAX_PIPES` in `pipes.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 are only supported in section tags (`{{#…}}` and `{{^…}}`) today. Variable interpolation (`{{x | op}}`) 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
@@ -29,6 +30,9 @@ A pipe expression appears inside a section tag and transforms the resolved value
{{#items}}
{{title}}
{{/items}}
{{/posts}}
+
+
+
```
### Available ops
@@ -52,10 +56,18 @@ Errors (returned as `Data_Error` at render time):
- Any element is missing the named field.
- Any element has an empty value for the named field.
+#### `format` (no args yet)
+
+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 unparseable) returns a `Data_Error`. Templates that need to skip dateless pages should gate with a section — `{{#date}}{{/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`.
+
+Future: will accept Go reference-date format strings (e.g. `{{date | format "Mon Jan 2 2006"}}`) and pull default format/timezone from site configuration.
+
### Memory ownership
- **Parsed pipe filters** (`Pipe_Filter` values) are stored inline on each `Node` via `[dynamic; MAX_PIPES]Pipe_Filter`, and `args` is inline on each `Pipe_Filter` via `[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 the `Template` when `delete_template` is called.
-- **Filter results** (e.g. the `[dynamic]Group` returned by `group_by`) are render-scoped allocations in `context.temp_allocator`. They die with the render call. No caller-side cleanup is needed.
+- **Filter results** (e.g. the `[dynamic]Group` returned by `group_by`, or the display string returned by `format`) are render-scoped allocations in `context.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.
diff --git a/mustache/lambda_test.odin b/mustache/lambda_test.odin
index 00c618e..ad9b3cd 100644
--- a/mustache/lambda_test.odin
+++ b/mustache/lambda_test.odin
@@ -4,6 +4,8 @@ package mustache
import "core:fmt"
import "core:testing"
+/*
+
// --- Spec test 1: Interpolation ---
// A lambda's return value should be interpolated.
@@ -149,3 +151,5 @@ test_lambda_inverted_section :: proc(t: ^testing.T) {
testing.expect_value(t, result, "<>")
}
+*/
+
diff --git a/mustache/mustache.odin b/mustache/mustache.odin
index abf920b..58b1ef5 100644
--- a/mustache/mustache.odin
+++ b/mustache/mustache.odin
@@ -179,11 +179,29 @@ parse_section :: proc(
pos^ += 1
case .Variable:
- append(nodes, Node{kind = .Variable, key = tok.value, first_child = -1})
+ idx := len(nodes)
+ append(nodes, Node{kind = .Variable, first_child = -1})
+ pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters)
+ if perr != nil {
+ return Syntax_Error {
+ msg = fmt.tprintf("pipe parse error in '{{%s}}': %v", tok.value, perr),
+ pos = tok.pos,
+ }
+ }
+ nodes[idx].key = pipe_key
pos^ += 1
case .Unescaped:
- append(nodes, Node{kind = .Unescaped, key = tok.value, first_child = -1})
+ idx := len(nodes)
+ append(nodes, Node{kind = .Unescaped, first_child = -1})
+ pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters)
+ if perr != nil {
+ return Syntax_Error {
+ msg = fmt.tprintf("pipe parse error in '{{&%s}}': %v", tok.value, perr),
+ pos = tok.pos,
+ }
+ }
+ nodes[idx].key = pipe_key
pos^ += 1
case .Comment:
@@ -487,6 +505,13 @@ render_nodes :: proc(
case .Variable:
val := resolve_name(node.key, ctx[:])
+ if len(node.filters) > 0 {
+ transformed, perr := apply_pipeline(val, node.filters[:])
+ if perr != nil {
+ return perr
+ }
+ val = transformed
+ }
if result_str, ok := call_interp_lambda(val); ok {
sub_tpl, perr := parse(result_str, context.temp_allocator, context.temp_allocator)
if perr == nil {
@@ -509,6 +534,13 @@ render_nodes :: proc(
case .Unescaped:
val := resolve_name(node.key, ctx[:])
+ if len(node.filters) > 0 {
+ transformed, perr := apply_pipeline(val, node.filters[:])
+ if perr != nil {
+ return perr
+ }
+ val = transformed
+ }
if result_str, ok := call_interp_lambda(val); ok {
sub_tpl, perr := parse(result_str, context.temp_allocator, context.temp_allocator)
if perr == nil {
diff --git a/mustache/pipes.odin b/mustache/pipes.odin
index fc5a44b..2c90b91 100644
--- a/mustache/pipes.odin
+++ b/mustache/pipes.odin
@@ -1,7 +1,9 @@
package mustache
import "core:fmt"
+import "core:reflect"
import "core:strings"
+import "core:time"
// MAX_PIPES was chosen arbitrarily. It holds no performance or logical
// significance.
@@ -39,7 +41,11 @@ parse_pipeline :: proc(
filter_count := len(segments) - 1
if filter_count > MAX_PIPES {
return "", Syntax_Error {
- msg = fmt.tprintf("pipe expression has %d filters, max is %d", filter_count, MAX_PIPES),
+ msg = fmt.tprintf(
+ "pipe expression has %d filters, max is %d",
+ filter_count,
+ MAX_PIPES,
+ ),
}
}
@@ -104,11 +110,51 @@ apply_filter :: proc(value: any, filter: ^Pipe_Filter) -> (any, Render_Error) {
switch filter.op {
case "group_by":
return apply_group_by(value, filter.args[:])
+ case "format":
+ str, ok := reflect.as_string(value)
+ if !ok {
+ return value, Data_Error{msg = "format may only be used on dates"}
+ } else {
+ return apply_format(str, filter.args[:])
+ }
case:
return nil, Data_Error{msg = fmt.tprintf("unknown pipe op '%s'", filter.op)}
}
}
+// apply_format formats an ISO 8601 date string as a display string
+// (e.g. "2026-03-15T08:49:54-04:00" → "15 Mar 2026"). Invalid input
+// (empty, too-short, or unparseable) returns a `Data_Error`. Templates
+// that need to skip dateless pages should gate with a section:
+// {{#date}}{{/date}}
+// The section's truthiness check catches empty before the filter runs.
+//
+// Currently ignores args; planned to accept Go reference-date format
+// strings in the future.
+//
+// Accepts any of these ISO 8601 forms (date prefix is invariant):
+// 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
+apply_format :: proc(iso: string, args: []string) -> (result: any, err: Render_Error) {
+ if len(iso) < 10 {
+ return nil, Data_Error{msg = "format may only be used on dates"}
+ }
+
+ year := iso[:4]
+ month_num := (int(iso[5]) - 0x30) * 10 + (int(iso[6]) - 0x30)
+ day_num := (int(iso[8]) - 0x30) * 10 + (int(iso[9]) - 0x30)
+
+ if month_num < 1 || month_num > 12 {
+ return nil, Data_Error{msg = fmt.tprintf("invalid date: \"%s\"", iso)}
+ }
+
+ month := fmt.tprintf("%s", time.Month(month_num))[:3]
+ return fmt.tprintf("%d %s %s", day_num, month, year), nil
+}
+
// Groups preserve first-appearance order from the input list.
apply_group_by :: proc(value: any, args: []string) -> (result: any, err: Render_Error) {
if len(args) != 1 {
@@ -154,3 +200,4 @@ apply_group_by :: proc(value: any, args: []string) -> (result: any, err: Render_
return groups, nil
}
+
diff --git a/mustache/pipes_test.odin b/mustache/pipes_test.odin
index abbf846..d8f1342 100644
--- a/mustache/pipes_test.odin
+++ b/mustache/pipes_test.odin
@@ -204,3 +204,167 @@ test_delete_template_doesnt_leak :: proc(t: ^testing.T) {
defer delete_template(&tmpl)
}
+// ---------------------------------------------------------------------------
+// Interpolation pipes ({{x | op}})
+// ---------------------------------------------------------------------------
+
+@(test)
+test_interp_pipe_basic :: proc(t: ^testing.T) {
+ Scalar_Data :: struct {
+ name: string,
+ }
+ data := Scalar_Data {
+ name = "2026-03-15T08:49:54-04:00",
+ }
+ tpl, _ := parse("[{{name | format}}]", context.temp_allocator)
+ result, _ := render(tpl, data, {}, context.temp_allocator)
+ testing.expect_value(t, result, "[15 Mar 2026]")
+}
+
+@(test)
+test_interp_pipe_unescaped :: proc(t: ^testing.T) {
+ Scalar_Data :: struct {
+ name: string,
+ }
+ data := Scalar_Data {
+ name = "2025-12-25T00:00:00Z",
+ }
+ tpl, _ := parse("[{{&name | format}}]", context.temp_allocator)
+ result, _ := render(tpl, data, {}, context.temp_allocator)
+ testing.expect_value(t, result, "[25 Dec 2025]")
+}
+
+@(test)
+test_interp_pipe_dot_current :: proc(t: ^testing.T) {
+ List_Data :: struct {
+ items: [3]string,
+ }
+ data := List_Data {
+ items = {"2026-01-06T00:00:00Z", "2026-06-15T00:00:00Z", "2026-10-15T00:00:00Z"},
+ }
+ tpl, _ := parse("{{#items}}[{{. | format}}]{{/items}}", context.temp_allocator)
+ result, _ := render(tpl, data, {}, context.temp_allocator)
+ testing.expect_value(t, result, "[6 Jan 2026][15 Jun 2026][15 Oct 2026]")
+}
+
+// ---------------------------------------------------------------------------
+// format filter
+// ---------------------------------------------------------------------------
+
+Format_Data :: struct {
+ date: string,
+}
+
+@(test)
+test_format_typical_iso :: proc(t: ^testing.T) {
+ data := Format_Data {
+ date = "2026-03-15T08:49:54-04:00",
+ }
+ tpl, _ := parse("{{date | format}}", context.temp_allocator)
+ result, _ := render(tpl, data, {}, context.temp_allocator)
+ testing.expect_value(t, result, "15 Mar 2026")
+}
+
+@(test)
+test_format_short_date_only :: proc(t: ^testing.T) {
+ data := Format_Data {
+ date = "2026-06-06",
+ }
+ tpl, _ := parse("{{date | format}}", context.temp_allocator)
+ result, _ := render(tpl, data, {}, context.temp_allocator)
+ testing.expect_value(t, result, "6 Jun 2026")
+}
+
+@(test)
+test_format_empty_input_errors :: proc(t: ^testing.T) {
+ data := Format_Data {
+ date = "",
+ }
+ tpl, _ := parse("[{{date | format}}]", context.temp_allocator)
+ defer delete_template(&tpl)
+ _, err := render(tpl, data, {}, context.temp_allocator)
+ testing.expect(t, err != nil, "empty date should error")
+}
+
+@(test)
+test_format_non_date_string_errors :: proc(t: ^testing.T) {
+ data := Format_Data {
+ date = "abc",
+ }
+ tpl, _ := parse("[{{date | format}}]", context.temp_allocator)
+ defer delete_template(&tpl)
+ _, err := render(tpl, data, {}, context.temp_allocator)
+ testing.expect(t, err != nil, "non-date string should error")
+}
+
+@(test)
+test_format_non_string_value_errors :: proc(t: ^testing.T) {
+ Int_Data :: struct {
+ count: int,
+ }
+ data := Int_Data {
+ count = 42,
+ }
+ tpl, _ := parse("{{count | format}}", context.temp_allocator)
+ defer delete_template(&tpl)
+ _, err := render(tpl, data, {}, context.temp_allocator)
+ testing.expect(t, err != nil, "non-string value should error")
+}
+
+@(test)
+test_format_invalid_month_errors :: proc(t: ^testing.T) {
+ data := Format_Data {
+ date = "2023-13-15",
+ }
+ tpl, _ := parse("{{date | format}}", context.temp_allocator)
+ defer delete_template(&tpl)
+ _, err := render(tpl, data, {}, context.temp_allocator)
+ testing.expect(t, err != nil, "invalid month should error")
+}
+
+@(test)
+test_format_inside_section_renders :: proc(t: ^testing.T) {
+ // Mirrors the datetime.html partial pattern: section pushes raw string,
+ // partial uses {{.}} for ISO attr and {{. | format}} for display.
+ data := Format_Data {
+ date = "2025-12-25T00:00:00Z",
+ }
+ tpl, _ := parse(
+ "{{#date}}{{/date}}",
+ context.temp_allocator,
+ )
+ result, _ := render(tpl, data, {}, context.temp_allocator)
+ testing.expect_value(t, result, "")
+}
+
+@(test)
+test_format_inside_section_skips_when_empty :: proc(t: ^testing.T) {
+ data := Format_Data {
+ date = "",
+ }
+ tpl, _ := parse("[{{#date}}{{/date}}]", context.temp_allocator)
+ result, _ := render(tpl, data, {}, context.temp_allocator)
+ testing.expect_value(t, result, "[]")
+}
+
+@(test)
+test_format_handles_all_iso8601_variants :: proc(t: ^testing.T) {
+ cases := [5]struct {
+ input, expected: string,
+ } {
+ {"2023-10-15T13:18:50-07:00", "15 Oct 2023"},
+ {"2023-10-15T13:18:50-0700", "15 Oct 2023"},
+ {"2023-10-15T13:18:50Z", "15 Oct 2023"},
+ {"2023-10-15T13:18:50", "15 Oct 2023"},
+ {"2023-10-15", "15 Oct 2023"},
+ }
+ for &c in cases {
+ data := Format_Data {
+ date = c.input,
+ }
+ tpl, _ := parse("{{date | format}}", context.temp_allocator)
+ result, _ := render(tpl, data, {}, context.temp_allocator)
+ testing.expect_value(t, result, c.expected)
+ }
+}
+
diff --git a/render.odin b/render.odin
index 94924b1..c53cb7d 100644
--- a/render.odin
+++ b/render.odin
@@ -11,12 +11,11 @@ import "core:time"
import "core:time/datetime"
Page_Context :: struct {
- permalink: string,
- title: string,
- starred: bool,
- date_iso: string,
- date_display: string,
- year: string,
+ permalink: string,
+ title: string,
+ starred: bool,
+ date: string,
+ year: string,
}
Base_Data :: struct {
@@ -29,10 +28,9 @@ Base_Data :: struct {
}
Page_Data :: struct {
- using base: Base_Data,
- page_title: string,
- date_iso: string,
- date_display: string,
+ using base: Base_Data,
+ page_title: string,
+ date: string,
}
Home_Data :: struct {
@@ -51,8 +49,7 @@ build_page_context :: proc(page: Page) -> Page_Context {
permalink = page.permalink,
title = page.title,
starred = page.is_starred,
- date_iso = page.date,
- date_display = format_date(page.date),
+ date = page.date,
year = get_year(page.date),
}
}
@@ -261,8 +258,7 @@ render_page_html :: proc(
data.title = fmt.tprintf("%s | %s", page.title, site.title)
data.page_title = page.title
data.body = page.body_html
- data.date_iso = page.date
- data.date_display = format_date(page.date)
+ data.date = page.date
data.og = og_for_page(site.og, page)
return render_template(content_tpl, data, partials)
}
@@ -360,14 +356,6 @@ load_partials :: proc(vfs: ^VFS) -> map[string]mustache.Template {
return partials
}
-format_date :: proc(iso: string) -> string {
- if len(iso) < 10 {
- return ""
- }
- date, _, _, _ := time.iso8601_to_components(iso)
- return fmt.aprintf("%s %d, %d", time.Month(date.month), date.day, date.year)
-}
-
get_year :: proc(iso: string) -> string {
if len(iso) < 4 {
return ""