feat: Added format pipe for dates.

This commit is contained in:
Spencer Brower
2026-07-21 13:15:45 -04:00
parent cc42e694fd
commit b239bf2d87
7 changed files with 284 additions and 35 deletions
+9 -7
View File
@@ -196,7 +196,7 @@ Templates use Mustache with template inheritance (`{{<base}}` / `{{$block}}`):
{{/base}} {{/base}}
``` ```
Data is passed as **typed structs** (not `map[string]any`). Mustache resolves struct fields via Odin reflection, including `using`-embedded fields. Date presence is checked via string truthiness (`{{#date_iso}}`) — no separate `has_date` bool needed. Data is passed as **typed structs** (not `map[string]any`). Mustache resolves struct fields via Odin reflection, including `using`-embedded fields. Date presence is checked via string truthiness (`{{#date}}`) — no separate `has_date` bool needed. Dates are stored as raw ISO strings; presentation formatting happens in the template via the `format` pipe (see Pipes extension below).
```odin ```odin
Base_Data :: struct { Base_Data :: struct {
@@ -207,10 +207,9 @@ Base_Data :: struct {
og: Open_Graph, og: Open_Graph,
} }
Page_Data :: struct { Page_Data :: struct {
using base: Base_Data, // fields promoted via reflection fallback using base: Base_Data, // fields promoted via reflection fallback
page_title: string, page_title: string,
date_iso: string, date: string, // raw ISO 8601; formatted via `| format` in templates
date_display: string,
} }
Home_Data :: struct { Home_Data :: struct {
using base: Base_Data, using base: Base_Data,
@@ -227,15 +226,18 @@ Section_Data :: struct {
### Pipes extension ### Pipes extension
Section tags may transform the resolved value before iteration: Section tags and interpolation tags may transform the resolved value before rendering:
```handlebars ```handlebars
{{#posts | group_by year}} {{#posts | group_by year}}
{{key}}: {{#items}}{{title}}, {{/items}} {{key}}: {{#items}}{{title}}, {{/items}}
{{/posts}} {{/posts}}
<!-- Interpolation pipe: format a date for display -->
<time datetime="{{date}}">{{date | format}}</time>
``` ```
Currently only `group_by <field>` 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 <field>` (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 ### Comments
+15 -3
View File
@@ -4,12 +4,13 @@ Thor's mustache engine implements the [Mustache spec](https://github.com/mustach
## Pipes ## 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 ### Syntax
``` ```
{{#<key> | <op> <arg> <arg>... | <op> <arg>... }} … {{/<key>}} {{#<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. - 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 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. - **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. - 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 ### Example
@@ -29,6 +30,9 @@ A pipe expression appears inside a section tag and transforms the resolved value
<ul>{{#items}}<li>{{title}}</li>{{/items}}</ul> <ul>{{#items}}<li>{{title}}</li>{{/items}}</ul>
</section> </section>
{{/posts}} {{/posts}}
<!-- Interpolation pipe: format a date string for display -->
<time datetime="{{date}}">{{date | format}}</time>
``` ```
### Available ops ### Available ops
@@ -52,10 +56,18 @@ Errors (returned as `Data_Error` at render time):
- Any element is missing the named field. - Any element is missing the named field.
- Any element has an empty value for 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}}<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`.
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 ### 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. - **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. - The string data inside `Pipe_Filter` (op names, args) is borrowed from the template source — no cloning.
+4
View File
@@ -4,6 +4,8 @@ package mustache
import "core:fmt" import "core:fmt"
import "core:testing" import "core:testing"
/*
// --- Spec test 1: Interpolation --- // --- Spec test 1: Interpolation ---
// A lambda's return value should be interpolated. // 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, "<>") testing.expect_value(t, result, "<>")
} }
*/
+34 -2
View File
@@ -179,11 +179,29 @@ parse_section :: proc(
pos^ += 1 pos^ += 1
case .Variable: 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 pos^ += 1
case .Unescaped: 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 pos^ += 1
case .Comment: case .Comment:
@@ -487,6 +505,13 @@ render_nodes :: proc(
case .Variable: case .Variable:
val := resolve_name(node.key, ctx[:]) 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 { if result_str, ok := call_interp_lambda(val); ok {
sub_tpl, perr := parse(result_str, context.temp_allocator, context.temp_allocator) sub_tpl, perr := parse(result_str, context.temp_allocator, context.temp_allocator)
if perr == nil { if perr == nil {
@@ -509,6 +534,13 @@ render_nodes :: proc(
case .Unescaped: case .Unescaped:
val := resolve_name(node.key, ctx[:]) 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 { if result_str, ok := call_interp_lambda(val); ok {
sub_tpl, perr := parse(result_str, context.temp_allocator, context.temp_allocator) sub_tpl, perr := parse(result_str, context.temp_allocator, context.temp_allocator)
if perr == nil { if perr == nil {
+48 -1
View File
@@ -1,7 +1,9 @@
package mustache package mustache
import "core:fmt" import "core:fmt"
import "core:reflect"
import "core:strings" import "core:strings"
import "core:time"
// MAX_PIPES was chosen arbitrarily. It holds no performance or logical // MAX_PIPES was chosen arbitrarily. It holds no performance or logical
// significance. // significance.
@@ -39,7 +41,11 @@ parse_pipeline :: proc(
filter_count := len(segments) - 1 filter_count := len(segments) - 1
if filter_count > MAX_PIPES { if filter_count > MAX_PIPES {
return "", Syntax_Error { 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 { switch filter.op {
case "group_by": case "group_by":
return apply_group_by(value, filter.args[:]) 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: case:
return nil, Data_Error{msg = fmt.tprintf("unknown pipe op '%s'", filter.op)} 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}}<time datetime="{{.}}">{{. | format}}</time>{{/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. // Groups preserve first-appearance order from the input list.
apply_group_by :: proc(value: any, args: []string) -> (result: any, err: Render_Error) { apply_group_by :: proc(value: any, args: []string) -> (result: any, err: Render_Error) {
if len(args) != 1 { if len(args) != 1 {
@@ -154,3 +200,4 @@ apply_group_by :: proc(value: any, args: []string) -> (result: any, err: Render_
return groups, nil return groups, nil
} }
+164
View File
@@ -204,3 +204,167 @@ test_delete_template_doesnt_leak :: proc(t: ^testing.T) {
defer delete_template(&tmpl) 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}}<time datetime=\"{{.}}\">{{. | format}}</time>{{/date}}",
context.temp_allocator,
)
result, _ := render(tpl, data, {}, context.temp_allocator)
testing.expect_value(t, result, "<time datetime=\"2025-12-25T00:00:00Z\">25 Dec 2025</time>")
}
@(test)
test_format_inside_section_skips_when_empty :: proc(t: ^testing.T) {
data := Format_Data {
date = "",
}
tpl, _ := parse("[{{#date}}<time>{{. | format}}</time>{{/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)
}
}
+10 -22
View File
@@ -11,12 +11,11 @@ import "core:time"
import "core:time/datetime" import "core:time/datetime"
Page_Context :: struct { Page_Context :: struct {
permalink: string, permalink: string,
title: string, title: string,
starred: bool, starred: bool,
date_iso: string, date: string,
date_display: string, year: string,
year: string,
} }
Base_Data :: struct { Base_Data :: struct {
@@ -29,10 +28,9 @@ Base_Data :: struct {
} }
Page_Data :: struct { Page_Data :: struct {
using base: Base_Data, using base: Base_Data,
page_title: string, page_title: string,
date_iso: string, date: string,
date_display: string,
} }
Home_Data :: struct { Home_Data :: struct {
@@ -51,8 +49,7 @@ build_page_context :: proc(page: Page) -> Page_Context {
permalink = page.permalink, permalink = page.permalink,
title = page.title, title = page.title,
starred = page.is_starred, starred = page.is_starred,
date_iso = page.date, date = page.date,
date_display = format_date(page.date),
year = get_year(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.title = fmt.tprintf("%s | %s", page.title, site.title)
data.page_title = page.title data.page_title = page.title
data.body = page.body_html data.body = page.body_html
data.date_iso = page.date data.date = page.date
data.date_display = format_date(page.date)
data.og = og_for_page(site.og, page) data.og = og_for_page(site.og, page)
return render_template(content_tpl, data, partials) return render_template(content_tpl, data, partials)
} }
@@ -360,14 +356,6 @@ load_partials :: proc(vfs: ^VFS) -> map[string]mustache.Template {
return partials 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 { get_year :: proc(iso: string) -> string {
if len(iso) < 4 { if len(iso) < 4 {
return "" return ""