mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
feat: Added format pipe for dates.
This commit is contained in:
@@ -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 {
|
||||||
@@ -209,8 +209,7 @@ Base_Data :: struct {
|
|||||||
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
@@ -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,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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-16
@@ -14,8 +14,7 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,8 +30,7 @@ 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 ""
|
||||||
|
|||||||
Reference in New Issue
Block a user