Compare commits

..

5 Commits

Author SHA1 Message Date
Spencer Brower 18733f31d1 feat: Errors are now tracked, so duplicate errors don't report more than once. 2026-07-31 10:15:33 -04:00
Spencer Brower a4db4c2c58 chore: Updated TODOS.md. 2026-07-31 09:33:15 -04:00
Spencer Brower 9e93c012b6 feat: help for misspelled pipes is now shown to the user. 2026-07-31 09:25:38 -04:00
Spencer Brower 0d09db8d26 refactor: Filters are now read as an enum. 2026-07-31 09:24:01 -04:00
Spencer Brower 4948eb05a9 chore: Removed old plan file. 2026-07-31 08:57:36 -04:00
7 changed files with 113 additions and 113 deletions
-87
View File
@@ -1,87 +0,0 @@
# Timezone Support
## Goal
Add timezone conversion to the `format` pipe so dates display in the configured site timezone, with correct abbreviations for the `MST` token.
## Context
Dates are stored as raw ISO 8601 strings. The `format` pipe formats them for display using Go-style layout tokens. Currently `parse_iso_date` ignores the timezone offset suffix, and `MST` is hardcoded to `"UTC"`.
Pipes now take `ctx: []any`, so timezone config can flow through the data context identically to `date_format` — no new parameters to thread.
## Decisions
- **MST fallback** (no target tz, date has offset): `UTC-04:00` format
- **`now` field**: intentionally UTC (offset=0). The `format` pipe handles timezone display.
- **TZ_Region cache**: lives in the mustache package (`format.odin`)
## Files changed
| File | Changes |
|---|---|
| `mustache/format.odin` | Extend `Date_Components` (+`offset_seconds`, `has_offset`, `tz_abbr`). Extend `parse_iso_date` to parse trailing offset. Add `tz_cache`, `get_cached_tz`, `convert_to_tz`, `format_offset`, `destroy_tz_cache`. Fix MST token. Import `core:time/timezone`. |
| `mustache/pipes.odin` | Resolve `date_timezone` from ctx in `"format"` case (optional — nil is fine). Add `timezone_name` param to `apply_format`. Orchestrates parse → convert → format. |
| `render.odin` | Add `date_timezone: string` to `Base_Data`, populate from `site.date.timezone`. Remove `// TODO: CAlculate offset` (offset=0 is intentional — UTC). |
| `site.odin` | Call `mustache.destroy_tz_cache()` from `destroy_site`. No structural changes — `Date_Preferences.timezone` already exists and flows through config. |
| `mustache/pipes_test.odin` | Add `date_timezone: string` to test data structs. Add timezone conversion tests. |
## Conversion logic (in `apply_format`)
```
1. parse_iso_date(iso) → components (now includes offset_seconds, has_offset)
2. tz_name := resolve "date_timezone" from ctx (optional)
3. target_tz := get_cached_tz(tz_name) // nil if empty/UTC/not configured
4. if target_tz != nil:
components = convert_to_tz(components, target_tz)
// tz_abbr filled by convert_to_tz via timezone.shortname()
5. else if components.has_offset:
components.tz_abbr = format_offset(components.offset_seconds)
// e.g. "UTC-04:00"
6. else:
components.tz_abbr = "UTC"
7. format_date(components, fmt)
```
## `convert_to_tz` flow
```
1. Build DateTime from components (tz=nil=UTC)
2. If has_offset: add offset_seconds to get true UTC
3. timezone.datetime_to_tz(utc_dt, target_tz) → converted DateTime
4. Extract components from converted DateTime
5. tz_abbr = timezone.shortname(converted_dt) // "EST", "EDT", etc.
```
## MST fallback: `format_offset`
```
0 → "UTC"
-14400 → "UTC-04:00"
+19800 → "UTC+05:30"
```
## `now` field
`now` stays UTC (offset=0). Remove the `// TODO: CAlculate offset` comment — it's correct as-is. Templates format it with `{{now | format}}` and the pipe handles timezone display.
## Behavior matrix
| Config TZ | ISO has offset | Conversion | `MST` output |
|---|---|---|---|
| `"America/New_York"` | yes (`-04:00`) | UTC → NY (DST-aware) | `"EST"`/`"EDT"` |
| `"America/New_York"` | no | assume already in NY | `"EST"`/`"EDT"` |
| not set | yes (`-04:00`) | none — display as-is | `"UTC-04:00"` |
| not set | no | none | `"UTC"` |
## Imports added
- `mustache/format.odin`: `import "core:time/timezone"` (for `region_load`, `datetime_to_tz`, `shortname`, `region_destroy`)
## Odin timezone API reference
- `timezone.region_load(name: string) -> (^datetime.TZ_Region, bool)``"local"` reads `$TZ` env, falls back to `/etc/localtime`
- `timezone.region_destroy(region: ^datetime.TZ_Region)`
- `timezone.datetime_to_tz(dt: DateTime, tz: ^TZ_Region) -> (DateTime, bool)` — DST-aware. If `dt.tz == tz`, no-op. If `dt.tz == nil`, treats as UTC.
- `timezone.shortname(dt: DateTime) -> (string, bool)` — abbreviation from TZ_Region records (e.g. `"EST"`, `"EDT"`)
- `datetime.DateTime :: struct { using date: Date, using time: Time, tz: ^TZ_Region }``tz == nil` means UTC
+7 -5
View File
@@ -2,7 +2,7 @@
- Polish existing features before moving on to new ones.
- [ ] Improve diagnostics
- [ ] keep track of every error and don't report them more than once.
- [x] keep track of every error and don't report them more than once.
- [ ] All Diagnostics should show:
- [ ] *What* went wrong
- [ ] *where* (in the file)
@@ -27,12 +27,14 @@
the correct template source/path. Other render errors still use the
content template's source/path, which can point at the wrong file.
- [ ] try to make file paths clickable links.
- [ ] centralize diagnostics to one place.
- [ ] consider logging the number of times an error occurred.
- [ ] Load grammars dynamically
- [ ] starred must be a param.
- [ ] Documentation
- [ ] talk about the context stack (and its limit).
- [ ] highlight the differences in the way menus are handled.
- [ ] consider sites with data based urls.
- [ ] consider sites with date based urls.
- [ ] Don't show annoying log output in tests.
- [ ] improve home link customization.
- [ ] currently an accessibility issue.
@@ -144,9 +146,9 @@ main :: proc () {
- wrong cwd?
- [ ] Clean up the default layouts
- [ ] Menus
- [ ] Detailed frontmatter menu form ("menu": {"main": {"weight": 5}})
- [x] Detailed frontmatter menu form ("menu": {"main": {"weight": 5}})
- [ ] Menu active state (pre-compute is_active based on page.permalink prefix match)
- [ ] Page.weight field for general-purpose page ordering (menus, lists, related posts)
- [x] Page.weight field for general-purpose page ordering (menus, lists, related posts)
- [ ] if no `html` tag detected in output, re-render output with base template
(or whatever template is next in the chain)
- [ ] Add `-production` flag
@@ -154,7 +156,7 @@ main :: proc () {
- [ ] Mustache diagnostics
- [ ] 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.
- [x] 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
+1 -1
View File
@@ -326,5 +326,5 @@ format_render_error :: proc(err: Error, tmpl: Template, colorize: bool = false)
if path == "" {
path = "<input>"
}
return format_error(path, source, b.pos, b.msg, colorize = colorize, span = b.span)
return format_error(path, source, b.pos, b.msg, hint = b.hint, colorize = colorize, span = b.span)
}
+2
View File
@@ -32,6 +32,7 @@ Error_Body :: struct {
source: string,
path: string,
span: int,
hint: string,
}
// Error is nil when no error occurred.
@@ -59,6 +60,7 @@ tag_error :: proc(err: Error, tmpl: Template) -> Error {
msg = b.msg,
pos = b.pos,
span = b.span,
hint = b.hint,
kind = b.kind,
source = tmpl.source,
path = tmpl.path,
+59 -12
View File
@@ -1,5 +1,6 @@
package mustache
import "base:runtime"
import "core:fmt"
import "core:log"
import "core:reflect"
@@ -16,6 +17,44 @@ MAX_PIPE_ARGS :: 2
DEFAULT_DATE_FORMAT :: "2 Jan 2006"
Pipe_Op :: enum {
Format,
Group_By,
}
// Pipe op names are derived from the enum via reflection (lowercased).
// Adding a new op only requires adding it to the enum AND handling it in
// apply_filter's switch — the compiler enforces exhaustive matching.
pipe_op_from_string :: proc(s: string) -> (Pipe_Op, bool) {
ti := type_info_of(typeid_of(Pipe_Op))
base := runtime.type_info_base(ti)
#partial switch &e in base.variant {
case runtime.Type_Info_Enum:
for name, idx in e.names {
lower := strings.to_lower(name, context.temp_allocator) or_else name
if lower == s {
return cast(Pipe_Op)idx, true
}
}
}
return {}, false
}
pipe_op_candidates :: proc(allocator := context.temp_allocator) -> []string {
ti := type_info_of(typeid_of(Pipe_Op))
base := runtime.type_info_base(ti)
out := make([dynamic]string, 0, 2, allocator)
#partial switch &e in base.variant {
case runtime.Type_Info_Enum:
for name in e.names {
lower := strings.to_lower(name, allocator) or_else name
append(&out, lower)
}
}
return out[:]
}
Pipe_Filter :: struct {
op: string,
args: [dynamic; MAX_PIPE_ARGS]string,
@@ -217,12 +256,27 @@ resolve_format_string :: proc(name: string, ctx: []any, pos: int) -> (string, Er
return str, nil
}
// TODO: diagnostics don't show anything relevant
apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) -> (any, Error) {
switch filter.op {
case "group_by":
op, ok := pipe_op_from_string(filter.op)
if !ok {
hint := ""
suggestion := suggest_correction(pipe_op_candidates(), filter.op)
if suggestion != "" {
hint = fmt.tprintf("did you mean '%s'?", suggestion)
}
return nil, Error_Body {
msg = fmt.tprintf("unknown pipe op '%s'", filter.op),
pos = filter.op_pos,
span = len(filter.op),
kind = .Data,
hint = hint,
}
}
switch op {
case .Group_By:
return apply_group_by(value, filter.args[:], pos)
case "format":
case .Format:
str, ok := reflect.as_string(value)
if !ok {
return value, Error_Body {
@@ -260,15 +314,8 @@ apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) ->
} else {
return any{new_clone(str2, context.temp_allocator), typeid_of(string)}, nil
}
case:
return nil, Error_Body {
msg = fmt.tprintf("unknown pipe op '%s'", filter.op),
pos = filter.op_pos,
span = len(filter.op),
kind = .Data,
}
}
return {}, nil
}
apply_format :: proc(
+22
View File
@@ -2,6 +2,7 @@
package mustache
import "core:fmt"
import "core:strings"
import "core:testing"
import "core:time/datetime"
import "core:time/timezone"
@@ -561,3 +562,24 @@ test_format_mst_no_offset_no_timezone :: proc(t: ^testing.T) {
result, _ := render(tpl, data, {}, context.temp_allocator)
testing.expect_value(t, result, "UTC")
}
// --- pipe op suggestion tests ---
@(test)
test_unknown_pipe_op_suggestion :: proc(t: ^testing.T) {
filter := Pipe_Filter{op = "formats", op_pos = 0}
_, err := apply_filter("2026-01-15", &filter, 0, nil)
testing.expect(t, err != nil, "should error on unknown op")
b := body(err)
testing.expect(t, strings.contains(b.hint, "format"), "hint should suggest 'format'")
testing.expect(t, strings.contains(b.hint, "did you mean"), "hint should be a suggestion")
}
@(test)
test_unknown_pipe_op_no_suggestion :: proc(t: ^testing.T) {
filter := Pipe_Filter{op = "xyz", op_pos = 0}
_, err := apply_filter("2026-01-15", &filter, 0, nil)
testing.expect(t, err != nil, "should error on unknown op")
b := body(err)
testing.expect(t, b.hint == "", "no suggestion expected for 'xyz'")
}
+22 -8
View File
@@ -121,13 +121,20 @@ render_template :: proc(
content_tpl: mustache.Template,
ctx: Template_Context,
partials: map[string]mustache.Template,
reported_errors: ^map[string]bool,
) -> string {
result, err := mustache.render(content_tpl, []any{ctx.site, ctx.page, ctx}, partials)
if err != nil {
log.errorf(
"%s",
mustache.format_render_error(err, content_tpl, colorize = mustache.should_colorize()),
formatted := mustache.format_render_error(
err,
content_tpl,
colorize = mustache.should_colorize(),
)
if formatted in reported_errors^ {
return ""
}
reported_errors[formatted] = true
log.errorf("%s", formatted)
return ""
}
return result
@@ -145,6 +152,8 @@ render_site :: proc(site: ^Site) {
template_cache: map[string]mustache.Template
defer delete(template_cache)
errors := make(map[string]bool, context.temp_allocator)
offset, ok := mustache.compute_utc_offset(site.tz)
assert(ok)
now, ok2 := time.time_to_rfc3339(time.now(), offset, false, allocator)
@@ -186,7 +195,7 @@ render_site :: proc(site: ^Site) {
continue
}
tpl := get_template(&site.vfs, page.layout, &template_cache)
html := render_page_html(page, site, tpl, partials, ctx)
html := render_page_html(page, site, tpl, partials, ctx, &seen)
if .Minify in site.features {
html = minify_html(html)
}
@@ -215,6 +224,7 @@ render_site :: proc(site: ^Site) {
section_tpl,
partials,
ctx,
&seen,
)
if .Minify in site.features {
html = minify_html(html)
@@ -225,7 +235,7 @@ render_site :: proc(site: ^Site) {
// Render home page
if has_home {
home_tpl := get_template(&site.vfs, "home", &template_cache)
home_html := render_home_html(home, site, home_tpl, partials, ctx)
home_html := render_home_html(home, site, home_tpl, partials, ctx, &seen)
if .Minify in site.features {
home_html = minify_html(home_html)
}
@@ -260,12 +270,13 @@ render_page_html :: proc(
content_tpl: mustache.Template,
partials: map[string]mustache.Template,
ctx: Template_Context,
seen: ^map[string]bool,
) -> string {
ctx := ctx
ctx.title = fmt.tprintf("%s | %s", page.title, site.title)
ctx.page = page
ctx.og = og_for_page(site.og, page)
return render_template(content_tpl, ctx, partials)
return render_template(content_tpl, ctx, partials, seen)
}
render_home_html :: proc(
@@ -274,6 +285,7 @@ render_home_html :: proc(
content_tpl: mustache.Template,
partials: map[string]mustache.Template,
ctx: Template_Context,
seen: ^map[string]bool,
) -> string {
list_pages := make([dynamic]Page, 0, 8, context.temp_allocator)
for page in site.pages {
@@ -288,7 +300,7 @@ render_home_html :: proc(
ctx.pages = list_pages
ctx.og = og_for_page(site.og, home)
return render_template(content_tpl, ctx, partials)
return render_template(content_tpl, ctx, partials, seen)
}
render_section :: proc(
@@ -299,6 +311,7 @@ render_section :: proc(
content_tpl: mustache.Template,
partials: map[string]mustache.Template,
ctx: Template_Context,
seen: ^map[string]bool,
) -> string {
alloc := site_allocator(site)
posts := make([dynamic]Page, 0, len(site.pages) / 2, context.temp_allocator)
@@ -327,7 +340,7 @@ render_section :: proc(
ctx.og.is_article = false
}
ctx.posts = posts
return render_template(content_tpl, ctx, partials)
return render_template(content_tpl, ctx, partials, seen)
}
load_partials :: proc(vfs: ^VFS) -> map[string]mustache.Template {
@@ -417,3 +430,4 @@ write_file :: proc(path: string, html: string) {
log.errorf("cannot write %s: %v", path, err)
}
}