mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
feat: format pipe can now accept custom formats as quoted strings.
This commit is contained in:
@@ -30,11 +30,12 @@
|
|||||||
- [ ] Decide if lambdas actually provide any value.
|
- [ ] Decide if lambdas actually provide any value.
|
||||||
|
|
||||||
## Dates
|
## Dates
|
||||||
- [ ] Accept "strings"
|
- [x] Accept "strings"
|
||||||
- [ ] Accept keys
|
- [x] Accept keys
|
||||||
- [ ] handle timezones
|
- [ ] handle timezones
|
||||||
- [ ] display an error when no part of the date appears in the output.
|
- [ ] display an error when no part of the date appears in the output.
|
||||||
- [x] use `date.format` as the default format.
|
- [x] use `date.format` as the default format.
|
||||||
|
- [ ] Handle 0 and whitespace padding i.e. "_2" -> " 2"
|
||||||
|
|
||||||
## General
|
## General
|
||||||
- [ ] Integrity hash
|
- [ ] 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.
|
- [ ] 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
|
- [ ] Could be better error message when missing a closing (or opening) brace
|
||||||
- [ ] Error message doesn't show position of faulty pipe name correctly.
|
- [ ] 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
|
```bash
|
||||||
[ERROR] --- [138:render_template()] unknown pipe op 'formats'
|
[ERROR] --- [138:render_template()] unknown pipe op 'formats'
|
||||||
--> /home/spencer/github.com/sbrow.github.io/layouts/home.html:1:1
|
--> /home/spencer/github.com/sbrow.github.io/layouts/home.html:1:1
|
||||||
|
|||||||
@@ -56,13 +56,16 @@ 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)
|
#### `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}}<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.
|
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`.
|
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
|
### Memory ownership
|
||||||
|
|
||||||
|
|||||||
+87
-18
@@ -25,6 +25,51 @@ Group :: struct {
|
|||||||
items: [dynamic]any,
|
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
|
// Returned strings are slices into content — no cloning, lifetime bound to
|
||||||
// the caller's source.
|
// the caller's source.
|
||||||
parse_pipeline :: proc(
|
parse_pipeline :: proc(
|
||||||
@@ -70,7 +115,11 @@ parse_pipeline :: proc(
|
|||||||
return "", Error_Body{msg = "empty filter", pos = pos, kind = .Syntax}
|
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 {
|
if len(tokens) == 0 {
|
||||||
return "", Error_Body{msg = "filter missing op name", pos = pos, kind = .Syntax}
|
return "", Error_Body{msg = "filter missing op name", pos = pos, kind = .Syntax}
|
||||||
}
|
}
|
||||||
@@ -119,6 +168,30 @@ apply_pipeline :: proc(
|
|||||||
return
|
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
|
// TODO: diagnostics don't show anything relevent
|
||||||
apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) -> (any, Error) {
|
apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) -> (any, Error) {
|
||||||
switch filter.op {
|
switch filter.op {
|
||||||
@@ -137,26 +210,22 @@ apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) ->
|
|||||||
date_format: string
|
date_format: string
|
||||||
|
|
||||||
if len(filter.args) > 0 {
|
if len(filter.args) > 0 {
|
||||||
date_format = filter.args[0]
|
arg := filter.args[0]
|
||||||
} else {
|
if len(arg) >= 2 && arg[0] == '"' && arg[len(arg) - 1] == '"' {
|
||||||
date_format_raw := resolve_name("date_format", ctx)
|
date_format = arg[1:len(arg) - 1]
|
||||||
if date_format_raw == nil {
|
|
||||||
return value, Error_Body {
|
|
||||||
msg = "Unable to determine date format",
|
|
||||||
pos = pos,
|
|
||||||
kind = .Data,
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
valid: bool
|
df, ferr := resolve_format_string(arg, ctx, pos)
|
||||||
date_format, valid = reflect.as_string(date_format_raw)
|
if ferr != nil {
|
||||||
if !valid {
|
return value, ferr
|
||||||
return value, Error_Body {
|
|
||||||
msg = "date format is not a string",
|
|
||||||
pos = pos,
|
|
||||||
kind = .Data,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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)
|
str2, err := apply_format(str, filter.args[:], pos, date_format)
|
||||||
|
|||||||
@@ -358,6 +358,72 @@ test_format_inside_section_skips_when_empty :: proc(t: ^testing.T) {
|
|||||||
testing.expect_value(t, result, "[]")
|
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"}}`, "<test>", 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}}", "<test>", 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}}", "<test>", 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}}", "<test>", 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)
|
||||||
test_format_context_date_format_weekday :: proc(t: ^testing.T) {
|
test_format_context_date_format_weekday :: proc(t: ^testing.T) {
|
||||||
data := Format_Data {
|
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}}", "<test>", 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")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user