refactor: Eliminated global TZ cache

The configured timezone (or local) is now set on Site directly.
This commit is contained in:
Spencer Brower
2026-07-24 11:32:47 -04:00
parent 4f09312daf
commit 75a7d7a14b
9 changed files with 114 additions and 144 deletions
+9 -6
View File
@@ -222,11 +222,14 @@ Data is passed as **typed structs** (not `map[string]any`). Mustache resolves st
```odin ```odin
Base_Data :: struct { Base_Data :: struct {
now: datetime.DateTime, now: string, // UTC ISO 8601 build timestamp
params: json.Value, params: json.Value,
body: string, body: string,
title: string, title: string,
og: Open_Graph, 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 { Page_Data :: struct {
using base: Base_Data, // fields promoted via reflection fallback 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
<time datetime="{{date}}">{{date | format}}</time> <time datetime="{{date}}">{{date | format}}</time>
``` ```
Currently implemented: `group_by <field>` (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 <field>` (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 ### Comments
+3 -1
View File
@@ -33,11 +33,13 @@
- [x] Accept "strings" - [x] Accept "strings"
- [x] Accept keys - [x] Accept keys
- [x] handle timezones - [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. - [ ] 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" - [ ] Handle 0 and whitespace padding i.e. "_2" -> " 2"
- [ ] Do we *need* mustache.Date_Components, or can we use core:time/datetime.DateTime? - [ ] 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 ## General
- [ ] Integrity hash - [ ] Integrity hash
+20
View File
@@ -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. - 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`). - 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 ### 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.
+21 -24
View File
@@ -240,33 +240,30 @@ format_offset :: proc(offset_seconds: int) -> string {
return fmt.tprintf("UTC%s%02d:%02d", sign, hours, minutes) return fmt.tprintf("UTC%s%02d:%02d", sign, hours, minutes)
} }
// tz_cache caches loaded TZ_Region pointers by timezone name. // resolve_tz looks up the `tz` field from the context stack and returns
// Entries persist until destroy_tz_cache is called. // the ^TZ_Region pointer, or nil if not set.
tz_cache: map[string]^datetime.TZ_Region resolve_tz :: proc(ctx: []any) -> ^datetime.TZ_Region {
raw := resolve_name("timezone", ctx)
get_cached_tz :: proc(name: string) -> (^datetime.TZ_Region, bool) { if raw == nil do return nil
if name == "" || name == "UTC" { switch v in raw {
return nil, true 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() { // compute_utc_offset returns the UTC offset (in seconds) for the given
for _, tz in tz_cache { // timezone at the current time. Returns (0, true) if tz is nil.
if tz != nil { compute_utc_offset :: proc(tz: ^datetime.TZ_Region) -> (offset: int, ok: bool) {
timezone.region_destroy(tz) if tz == nil do return 0, true
} tm := time.now()
} dt_utc := time.time_to_datetime(tm) or_return
delete(tz_cache) dt_local := timezone.datetime_to_tz(dt_utc, tz) or_return
tz_cache = nil 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 // convert_to_tz converts date components from their source timezone to a
+11 -29
View File
@@ -2,6 +2,7 @@
package mustache package mustache
import "core:testing" import "core:testing"
import "core:time/timezone"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// parse_iso_date // 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)
test_convert_to_tz_no_offset_assumes_target :: proc(t: ^testing.T) { test_convert_to_tz_no_offset_assumes_target :: proc(t: ^testing.T) {
tz, tz_ok := get_cached_tz("America/New_York") tz, tz_ok := timezone.region_load("America/New_York", context.temp_allocator)
defer destroy_tz_cache() testing.expect(t, tz_ok, "should load timezone")
if !tz_ok do return if !tz_ok do return
defer timezone.region_destroy(tz, context.temp_allocator)
c := Date_Components{ c := Date_Components{
year = 2026, month = 3, day = 15, year = 2026, month = 3, day = 15,
@@ -274,9 +254,10 @@ test_convert_to_tz_no_offset_assumes_target :: proc(t: ^testing.T) {
@(test) @(test)
test_convert_to_tz_with_offset_converts :: proc(t: ^testing.T) { test_convert_to_tz_with_offset_converts :: proc(t: ^testing.T) {
tz, tz_ok := get_cached_tz("America/New_York") tz, tz_ok := timezone.region_load("America/New_York", context.temp_allocator)
defer destroy_tz_cache() testing.expect(t, tz_ok, "should load timezone")
if !tz_ok do return 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) // 2026-03-15T12:49:54Z (UTC) → 08:49:54 EDT (UTC-4)
c := Date_Components{ c := Date_Components{
@@ -294,9 +275,10 @@ test_convert_to_tz_with_offset_converts :: proc(t: ^testing.T) {
@(test) @(test)
test_convert_to_tz_with_negative_offset_converts :: proc(t: ^testing.T) { test_convert_to_tz_with_negative_offset_converts :: proc(t: ^testing.T) {
tz, tz_ok := get_cached_tz("America/New_York") tz, tz_ok := timezone.region_load("America/New_York", context.temp_allocator)
defer destroy_tz_cache() testing.expect(t, tz_ok, "should load timezone")
if !tz_ok do return 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 // 2026-03-15T08:49:54-04:00 → UTC 12:49:54 → EDT 08:49:54
c := Date_Components{ c := Date_Components{
+6 -22
View File
@@ -5,6 +5,7 @@ import "core:log"
import "core:reflect" import "core:reflect"
import "core:strings" import "core:strings"
import "core:time" import "core:time"
import "core:time/datetime"
// MAX_PIPES was chosen arbitrarily. It holds no performance or logical // MAX_PIPES was chosen arbitrarily. It holds no performance or logical
// significance. // significance.
@@ -228,15 +229,8 @@ apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) ->
date_format = df date_format = df
} }
// Resolve timezone (optional — empty is fine, means display as-is) tz := resolve_tz(ctx)
tz_name := "" str2, err := apply_format(str, filter.args[:], pos, date_format, tz)
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)
if err != nil { if err != nil {
return value, err return value, err
} else { } else {
@@ -257,7 +251,7 @@ apply_format :: proc(
args: []string, args: []string,
pos: int, pos: int,
date_format: string, date_format: string,
timezone_name: string, tz: ^datetime.TZ_Region,
) -> ( ) -> (
result: string, result: string,
err: Error, err: Error,
@@ -279,22 +273,12 @@ apply_format :: proc(
} }
} }
target_tz, tz_ok := get_cached_tz(timezone_name) if tz != nil {
if !tz_ok { components, _ = convert_to_tz(components, tz)
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)
} else if components.has_offset { } else if components.has_offset {
components.tz_abbr = format_offset(components.offset_seconds) 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 return format_date(components, fmt_str), nil
} }
+19 -54
View File
@@ -3,6 +3,8 @@ package mustache
import "core:fmt" import "core:fmt"
import "core:testing" import "core:testing"
import "core:time/datetime"
import "core:time/timezone"
Pipe_Post :: struct { Pipe_Post :: struct {
title: string, title: string,
@@ -209,7 +211,7 @@ test_interp_pipe_basic :: proc(t: ^testing.T) {
Scalar_Data :: struct { Scalar_Data :: struct {
name: string, name: string,
date_format: string, date_format: string,
timezone: string, timezone: ^datetime.TZ_Region,
} }
data := Scalar_Data { data := Scalar_Data {
name = "2026-03-15T08:49:54-04:00", name = "2026-03-15T08:49:54-04:00",
@@ -223,9 +225,9 @@ test_interp_pipe_basic :: proc(t: ^testing.T) {
@(test) @(test)
test_interp_pipe_unescaped :: proc(t: ^testing.T) { test_interp_pipe_unescaped :: proc(t: ^testing.T) {
Scalar_Data :: struct { Scalar_Data :: struct {
name: string, name: string,
date_format: string, date_format: string,
date_timezone: string, timezone: ^datetime.TZ_Region,
} }
data := Scalar_Data { data := Scalar_Data {
name = "2025-12-25T00:00:00Z", name = "2025-12-25T00:00:00Z",
@@ -241,7 +243,7 @@ test_interp_pipe_dot_current :: proc(t: ^testing.T) {
List_Data :: struct { List_Data :: struct {
items: [3]string, items: [3]string,
date_format: string, date_format: string,
timezone: string, timezone: ^datetime.TZ_Region,
} }
data := List_Data { data := List_Data {
items = {"2026-01-06T00:00:00Z", "2026-06-15T00:00:00Z", "2026-10-15T00:00:00Z"}, 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 { Format_Data :: struct {
date: string, date: string,
date_format: string, date_format: string,
timezone: string, timezone: ^datetime.TZ_Region,
} }
@(test) @(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") 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}}]", "<test>", 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}}]", "<test>", allocator = context.temp_allocator)
result, _ := render(tpl, data, {}, context.temp_allocator)
testing.expect_value(t, result, "[]")
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// format pipe with timezone conversion // format pipe with timezone conversion
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@(test) @(test)
test_format_with_timezone_summer :: proc(t: ^testing.T) { test_format_with_timezone_summer :: proc(t: ^testing.T) {
tz, tz_ok := get_cached_tz("America/New_York") tz, ok := timezone.region_load("America/New_York", context.temp_allocator)
defer destroy_tz_cache() testing.expect(t, ok, "should load timezone")
if !tz_ok || tz == nil do return if !ok do return
defer timezone.region_destroy(tz, context.temp_allocator)
data := Format_Data { data := Format_Data {
date = "2026-03-15T12:49:54Z", date = "2026-03-15T12:49:54Z",
date_format = "15:04 MST", date_format = "15:04 MST",
timezone = "America/New_York", timezone = tz,
} }
tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator) tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
result, _ := render(tpl, data, {}, 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)
test_format_with_timezone_winter :: proc(t: ^testing.T) { test_format_with_timezone_winter :: proc(t: ^testing.T) {
tz, tz_ok := get_cached_tz("America/New_York") tz, ok := timezone.region_load("America/New_York", context.temp_allocator)
defer destroy_tz_cache() testing.expect(t, ok, "should load timezone")
if !tz_ok || tz == nil do return if !ok do return
defer timezone.region_destroy(tz, context.temp_allocator)
data := Format_Data { data := Format_Data {
date = "2026-01-15T12:49:54Z", date = "2026-01-15T12:49:54Z",
date_format = "15:04 MST", date_format = "15:04 MST",
timezone = "America/New_York", timezone = tz,
} }
tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator) tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
result, _ := render(tpl, data, {}, 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) result, _ := render(tpl, data, {}, context.temp_allocator)
testing.expect_value(t, result, "UTC") 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}}", "<test>", allocator = context.temp_allocator)
defer delete_template(&tpl)
_, err := render(tpl, data, {}, context.temp_allocator)
testing.expect(t, err != nil, "invalid timezone should error")
}
+7 -7
View File
@@ -25,8 +25,8 @@ Base_Data :: struct {
title: string, title: string,
description: string, description: string,
og: Open_Graph, og: Open_Graph,
date_format: string, date_format: string,
timezone: string, timezone: ^datetime.TZ_Region,
} }
Page_Data :: struct { Page_Data :: struct {
@@ -157,10 +157,10 @@ render_site :: proc(site: ^Site) {
template_cache: map[string]mustache.Template template_cache: map[string]mustache.Template
defer delete(template_cache) defer delete(template_cache)
// TODO: CAlculate offset offset, ok := mustache.compute_utc_offset(site.tz)
offset := 0
now, ok := time.time_to_rfc3339(time.now(), offset, false, allocator)
assert(ok) assert(ok)
now, ok2 := time.time_to_rfc3339(time.now(), offset, false, allocator)
assert(ok2)
// Build base data once // Build base data once
base := Base_Data { base := Base_Data {
@@ -168,8 +168,8 @@ render_site :: proc(site: ^Site) {
params = site.params, params = site.params,
description = site.description, description = site.description,
og = site.og, og = site.og,
date_format = site.date.format, date_format = site.date.format,
timezone = site.date.timezone, timezone = site.tz,
} }
// Find home page // Find home page
+18 -1
View File
@@ -7,6 +7,8 @@ import "core:log"
import "core:mem" import "core:mem"
import "core:os" import "core:os"
import "core:strings" import "core:strings"
import "core:time/datetime"
import "core:time/timezone"
import md "markdown" import md "markdown"
@@ -30,6 +32,7 @@ Site :: struct {
markdown_extensions: bit_set[md.Extension], markdown_extensions: bit_set[md.Extension],
og: Open_Graph, og: Open_Graph,
date: Date_Preferences, date: Date_Preferences,
tz: ^datetime.TZ_Region,
} }
Date_Preferences :: struct { Date_Preferences :: struct {
@@ -115,8 +118,22 @@ init_site :: proc(site: ^Site, args: []string) {
site_apply_cli_flags(site, _flags) site_apply_cli_flags(site, _flags)
site.config_path = path site.config_path = path
// Build the resolved site-level OG now that every other field is set.
site.og = og_for_site(site) 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( load_config_file :: proc(