diff --git a/TODOS.md b/TODOS.md index 7acfb0e..34f90cc 100644 --- a/TODOS.md +++ b/TODOS.md @@ -30,11 +30,12 @@ - [ ] Decide if lambdas actually provide any value. ## Dates -- [ ] Accept "strings" -- [ ] Accept keys +- [x] Accept "strings" +- [x] Accept keys - [ ] handle timezones - [ ] display an error when no part of the date appears in the output. - [x] use `date.format` as the default format. +- [ ] Handle 0 and whitespace padding i.e. "_2" -> " 2" ## General - [ ] Integrity hash @@ -62,6 +63,12 @@ - [ ] Partial invocation stack in diagnostics: when an error fires inside a partial, show "invoked from" chain through `{{> name}}` calls. Currently warnings inside partials point at the partial (correct file) but don't show the invocation site. - [ ] Could be better error message when missing a closing (or opening) brace - [ ] Error message doesn't show position of faulty pipe name correctly. + - [ ] `render_template` (`render.odin`) blanks the *entire page* to `""` on any + mustache render error and only `log.errorf`s it — a single bad tag/pipe + anywhere on the page silently kills the whole output with no visible + signal outside the terminal log. Should at least be scoped to the + failing tag/section, or surfaced somewhere the person building the + site will actually see it. ```bash [ERROR] --- [138:render_template()] unknown pipe op 'formats' --> /home/spencer/github.com/sbrow.github.io/layouts/home.html:1:1 diff --git a/mustache/EXTENSIONS.md b/mustache/EXTENSIONS.md index 6596210..4b74851 100644 --- a/mustache/EXTENSIONS.md +++ b/mustache/EXTENSIONS.md @@ -56,13 +56,16 @@ 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) +#### `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 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. +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 of `long` (e.g. a site-config field) as the layout. +- No arg: falls back to the `date_format` context key (typically `date.format` from `thor.json`). ### Memory ownership diff --git a/mustache/pipes.odin b/mustache/pipes.odin index a68975c..c3b3d9f 100644 --- a/mustache/pipes.odin +++ b/mustache/pipes.odin @@ -25,6 +25,51 @@ Group :: struct { items: [dynamic]any, } +// is_pipe_space reports whether c is whitespace for the purposes of +// tokenizing a filter segment. +is_pipe_space :: proc(c: u8) -> bool { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' +} + +// tokenize_fields splits seg on whitespace like strings.fields, but a +// double-quoted span (spaces allowed inside) becomes a single token. The +// quote characters are kept in the token (not stripped) so callers can +// distinguish a quoted literal from a bare key name. No escape sequences. +tokenize_fields :: proc(seg: string, pos: int) -> (tokens: [dynamic]string, err: Error) { + i := 0 + for i < len(seg) { + for i < len(seg) && is_pipe_space(seg[i]) { + i += 1 + } + if i >= len(seg) { + break + } + if seg[i] == '"' { + start := i + j := i + 1 + for j < len(seg) && seg[j] != '"' { + j += 1 + } + if j >= len(seg) { + return tokens, Error_Body { + msg = fmt.tprintf("unterminated string literal: %s", seg), + pos = pos, + kind = .Syntax, + } + } + append(&tokens, seg[start:j + 1]) + i = j + 1 + } else { + start := i + for i < len(seg) && !is_pipe_space(seg[i]) { + i += 1 + } + append(&tokens, seg[start:i]) + } + } + return tokens, nil +} + // Returned strings are slices into content — no cloning, lifetime bound to // the caller's source. parse_pipeline :: proc( @@ -70,7 +115,11 @@ parse_pipeline :: proc( return "", Error_Body{msg = "empty filter", pos = pos, kind = .Syntax} } - tokens := strings.fields(seg) + tokens, terr := tokenize_fields(seg, pos) + if terr != nil { + delete(tokens) + return "", terr + } if len(tokens) == 0 { return "", Error_Body{msg = "filter missing op name", pos = pos, kind = .Syntax} } @@ -119,6 +168,30 @@ apply_pipeline :: proc( return } +// resolve_format_string looks up name as a context key and returns its +// string value. Used both for an explicit bare-key filter arg (e.g. +// `format long`) and for the implicit "date_format" fallback when no arg +// is given. +resolve_format_string :: proc(name: string, ctx: []any, pos: int) -> (string, Error) { + raw := resolve_name(name, ctx) + if raw == nil { + return "", Error_Body { + msg = fmt.tprintf("unable to resolve date format key '%s'", name), + pos = pos, + kind = .Data, + } + } + str, ok := reflect.as_string(raw) + if !ok { + return "", Error_Body { + msg = fmt.tprintf("date format key '%s' is not a string", name), + pos = pos, + kind = .Data, + } + } + return str, nil +} + // TODO: diagnostics don't show anything relevent apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) -> (any, Error) { switch filter.op { @@ -137,26 +210,22 @@ apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) -> date_format: string if len(filter.args) > 0 { - date_format = filter.args[0] - } else { - date_format_raw := resolve_name("date_format", ctx) - if date_format_raw == nil { - return value, Error_Body { - msg = "Unable to determine date format", - pos = pos, - kind = .Data, - } + arg := filter.args[0] + if len(arg) >= 2 && arg[0] == '"' && arg[len(arg) - 1] == '"' { + date_format = arg[1:len(arg) - 1] } else { - valid: bool - date_format, valid = reflect.as_string(date_format_raw) - if !valid { - return value, Error_Body { - msg = "date format is not a string", - pos = pos, - kind = .Data, - } + df, ferr := resolve_format_string(arg, ctx, pos) + if ferr != nil { + return value, ferr } + date_format = df } + } else { + df, ferr := resolve_format_string("date_format", ctx, pos) + if ferr != nil { + return value, ferr + } + date_format = df } str2, err := apply_format(str, filter.args[:], pos, date_format) diff --git a/mustache/pipes_test.odin b/mustache/pipes_test.odin index 5caf1ef..149c269 100644 --- a/mustache/pipes_test.odin +++ b/mustache/pipes_test.odin @@ -358,6 +358,72 @@ test_format_inside_section_skips_when_empty :: proc(t: ^testing.T) { testing.expect_value(t, result, "[]") } +// --------------------------------------------------------------------------- +// format filter args: quoted literal strings and bare context keys +// --------------------------------------------------------------------------- + +@(test) +test_format_quoted_literal_arg :: proc(t: ^testing.T) { + data := Format_Data { + date = "2026-03-15T08:49:54-04:00", + date_format = "2 Jan 2006", + } + tpl, _ := parse(`{{date | format "Jan 2, 2006"}}`, "", allocator = context.temp_allocator) + result, _ := render(tpl, data, {}, context.temp_allocator) + testing.expect_value(t, result, "Mar 15, 2026") +} + +Key_Format_Data :: struct { + date: string, + long: string, +} + +@(test) +test_format_bare_key_arg_resolves_from_context :: proc(t: ^testing.T) { + data := Key_Format_Data { + date = "2026-03-15T08:49:54-04:00", + long = "2 January 2006", + } + tpl, _ := parse("{{date | format long}}", "", allocator = context.temp_allocator) + result, _ := render(tpl, data, {}, context.temp_allocator) + testing.expect_value(t, result, "15 March 2026") +} + +@(test) +test_format_bare_key_arg_missing_errors :: proc(t: ^testing.T) { + data := Key_Format_Data { + date = "2026-03-15T08:49:54-04:00", + long = "2 January 2006", + } + tpl, _ := parse("{{date | format missing}}", "", allocator = context.temp_allocator) + defer delete_template(&tpl) + _, err := render(tpl, data, {}, context.temp_allocator) + testing.expect(t, err != nil, "unresolved format key should error") +} + +@(test) +test_format_bare_key_arg_non_string_errors :: proc(t: ^testing.T) { + Int_Key_Data :: struct { + date: string, + count: int, + } + data := Int_Key_Data { + date = "2026-03-15T08:49:54-04:00", + count = 42, + } + tpl, _ := parse("{{date | format count}}", "", allocator = context.temp_allocator) + defer delete_template(&tpl) + _, err := render(tpl, data, {}, context.temp_allocator) + testing.expect(t, err != nil, "non-string format key should error") +} + +@(test) +test_format_unterminated_quote_arg_is_parse_error :: proc(t: ^testing.T) { + src := `{{date | format "Jan 2, 2006}}` + _, err := parse(src) + testing.expect(t, err != nil, "unterminated string literal should fail to parse") +} + @(test) test_format_context_date_format_weekday :: proc(t: ^testing.T) { data := Format_Data { @@ -402,3 +468,18 @@ test_format_handles_all_iso8601_variants :: proc(t: ^testing.T) { } } + +@(test) +test_format_bare_numeric_arg_treated_as_key_not_literal :: proc(t: ^testing.T) { + // "2006" happens to also be a valid Go layout token — make sure an + // unquoted arg is still resolved as a context key (and fails, since + // no field is named "2006"), not silently used as the literal layout. + data := Format_Data { + date = "2026-03-15T08:49:54-04:00", + date_format = "2 Jan 2006", + } + tpl, _ := parse("{{date | format 2006}}", "", allocator = context.temp_allocator) + defer delete_template(&tpl) + _, err := render(tpl, data, {}, context.temp_allocator) + testing.expect(t, err != nil, "bare numeric-looking arg should error as an unresolved key") +}