From 75a7d7a14b59566236f81e4734da034da4e50bd8 Mon Sep 17 00:00:00 2001 From: Spencer Brower <6729162+sbrow@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:32:47 -0400 Subject: [PATCH] refactor: Eliminated global TZ cache The configured timezone (or local) is now set on Site directly. --- AGENTS.md | 15 ++++---- TODOS.md | 4 ++- mustache/EXTENSIONS.md | 20 +++++++++++ mustache/format.odin | 45 +++++++++++------------- mustache/format_test.odin | 40 ++++++--------------- mustache/pipes.odin | 28 ++++----------- mustache/pipes_test.odin | 73 ++++++++++----------------------------- render.odin | 14 ++++---- site.odin | 19 +++++++++- 9 files changed, 114 insertions(+), 144 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b852169..dcae28a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -222,11 +222,14 @@ Data is passed as **typed structs** (not `map[string]any`). Mustache resolves st ```odin Base_Data :: struct { - now: datetime.DateTime, - params: json.Value, - body: string, - title: string, - og: Open_Graph, + now: string, // UTC ISO 8601 build timestamp + params: json.Value, + body: string, + title: string, + description: string, + og: Open_Graph, + date_format: string, // from site.date.format (thor.json) + timezone: ^datetime.TZ_Region, // loaded from site.date.timezone or local, owned by Site } Page_Data :: struct { using base: Base_Data, // fields promoted via reflection fallback @@ -259,7 +262,7 @@ Section tags and interpolation tags may transform the resolved value before rend ``` -Currently implemented: `group_by ` (list → list-of-groups) and `format` (ISO date string → display string like "15 Mar 2026"). 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 ` (list → list-of-groups) and `format` (ISO date string → display string like "15 Mar 2026"). The `format` pipe resolves `date_format` (string) and `timezone` (`^datetime.TZ_Region`) from the data context. When `timezone` is non-nil, dates are DST-aware converted before formatting. The `MST` token reflects the active timezone abbreviation (e.g. `"EST"`/`"EDT"`) or the source offset (e.g. `"UTC-04:00"`) when no target tz is configured. TZ data is loaded once by `init_site` via `timezone.region_load` using the site arena allocator, stored on `Site.tz`, and freed when the arena is destroyed. 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 diff --git a/TODOS.md b/TODOS.md index ba5ed2a..889f30a 100644 --- a/TODOS.md +++ b/TODOS.md @@ -33,11 +33,13 @@ - [x] Accept "strings" - [x] Accept keys - [x] handle timezones -- [ ] Fix TZ cache leak in parallel tests: `tz_cache` uses `context.allocator` (tracking allocator in tests), but it's global state shared across parallel test threads. Race causes leak warnings. Fix: use heap allocator for TZ cache. - [ ] 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" - [ ] Do we *need* mustache.Date_Components, or can we use core:time/datetime.DateTime? +- [ ] show a proper diagnostic for timezones + - currently "unable to load timezone 'America/New_Yorkskie'" + - want rust style diagnostic and better message, maybe "unknown timezone 'America/New_Yorkskie'" ## General - [ ] Integrity hash diff --git a/mustache/EXTENSIONS.md b/mustache/EXTENSIONS.md index 4b74851..1c911b8 100644 --- a/mustache/EXTENSIONS.md +++ b/mustache/EXTENSIONS.md @@ -67,6 +67,26 @@ Takes an optional arg for the Go reference-date layout to use: - 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`). +#### Timezone conversion + +When the `timezone` context key is set (typically `date.timezone` from `thor.json`, an IANA name like `"America/New_York"`), the `format` pipe converts dates to that timezone before formatting: + +- **Date has offset + target tz**: adjusts to true UTC, then converts to the target timezone (DST-aware). +- **Date has no offset + target tz**: assumes the date is already in the target timezone — no conversion, only resolves the abbreviation. +- **Date has offset + no target tz**: displays in the source offset. +- **Date has no offset + no target tz**: displays as-is, assumes UTC. + +The `MST` token reflects the active timezone: + +| Config tz | ISO has offset | `MST` output | +|---|---|---| +| `"America/New_York"` | yes | `"EST"` or `"EDT"` (DST-aware) | +| `"America/New_York"` | no | `"EST"` or `"EDT"` | +| not set | yes | `"UTC-04:00"` (from source offset) | +| not set | no | `"UTC"` | + +Timezone data is loaded once by `init_site` via `core:time/timezone.region_load`, using the site arena allocator. The resolved `^datetime.TZ_Region` pointer is stored on `Site.tz` and passed to templates through `Base_Data.timezone`. The arena frees it automatically on `destroy_site`. + ### 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. diff --git a/mustache/format.odin b/mustache/format.odin index d120da7..3c16cd2 100644 --- a/mustache/format.odin +++ b/mustache/format.odin @@ -240,33 +240,30 @@ format_offset :: proc(offset_seconds: int) -> string { return fmt.tprintf("UTC%s%02d:%02d", sign, hours, minutes) } -// tz_cache caches loaded TZ_Region pointers by timezone name. -// Entries persist until destroy_tz_cache is called. -tz_cache: map[string]^datetime.TZ_Region - -get_cached_tz :: proc(name: string) -> (^datetime.TZ_Region, bool) { - if name == "" || name == "UTC" { - return nil, true +// resolve_tz looks up the `tz` field from the context stack and returns +// the ^TZ_Region pointer, or nil if not set. +resolve_tz :: proc(ctx: []any) -> ^datetime.TZ_Region { + raw := resolve_name("timezone", ctx) + if raw == nil do return nil + switch v in raw { + case ^datetime.TZ_Region: + return v + case: + return nil } - if cached, ok := tz_cache[name]; ok { - return cached, true - } - tz, ok := timezone.region_load(name) - if !ok { - return nil, false - } - tz_cache[name] = tz - return tz, true } -destroy_tz_cache :: proc() { - for _, tz in tz_cache { - if tz != nil { - timezone.region_destroy(tz) - } - } - delete(tz_cache) - tz_cache = nil +// compute_utc_offset returns the UTC offset (in seconds) for the given +// timezone at the current time. Returns (0, true) if tz is nil. +compute_utc_offset :: proc(tz: ^datetime.TZ_Region) -> (offset: int, ok: bool) { + if tz == nil do return 0, true + tm := time.now() + dt_utc := time.time_to_datetime(tm) or_return + dt_local := timezone.datetime_to_tz(dt_utc, tz) or_return + tm_utc := time.datetime_to_time(dt_utc) or_return + tm_local := time.datetime_to_time(dt_local) or_return + offset = int(time.time_to_unix(tm_local) - time.time_to_unix(tm_utc)) + return offset, true } // convert_to_tz converts date components from their source timezone to a diff --git a/mustache/format_test.odin b/mustache/format_test.odin index 3695ee5..7d10298 100644 --- a/mustache/format_test.odin +++ b/mustache/format_test.odin @@ -2,6 +2,7 @@ package mustache import "core:testing" +import "core:time/timezone" // --------------------------------------------------------------------------- // parse_iso_date @@ -230,36 +231,15 @@ test_format_offset_positive :: proc(t: ^testing.T) { } // --------------------------------------------------------------------------- -// get_cached_tz / convert_to_tz (require system zoneinfo) +// convert_to_tz (require system zoneinfo) // --------------------------------------------------------------------------- -@(test) -test_get_cached_tz_utc_returns_nil :: proc(t: ^testing.T) { - tz, ok := get_cached_tz("UTC") - testing.expect_value(t, ok, true) - testing.expect_value(t, tz == nil, true) -} - -@(test) -test_get_cached_tz_empty_returns_nil :: proc(t: ^testing.T) { - tz, ok := get_cached_tz("") - testing.expect_value(t, ok, true) - testing.expect_value(t, tz == nil, true) -} - -@(test) -test_get_cached_tz_loads_named_region :: proc(t: ^testing.T) { - tz, ok := get_cached_tz("America/New_York") - defer destroy_tz_cache() - if !ok do return - testing.expect(t, tz != nil, "should load TZ_Region for America/New_York") -} - @(test) test_convert_to_tz_no_offset_assumes_target :: proc(t: ^testing.T) { - tz, tz_ok := get_cached_tz("America/New_York") - defer destroy_tz_cache() + tz, tz_ok := timezone.region_load("America/New_York", context.temp_allocator) + testing.expect(t, tz_ok, "should load timezone") if !tz_ok do return + defer timezone.region_destroy(tz, context.temp_allocator) c := Date_Components{ year = 2026, month = 3, day = 15, @@ -274,9 +254,10 @@ test_convert_to_tz_no_offset_assumes_target :: proc(t: ^testing.T) { @(test) test_convert_to_tz_with_offset_converts :: proc(t: ^testing.T) { - tz, tz_ok := get_cached_tz("America/New_York") - defer destroy_tz_cache() + tz, tz_ok := timezone.region_load("America/New_York", context.temp_allocator) + testing.expect(t, tz_ok, "should load timezone") if !tz_ok do return + defer timezone.region_destroy(tz, context.temp_allocator) // 2026-03-15T12:49:54Z (UTC) → 08:49:54 EDT (UTC-4) c := Date_Components{ @@ -294,9 +275,10 @@ test_convert_to_tz_with_offset_converts :: proc(t: ^testing.T) { @(test) test_convert_to_tz_with_negative_offset_converts :: proc(t: ^testing.T) { - tz, tz_ok := get_cached_tz("America/New_York") - defer destroy_tz_cache() + tz, tz_ok := timezone.region_load("America/New_York", context.temp_allocator) + testing.expect(t, tz_ok, "should load timezone") if !tz_ok do return + defer timezone.region_destroy(tz, context.temp_allocator) // 2026-03-15T08:49:54-04:00 → UTC 12:49:54 → EDT 08:49:54 c := Date_Components{ diff --git a/mustache/pipes.odin b/mustache/pipes.odin index 9d63e28..d81d922 100644 --- a/mustache/pipes.odin +++ b/mustache/pipes.odin @@ -5,6 +5,7 @@ import "core:log" import "core:reflect" import "core:strings" import "core:time" +import "core:time/datetime" // MAX_PIPES was chosen arbitrarily. It holds no performance or logical // significance. @@ -228,15 +229,8 @@ apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) -> date_format = df } - // Resolve timezone (optional — empty is fine, means display as-is) - tz_name := "" - if raw := resolve_name("timezone", ctx); raw != nil { - if s, s_ok := reflect.as_string(raw); s_ok { - tz_name = s - } - } - - str2, err := apply_format(str, filter.args[:], pos, date_format, tz_name) + tz := resolve_tz(ctx) + str2, err := apply_format(str, filter.args[:], pos, date_format, tz) if err != nil { return value, err } else { @@ -257,7 +251,7 @@ apply_format :: proc( args: []string, pos: int, date_format: string, - timezone_name: string, + tz: ^datetime.TZ_Region, ) -> ( result: string, err: Error, @@ -279,22 +273,12 @@ apply_format :: proc( } } - target_tz, tz_ok := get_cached_tz(timezone_name) - if !tz_ok { - return "", Error_Body { - msg = fmt.tprintf("unable to load timezone '%s'", timezone_name), - pos = pos, - kind = .Data, - } - } - - if target_tz != nil { - components, _ = convert_to_tz(components, target_tz) + if tz != nil { + components, _ = convert_to_tz(components, tz) } else if components.has_offset { components.tz_abbr = format_offset(components.offset_seconds) } - log.debugf("date: '%s' format: '%s' tz: '%s'", iso, date_format, timezone_name) return format_date(components, fmt_str), nil } diff --git a/mustache/pipes_test.odin b/mustache/pipes_test.odin index 0a29d9b..54c193f 100644 --- a/mustache/pipes_test.odin +++ b/mustache/pipes_test.odin @@ -3,6 +3,8 @@ package mustache import "core:fmt" import "core:testing" +import "core:time/datetime" +import "core:time/timezone" Pipe_Post :: struct { title: string, @@ -209,7 +211,7 @@ test_interp_pipe_basic :: proc(t: ^testing.T) { Scalar_Data :: struct { name: string, date_format: string, - timezone: string, + timezone: ^datetime.TZ_Region, } data := Scalar_Data { name = "2026-03-15T08:49:54-04:00", @@ -223,9 +225,9 @@ test_interp_pipe_basic :: proc(t: ^testing.T) { @(test) test_interp_pipe_unescaped :: proc(t: ^testing.T) { Scalar_Data :: struct { - name: string, - date_format: string, - date_timezone: string, + name: string, + date_format: string, + timezone: ^datetime.TZ_Region, } data := Scalar_Data { name = "2025-12-25T00:00:00Z", @@ -241,7 +243,7 @@ test_interp_pipe_dot_current :: proc(t: ^testing.T) { List_Data :: struct { items: [3]string, date_format: string, - timezone: string, + timezone: ^datetime.TZ_Region, } data := List_Data { items = {"2026-01-06T00:00:00Z", "2026-06-15T00:00:00Z", "2026-10-15T00:00:00Z"}, @@ -257,9 +259,9 @@ test_interp_pipe_dot_current :: proc(t: ^testing.T) { // --------------------------------------------------------------------------- Format_Data :: struct { - date: string, + date: string, date_format: string, - timezone: string, + timezone: ^datetime.TZ_Region, } @(test) @@ -488,44 +490,21 @@ test_format_bare_numeric_arg_treated_as_key_not_literal :: proc(t: ^testing.T) { testing.expect(t, err != nil, "bare numeric-looking arg should error as an unresolved key") } -@(test) -test_timezone_resolves_from_context :: proc(t: ^testing.T) { - TZ_Data :: struct { - timezone: string, - } - data := TZ_Data { - timezone = "America/New_York", - } - tpl, _ := parse("[{{timezone}}]", "", allocator = context.temp_allocator) - result, _ := render(tpl, data, {}, context.temp_allocator) - testing.expect_value(t, result, "[America/New_York]") -} - -@(test) -test_timezone_empty_when_not_set :: proc(t: ^testing.T) { - TZ_Data :: struct { - timezone: string, - } - data := TZ_Data {} - tpl, _ := parse("[{{timezone}}]", "", allocator = context.temp_allocator) - result, _ := render(tpl, data, {}, context.temp_allocator) - testing.expect_value(t, result, "[]") -} - // --------------------------------------------------------------------------- // format pipe with timezone conversion // --------------------------------------------------------------------------- @(test) test_format_with_timezone_summer :: proc(t: ^testing.T) { - tz, tz_ok := get_cached_tz("America/New_York") - defer destroy_tz_cache() - if !tz_ok || tz == nil do return + tz, ok := timezone.region_load("America/New_York", context.temp_allocator) + testing.expect(t, ok, "should load timezone") + if !ok do return + defer timezone.region_destroy(tz, context.temp_allocator) data := Format_Data { date = "2026-03-15T12:49:54Z", date_format = "15:04 MST", - timezone = "America/New_York", + timezone = tz, } tpl, _ := parse("{{date | format}}", "", allocator = context.temp_allocator) result, _ := render(tpl, data, {}, context.temp_allocator) @@ -534,14 +513,15 @@ test_format_with_timezone_summer :: proc(t: ^testing.T) { @(test) test_format_with_timezone_winter :: proc(t: ^testing.T) { - tz, tz_ok := get_cached_tz("America/New_York") - defer destroy_tz_cache() - if !tz_ok || tz == nil do return + tz, ok := timezone.region_load("America/New_York", context.temp_allocator) + testing.expect(t, ok, "should load timezone") + if !ok do return + defer timezone.region_destroy(tz, context.temp_allocator) data := Format_Data { date = "2026-01-15T12:49:54Z", date_format = "15:04 MST", - timezone = "America/New_York", + timezone = tz, } tpl, _ := parse("{{date | format}}", "", allocator = context.temp_allocator) result, _ := render(tpl, data, {}, context.temp_allocator) @@ -569,18 +549,3 @@ test_format_mst_no_offset_no_timezone :: proc(t: ^testing.T) { result, _ := render(tpl, data, {}, context.temp_allocator) testing.expect_value(t, result, "UTC") } - -@(test) -test_format_invalid_timezone_errors :: proc(t: ^testing.T) { - defer destroy_tz_cache() - - data := Format_Data { - date = "2026-03-15", - date_format = "2 Jan 2006", - timezone = "Invalid/Zone", - } - tpl, _ := parse("{{date | format}}", "", allocator = context.temp_allocator) - defer delete_template(&tpl) - _, err := render(tpl, data, {}, context.temp_allocator) - testing.expect(t, err != nil, "invalid timezone should error") -} diff --git a/render.odin b/render.odin index 0c012a5..a380793 100644 --- a/render.odin +++ b/render.odin @@ -25,8 +25,8 @@ Base_Data :: struct { title: string, description: string, og: Open_Graph, - date_format: string, - timezone: string, + date_format: string, + timezone: ^datetime.TZ_Region, } Page_Data :: struct { @@ -157,10 +157,10 @@ render_site :: proc(site: ^Site) { template_cache: map[string]mustache.Template defer delete(template_cache) - // TODO: CAlculate offset - offset := 0 - now, ok := time.time_to_rfc3339(time.now(), offset, false, allocator) + offset, ok := mustache.compute_utc_offset(site.tz) assert(ok) + now, ok2 := time.time_to_rfc3339(time.now(), offset, false, allocator) + assert(ok2) // Build base data once base := Base_Data { @@ -168,8 +168,8 @@ render_site :: proc(site: ^Site) { params = site.params, description = site.description, og = site.og, - date_format = site.date.format, - timezone = site.date.timezone, + date_format = site.date.format, + timezone = site.tz, } // Find home page diff --git a/site.odin b/site.odin index 0299d98..735ac70 100644 --- a/site.odin +++ b/site.odin @@ -7,6 +7,8 @@ import "core:log" import "core:mem" import "core:os" import "core:strings" +import "core:time/datetime" +import "core:time/timezone" import md "markdown" @@ -30,6 +32,7 @@ Site :: struct { markdown_extensions: bit_set[md.Extension], og: Open_Graph, date: Date_Preferences, + tz: ^datetime.TZ_Region, } Date_Preferences :: struct { @@ -115,8 +118,22 @@ init_site :: proc(site: ^Site, args: []string) { site_apply_cli_flags(site, _flags) site.config_path = path - // Build the resolved site-level OG now that every other field is set. site.og = og_for_site(site) + + tz_name := site.date.timezone + if tz_name == "" { + log.warnf("no timezone configured, falling back to local system timezone") + tz_name = "local" + } + tz, tz_ok := timezone.region_load(tz_name, alloc) + if tz_ok { + site.tz = tz + if site.date.timezone == "" { + log.debugf("detected local timezone: %s", tz.name) + } + } else if site.date.timezone != "" { + log.warnf("unable to load timezone '%s'", site.date.timezone) + } } load_config_file :: proc(