diff --git a/TODOS.md b/TODOS.md
index 05a9f87..7acfb0e 100644
--- a/TODOS.md
+++ b/TODOS.md
@@ -13,6 +13,14 @@
- [ ] Not sure whether to use temp allocator or site_allocator in opengraph.odin.
- [ ] Not sure whether to use temp allocator or site_allocator in `site_load_content`.
- [ ] Might not need to allocate in `strip_html_tags`
+- [ ] Fix `apply_filter`'s `format` case (`mustache/pipes.odin`) boxing `apply_format`'s
+ `string` result into `any` via bare `return`, which materializes a hidden
+ header temp in `apply_filter`'s own stack frame. Dangling once the frame
+ returns; caused the `-o:speed` segfault in `write_value`. Fix: box explicitly
+ with `any{new_clone(formatted, context.temp_allocator), typeid_of(string)}`.
+- [ ] Same pattern in `apply_group_by` (`mustache/pipes.odin`): `return groups, nil`
+ boxes a freshly-built `[dynamic]Group` as bare `any` — same latent
+ stack-temp UB, hasn't crashed yet but should get the same treatment.
## Markdown
- [ ] Add overloads for every extension - accept ^strings.Builder.
@@ -20,13 +28,20 @@
- [ ] Add heading ids as a default on extension.
- [ ] Add opt-in deflist support.
- [ ] Decide if lambdas actually provide any value.
-- [ ] configure date format as a partial
+
+## Dates
+- [ ] Accept "strings"
+- [ ] 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.
## General
- [ ] Integrity hash
- Allows users to verify their output didn't change after upgrading to a new version
- [ ] Content-hash fingerprinting for CSS and JS cache busting
- [ ] Avoid `json.Value` / `json.Object` where possible.
+- [ ] Create a json schema file for `thor.json`.
- [ ] make `parse` an overload of `parse_text/parse_inline` and `parse_file`, or something.
- [ ] Add page params
- [ ] We must remove all mention of `posts` from the odin code.
@@ -46,6 +61,16 @@
- [x] Block-override source-template tracking: warnings inside overrides point at the override's source file, not the parent template
- [ ] 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.
+ ```bash
+ [ERROR] --- [138:render_template()] unknown pipe op 'formats'
+ --> /home/spencer/github.com/sbrow.github.io/layouts/home.html:1:1
+ |
+1 | {{
+```
- [ ] Block attributes on code fences (`{ #ex-1 }`) — hello-world.md
- [ ] include-code shortcode (`{{< include-code ... >}}`) — i-ported-fd-to-odin
- [ ] follow symlinks in `scan_content`?
diff --git a/bench/date/check.odin b/bench/date/check.odin
new file mode 100644
index 0000000..8e825e9
--- /dev/null
+++ b/bench/date/check.odin
@@ -0,0 +1,130 @@
+package main
+
+import "base:runtime"
+import "common"
+import "core:fmt"
+import "core:log"
+import "core:os"
+import "core:strings"
+import "core:time"
+import "inlined"
+import "original"
+
+DATES :: #load(#directory + os.Path_Separator_String + "dates.txt")
+FORMATS :: #load(#directory + os.Path_Separator_String + "formats.txt")
+
+ITERATIONS :: 1_000
+
+dates: []common.Date_Components
+formats: []string
+
+formatter :: #type proc(
+ date: common.Date_Components,
+ format: string,
+ allocator: runtime.Allocator,
+) -> string
+
+benchmark :: #type proc(
+ options: ^time.Benchmark_Options,
+ allocator: runtime.Allocator,
+) -> (
+ err: time.Benchmark_Error,
+)
+
+Version :: struct {
+ name: string,
+ bench: benchmark,
+}
+
+init :: proc() {
+ raw_dates := strings.split_lines(string(DATES))
+ raw_dates = raw_dates[:len(raw_dates) - 1]
+ formats = strings.split_lines(string(FORMATS))
+
+ dates = make([]common.Date_Components, len(raw_dates))
+
+ assert(to_parsed(raw_dates, &dates))
+}
+
+to_parsed :: proc(raw_dates: []string, dates: ^[]common.Date_Components) -> bool {
+ for date, i in raw_dates {
+ // log.debugf("parsing '%s'", date)
+ dates[i] = common.parse_iso_date(date) or_return
+ }
+
+ return true
+}
+
+main :: proc() {
+ logger_opts: log.Options =
+ (log.Default_Console_Logger_Opts - log.Full_Timestamp_Opts - {.Short_File_Path})
+ console_logger := log.create_console_logger(.Info, logger_opts)
+ context.logger = console_logger
+ defer log.destroy_console_logger(console_logger)
+
+ init()
+
+ versions := [?]Version {
+ {"original", to_benchmark(original.format_date)},
+ {"inlined", to_benchmark(inlined.format_date)},
+ }
+
+ fmt.printfln(
+ "%-10s %14s %10s %14s %12s %8s",
+ "version",
+ "total",
+ "calls",
+ "calls/s",
+ "time/call",
+ "MB/s",
+ )
+
+ for version in versions {
+ defer free_all(context.temp_allocator)
+
+ b: time.Benchmark_Options
+ b.bench = version.bench
+
+ if err := time.benchmark(&b, context.temp_allocator); err != nil {
+ fmt.panicf("%v", err)
+ }
+
+ // fmt.printfln("%v", b)
+ per_call := time.Duration(i64(b.duration) / i64(b.count))
+ fmt.printfln(
+ "%-10s %14v % 10d % 14.0f %12v % 8.2f",
+ version.name,
+ b.duration,
+ b.count,
+ b.rounds_per_second,
+ per_call,
+ b.megabytes_per_second,
+ )
+ }
+}
+
+to_benchmark :: proc($f: formatter) -> benchmark {
+ return(
+ proc(
+ opts: ^time.Benchmark_Options,
+ allocator: runtime.Allocator,
+ ) -> (
+ err: time.Benchmark_Error,
+ ) {
+ for _ in 0 ..< ITERATIONS {
+ for date in dates {
+ for format in formats {
+ f(date, format, allocator)
+ opts.count += 1
+ opts.processed += size_of(date) + size_of(format)
+ }
+ }
+
+ opts.rounds += 1
+ }
+
+ return err
+ } \
+ )
+}
+
diff --git a/bench/date/common/types.odin b/bench/date/common/types.odin
new file mode 100644
index 0000000..46abd1f
--- /dev/null
+++ b/bench/date/common/types.odin
@@ -0,0 +1,44 @@
+package common
+
+Date_Components :: struct {
+ year: int,
+ month: int,
+ day: int,
+ hour: int,
+ minute: int,
+ second: int,
+}
+
+// TODO: Use some kind of scanner interface
+parse_iso_date :: proc(iso: string) -> (c: Date_Components, ok: bool) {
+ if len(iso) < 10 {
+ return {}, false
+ }
+
+ c.year = parse_2_digits(iso, 0) * 100 + parse_2_digits(iso, 2)
+ c.month = parse_2_digits(iso, 5)
+ c.day = parse_2_digits(iso, 8)
+
+ if c.month < 1 || c.month > 12 {
+ return {}, false
+ }
+ if c.day < 1 || c.day > 31 {
+ return {}, false
+ }
+
+ if len(iso) >= 19 && (iso[10] == 'T' || iso[10] == 't') {
+ c.hour = parse_2_digits(iso, 11)
+ c.minute = parse_2_digits(iso, 14)
+ c.second = parse_2_digits(iso, 17)
+ }
+
+ return c, true
+}
+
+parse_2_digits :: proc(s: string, offset: int) -> int {
+ if offset + 1 >= len(s) {
+ return 0
+ }
+ return (int(s[offset]) - 0x30) * 10 + (int(s[offset + 1]) - 0x30)
+}
+
diff --git a/bench/date/dates.txt b/bench/date/dates.txt
new file mode 100644
index 0000000..2c110bb
--- /dev/null
+++ b/bench/date/dates.txt
@@ -0,0 +1,59 @@
+2024-01-05
+2024-02-14
+2024-03-08
+2024-04-22
+2024-05-01
+2024-06-19
+2024-07-04
+2024-08-30
+2024-09-11
+2024-10-31
+2024-11-27
+2024-12-25
+2023-01-15T00:00:00-05:00
+2023-02-14T09:30:15+01:00
+2023-03-08T14:45:22-08:00
+2023-04-22T23:59:59+05:30
+2023-05-01T06:05:09-04:00
+2023-06-19T18:20:33+09:00
+2023-07-04T03:04:05-07:00
+2023-08-30T11:11:11+00:00
+2023-09-11T15:04:05-04:00
+2023-10-31T20:15:30+02:00
+2023-11-27T07:07:07-06:00
+2023-12-25T12:30:45+03:00
+2025-01-15T00:15:00-0500
+2025-02-14T09:30:15+0100
+2025-03-08T14:45:22-0800
+2025-04-22T23:59:59+0530
+2025-05-01T06:05:09-0400
+2025-06-19T18:20:33+0900
+2025-07-04T03:04:05-0700
+2025-08-30T11:11:11+0000
+2025-09-11T15:04:05-0400
+2025-10-31T20:15:30+0200
+2025-11-27T07:07:07-0600
+2025-12-25T12:30:45+0300
+2022-01-01T00:00:00Z
+2022-02-14T01:30:00Z
+2022-03-08T11:59:59Z
+2022-04-22T12:00:00Z
+2022-05-01T12:00:01Z
+2022-06-19T13:15:00Z
+2022-07-04T15:04:05Z
+2022-08-30T18:45:30Z
+2022-09-11T20:20:20Z
+2022-10-31T22:10:10Z
+2022-11-27T23:59:59Z
+2022-12-25T09:09:09Z
+2021-03-14t09:26:53
+2021-07-20t23:00:00
+2021-11-05t00:00:00
+2020-06-15T15:04:05
+2020-12-31T23:59:59
+2020-01-01T00:00:00
+2024-02-29T12:00:00Z
+2000-02-29T00:00:00Z
+1999-12-31T23:59:59Z
+2026-01-01T00:00:00Z
+2026-07-23T14:56:07-04:00
diff --git a/bench/date/formats.txt b/bench/date/formats.txt
new file mode 100644
index 0000000..e351b03
--- /dev/null
+++ b/bench/date/formats.txt
@@ -0,0 +1,46 @@
+2006
+06
+January
+Jan
+Monday
+Mon
+01
+02
+03
+04
+05
+15
+1
+2
+3
+4
+5
+PM
+pm
+MST
+2006-01-02
+2006-01-02 15:04:05
+2 Jan 2006
+Jan 2, 2006
+January 2, 2006
+Monday, January 2, 2006
+Mon Jan 2 2006
+Mon Jan 02 2006
+Mon, 02 Jan 2006 15:04:05 MST
+01/02/2006
+02/01/2006
+2006/01/02
+1/2/06
+1/2/06 3:04PM
+1/2/2006
+3:04:05 PM
+3:04 pm
+15:04:05
+15:04
+15:04:05 MST
+January 2006
+Jan 06
+2 January 2006
+Monday 2 Jan 2006 at 15:04
+06-01-02
+3:4:5
diff --git a/bench/date/inlined/format.odin b/bench/date/inlined/format.odin
new file mode 100644
index 0000000..381cc1d
--- /dev/null
+++ b/bench/date/inlined/format.odin
@@ -0,0 +1,126 @@
+package inlined
+
+import "../common"
+import "core:fmt"
+import "core:log"
+import "core:strings"
+import "core:time"
+import "core:time/datetime"
+
+format_date :: proc(
+ dt: common.Date_Components,
+ fmt: string,
+ allocator := context.temp_allocator,
+) -> string {
+ b: strings.Builder
+ strings.builder_init_len(&b, len(fmt), allocator)
+
+ for i := 0; i < len(fmt); {
+ matched := match_token(&b, dt, fmt[i:])
+ if matched > 0 {
+ i += matched
+ } else {
+ strings.write_byte(&b, fmt[i])
+ i += 1
+ }
+ }
+
+ log.debugf("formatted date: '%s'", b.buf)
+ return strings.to_string(b)
+}
+
+match_token :: proc(b: ^strings.Builder, dt: common.Date_Components, s: string) -> int {
+ if strings.has_prefix(
+ s,
+ "January",
+ ) {strings.write_string(b, fmt.tprintf("%s", time.Month(dt.month))); return 7}
+ if strings.has_prefix(s, "Monday") {emit_weekday(b, dt, full = true); return 6}
+ if strings.has_prefix(
+ s,
+ "2006",
+ ) {strings.write_string(b, fmt.tprintf("%04d", dt.year)); return 4}
+ if strings.has_prefix(s, "MST") {strings.write_string(b, "UTC"); return 3}
+ if strings.has_prefix(s, "Jan") {emit_month_abbr(b, dt); return 3}
+ if strings.has_prefix(s, "Mon") {emit_weekday(b, dt, full = false); return 3}
+ if strings.has_prefix(
+ s,
+ "06",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.year % 100)); return 2}
+ if strings.has_prefix(s, "02") {strings.write_string(b, fmt.tprintf("%02d", dt.day)); return 2}
+ if strings.has_prefix(
+ s,
+ "15",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.hour)); return 2}
+ if strings.has_prefix(
+ s,
+ "04",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.minute)); return 2}
+ if strings.has_prefix(
+ s,
+ "05",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.second)); return 2}
+ if strings.has_prefix(
+ s,
+ "01",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.month)); return 2}
+ if strings.has_prefix(s, "03") {emit_hour_12(b, dt, pad = true); return 2}
+ if strings.has_prefix(s, "PM") {
+ strings.write_string(b, "PM" if dt.hour >= 12 else "AM")
+ return 2
+ }
+ if strings.has_prefix(s, "pm") {
+ strings.write_string(b, "pm" if dt.hour >= 12 else "am")
+ return 2
+ }
+ if len(s) >= 1 {
+ switch s[0] {
+ case '2':
+ strings.write_string(b, fmt.tprintf("%d", dt.day)); return 1
+ case '1':
+ strings.write_string(b, fmt.tprintf("%d", dt.month)); return 1
+ case '4':
+ strings.write_string(b, fmt.tprintf("%d", dt.minute)); return 1
+ case '5':
+ strings.write_string(b, fmt.tprintf("%d", dt.second)); return 1
+ case '3':
+ emit_hour_12(b, dt, pad = false); return 1
+ case:
+ return 0
+ }
+ }
+ return 0
+}
+
+emit_month_abbr :: proc(b: ^strings.Builder, dt: common.Date_Components) {
+ name := fmt.tprintf("%s", time.Month(dt.month))
+ strings.write_string(b, name[:3 if len(name) >= 3 else len(name)])
+}
+
+emit_weekday :: proc(b: ^strings.Builder, dt: common.Date_Components, full: bool) {
+ date := datetime.Date {
+ year = i64(dt.year),
+ month = i8(dt.month),
+ day = i8(dt.day),
+ }
+ ordinal, err := datetime.date_to_ordinal(date)
+ if err != .None {
+ strings.write_string(b, "???")
+ return
+ }
+ weekday := datetime.day_of_week(ordinal)
+ name := fmt.tprintf("%s", weekday)
+ if full {
+ strings.write_string(b, name)
+ } else {
+ strings.write_string(b, name[:3 if len(name) >= 3 else len(name)])
+ }
+}
+
+emit_hour_12 :: proc(b: ^strings.Builder, dt: common.Date_Components, pad: bool) {
+ h12 := dt.hour % 12
+ if h12 == 0 {h12 = 12}
+ format := "%02d" if pad else "%d"
+
+ fmt.sbprintf(b, format, h12)
+}
+
diff --git a/bench/date/inplace/format.odin b/bench/date/inplace/format.odin
new file mode 100644
index 0000000..5297365
--- /dev/null
+++ b/bench/date/inplace/format.odin
@@ -0,0 +1,133 @@
+package inplace
+
+import "../common"
+import "core:bytes"
+import "core:fmt"
+import "core:mem"
+import "core:strings"
+import "core:time"
+import "core:time/datetime"
+
+format_date :: proc(
+ dt: common.Date_Components,
+ fmt: string,
+ allocator := context.temp_allocator,
+) -> string {
+ b := make([]byte, len(fmt), context.temp_allocator)
+ x := transmute([]byte)(fmt)
+ mem.copy_non_overlapping(&b[0], &x, len(fmt))
+
+
+ return string(b)
+ // for i := 0; i < len(fmt); {
+ // matched := match_token(&b, dt, fmt[i:])
+ // if matched > 0 {
+ // i += matched
+ // } else {
+ // strings.write_byte(&b, fmt[i])
+ // i += 1
+ // }
+ // }
+
+ // log.debugf("formatted date: '%s'", b.buf)
+ // return strings.to_string(b)
+}
+
+FULL_MONTH: string : "January"
+
+match_token :: proc(s: []byte, dt: common.Date_Components) {
+ if bytes.equal(s[:len(FULL_MONTH)], transmute([]u8)FULL_MONTH) {
+ mo := fmt.tprintf("%s", time.Month(dt.month))
+ mem.copy_non_overlapping(&s[0], &(transmute([]u8)mo)[0], len(mo))
+ }
+ /*
+ if strings.has_prefix(s, "Monday") {emit_weekday(b, dt, full = true); return 6}
+ if strings.has_prefix(
+ s,
+ "2006",
+ ) {strings.write_string(b, fmt.tprintf("%04d", dt.year)); return 4}
+ if strings.has_prefix(s, "MST") {strings.write_string(b, "UTC"); return 3}
+ if strings.has_prefix(s, "Jan") {emit_month_abbr(b, dt); return 3}
+ if strings.has_prefix(s, "Mon") {emit_weekday(b, dt, full = false); return 3}
+ if strings.has_prefix(
+ s,
+ "06",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.year % 100)); return 2}
+ if strings.has_prefix(s, "02") {strings.write_string(b, fmt.tprintf("%02d", dt.day)); return 2}
+ if strings.has_prefix(
+ s,
+ "15",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.hour)); return 2}
+ if strings.has_prefix(
+ s,
+ "04",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.minute)); return 2}
+ if strings.has_prefix(
+ s,
+ "05",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.second)); return 2}
+ if strings.has_prefix(
+ s,
+ "01",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.month)); return 2}
+ if strings.has_prefix(s, "03") {emit_hour_12(b, dt, pad = true); return 2}
+ if strings.has_prefix(s, "PM") {
+ strings.write_string(b, "PM" if dt.hour >= 12 else "AM")
+ return 2
+ }
+ if strings.has_prefix(s, "pm") {
+ strings.write_string(b, "pm" if dt.hour >= 12 else "am")
+ return 2
+ }
+ if len(s) >= 1 {
+ switch s[0] {
+ case '2':
+ strings.write_string(b, fmt.tprintf("%d", dt.day)); return 1
+ case '1':
+ strings.write_string(b, fmt.tprintf("%d", dt.month)); return 1
+ case '4':
+ strings.write_string(b, fmt.tprintf("%d", dt.minute)); return 1
+ case '5':
+ strings.write_string(b, fmt.tprintf("%d", dt.second)); return 1
+ case '3':
+ emit_hour_12(b, dt, pad = false); return 1
+ case:
+ return 0
+ }
+ }
+ */
+}
+
+emit_month_abbr :: proc(b: ^strings.Builder, dt: common.Date_Components) {
+ name := fmt.tprintf("%s", time.Month(dt.month))
+ strings.write_string(b, name[:3 if len(name) >= 3 else len(name)])
+}
+
+emit_weekday :: proc(b: ^strings.Builder, dt: common.Date_Components, full: bool) {
+ date := datetime.Date {
+ year = i64(dt.year),
+ month = i8(dt.month),
+ day = i8(dt.day),
+ }
+ ordinal, err := datetime.date_to_ordinal(date)
+ if err != .None {
+ strings.write_string(b, "???")
+ return
+ }
+ weekday := datetime.day_of_week(ordinal)
+ name := fmt.tprintf("%s", weekday)
+ if full {
+ strings.write_string(b, name)
+ } else {
+ strings.write_string(b, name[:3 if len(name) >= 3 else len(name)])
+ }
+}
+
+emit_hour_12 :: proc(b: ^strings.Builder, dt: common.Date_Components, pad: bool) {
+ h12 := dt.hour % 12
+ if h12 == 0 {h12 = 12}
+ format := "%02d" if pad else "%d"
+
+ fmt.sbprintf(b, format, h12)
+}
+
diff --git a/bench/date/original/format.odin b/bench/date/original/format.odin
new file mode 100644
index 0000000..d9868dd
--- /dev/null
+++ b/bench/date/original/format.odin
@@ -0,0 +1,128 @@
+package original
+
+import "../common"
+import "core:fmt"
+import "core:log"
+import "core:strings"
+import "core:time"
+import "core:time/datetime"
+
+format_date :: proc(
+ dt: common.Date_Components,
+ fmt: string,
+ allocator := context.temp_allocator,
+) -> string {
+ b: strings.Builder
+ strings.builder_init(&b, allocator)
+
+ for i := 0; i < len(fmt); {
+ matched := match_token(&b, dt, fmt[i:])
+ if matched > 0 {
+ i += matched
+ } else {
+ strings.write_byte(&b, fmt[i])
+ i += 1
+ }
+ }
+
+ log.debugf("formatted date: '%s'", b.buf)
+ return strings.to_string(b)
+}
+
+match_token :: proc(b: ^strings.Builder, dt: common.Date_Components, s: string) -> int {
+ if strings.has_prefix(
+ s,
+ "January",
+ ) {strings.write_string(b, fmt.tprintf("%s", time.Month(dt.month))); return 7}
+ if strings.has_prefix(s, "Monday") {emit_weekday(b, dt, full = true); return 6}
+ if strings.has_prefix(
+ s,
+ "2006",
+ ) {strings.write_string(b, fmt.tprintf("%04d", dt.year)); return 4}
+ if strings.has_prefix(s, "MST") {strings.write_string(b, "UTC"); return 3}
+ if strings.has_prefix(s, "Jan") {emit_month_abbr(b, dt); return 3}
+ if strings.has_prefix(s, "Mon") {emit_weekday(b, dt, full = false); return 3}
+ if strings.has_prefix(
+ s,
+ "06",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.year % 100)); return 2}
+ if strings.has_prefix(s, "02") {strings.write_string(b, fmt.tprintf("%02d", dt.day)); return 2}
+ if strings.has_prefix(
+ s,
+ "15",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.hour)); return 2}
+ if strings.has_prefix(
+ s,
+ "04",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.minute)); return 2}
+ if strings.has_prefix(
+ s,
+ "05",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.second)); return 2}
+ if strings.has_prefix(
+ s,
+ "01",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.month)); return 2}
+ if strings.has_prefix(s, "03") {emit_hour_12(b, dt, pad = true); return 2}
+ if strings.has_prefix(s, "PM") {emit_am_pm(b, dt); return 2}
+ if strings.has_prefix(s, "pm") {emit_am_pm_lower(b, dt); return 2}
+ if len(s) >= 1 {
+ switch s[0] {
+ case '2':
+ strings.write_string(b, fmt.tprintf("%d", dt.day)); return 1
+ case '1':
+ strings.write_string(b, fmt.tprintf("%d", dt.month)); return 1
+ case '4':
+ strings.write_string(b, fmt.tprintf("%d", dt.minute)); return 1
+ case '5':
+ strings.write_string(b, fmt.tprintf("%d", dt.second)); return 1
+ case '3':
+ emit_hour_12(b, dt, pad = false); return 1
+ case:
+ return 0
+ }
+ }
+ return 0
+}
+
+emit_month_abbr :: proc(b: ^strings.Builder, dt: common.Date_Components) {
+ name := fmt.tprintf("%s", time.Month(dt.month))
+ strings.write_string(b, name[:3 if len(name) >= 3 else len(name)])
+}
+
+emit_weekday :: proc(b: ^strings.Builder, dt: common.Date_Components, full: bool) {
+ date := datetime.Date {
+ year = i64(dt.year),
+ month = i8(dt.month),
+ day = i8(dt.day),
+ }
+ ordinal, err := datetime.date_to_ordinal(date)
+ if err != .None {
+ strings.write_string(b, "???")
+ return
+ }
+ weekday := datetime.day_of_week(ordinal)
+ name := fmt.tprintf("%s", weekday)
+ if full {
+ strings.write_string(b, name)
+ } else {
+ strings.write_string(b, name[:3 if len(name) >= 3 else len(name)])
+ }
+}
+
+emit_hour_12 :: proc(b: ^strings.Builder, dt: common.Date_Components, pad: bool) {
+ h12 := dt.hour % 12
+ if h12 == 0 {h12 = 12}
+ format := "%02d" if pad else "%d"
+
+ fmt.sbprintf(b, format, h12)
+}
+
+emit_am_pm :: proc(b: ^strings.Builder, dt: common.Date_Components) {
+ strings.write_string(b, "PM" if dt.hour >= 12 else "AM")
+}
+
+emit_am_pm_lower :: proc(b: ^strings.Builder, dt: common.Date_Components) {
+ strings.write_string(b, "pm" if dt.hour >= 12 else "am")
+}
+
diff --git a/bench/date/parse.odin b/bench/date/parse.odin
new file mode 100644
index 0000000..43727f6
--- /dev/null
+++ b/bench/date/parse.odin
@@ -0,0 +1,44 @@
+package main
+
+Date_Components :: struct {
+ year: int,
+ month: int,
+ day: int,
+ hour: int,
+ minute: int,
+ second: int,
+}
+
+// TODO: Use some kind of scanner interface
+parse_iso_date :: proc(iso: string) -> (c: Date_Components, ok: bool) {
+ if len(iso) < 10 {
+ return {}, false
+ }
+
+ c.year = parse_2_digits(iso, 0) * 100 + parse_2_digits(iso, 2)
+ c.month = parse_2_digits(iso, 5)
+ c.day = parse_2_digits(iso, 8)
+
+ if c.month < 1 || c.month > 12 {
+ return {}, false
+ }
+ if c.day < 1 || c.day > 31 {
+ return {}, false
+ }
+
+ if len(iso) >= 19 && (iso[10] == 'T' || iso[10] == 't') {
+ c.hour = parse_2_digits(iso, 11)
+ c.minute = parse_2_digits(iso, 14)
+ c.second = parse_2_digits(iso, 17)
+ }
+
+ return c, true
+}
+
+parse_2_digits :: proc(s: string, offset: int) -> int {
+ if offset + 1 >= len(s) {
+ return 0
+ }
+ return (int(s[offset]) - 0x30) * 10 + (int(s[offset + 1]) - 0x30)
+}
+
diff --git a/bench/date/returned/format.odin b/bench/date/returned/format.odin
new file mode 100644
index 0000000..4442cc0
--- /dev/null
+++ b/bench/date/returned/format.odin
@@ -0,0 +1,127 @@
+package returned
+
+import "../common"
+import "core:fmt"
+import "core:log"
+import "core:strings"
+import "core:time"
+import "core:time/datetime"
+
+format_date :: proc(
+ dt: common.Date_Components,
+ fmt: string,
+ allocator := context.temp_allocator,
+) -> string {
+ b: strings.Builder
+ // strings.builder_init_len(&b, len(fmt), allocator)
+ strings.builder_init(&b, allocator)
+
+ for i := 0; i < len(fmt); {
+ matched := match_token(&b, dt, fmt[i:])
+ if matched > 0 {
+ i += matched
+ } else {
+ strings.write_byte(&b, fmt[i])
+ i += 1
+ }
+ }
+
+ log.debugf("formatted date: '%s'", b.buf)
+ return strings.to_string(b)
+}
+
+match_token :: proc(b: ^strings.Builder, dt: common.Date_Components, s: string) -> int {
+ if strings.has_prefix(
+ s,
+ "January",
+ ) {strings.write_string(b, fmt.tprintf("%s", time.Month(dt.month))); return 7}
+ if strings.has_prefix(s, "Monday") {emit_weekday(b, dt, full = true); return 6}
+ if strings.has_prefix(
+ s,
+ "2006",
+ ) {strings.write_string(b, fmt.tprintf("%04d", dt.year)); return 4}
+ if strings.has_prefix(s, "MST") {strings.write_string(b, "UTC"); return 3}
+ if strings.has_prefix(s, "Jan") {emit_month_abbr(b, dt); return 3}
+ if strings.has_prefix(s, "Mon") {emit_weekday(b, dt, full = false); return 3}
+ if strings.has_prefix(
+ s,
+ "06",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.year % 100)); return 2}
+ if strings.has_prefix(s, "02") {strings.write_string(b, fmt.tprintf("%02d", dt.day)); return 2}
+ if strings.has_prefix(
+ s,
+ "15",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.hour)); return 2}
+ if strings.has_prefix(
+ s,
+ "04",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.minute)); return 2}
+ if strings.has_prefix(
+ s,
+ "05",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.second)); return 2}
+ if strings.has_prefix(
+ s,
+ "01",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.month)); return 2}
+ if strings.has_prefix(s, "03") {emit_hour_12(b, dt, pad = true); return 2}
+ if strings.has_prefix(s, "PM") {
+ strings.write_string(b, "PM" if dt.hour >= 12 else "AM")
+ return 2
+ }
+ if strings.has_prefix(s, "pm") {
+ strings.write_string(b, "pm" if dt.hour >= 12 else "am")
+ return 2
+ }
+ if len(s) >= 1 {
+ switch s[0] {
+ case '2':
+ strings.write_string(b, fmt.tprintf("%d", dt.day)); return 1
+ case '1':
+ strings.write_string(b, fmt.tprintf("%d", dt.month)); return 1
+ case '4':
+ strings.write_string(b, fmt.tprintf("%d", dt.minute)); return 1
+ case '5':
+ strings.write_string(b, fmt.tprintf("%d", dt.second)); return 1
+ case '3':
+ emit_hour_12(b, dt, pad = false); return 1
+ case:
+ return 0
+ }
+ }
+ return 0
+}
+
+emit_month_abbr :: proc(b: ^strings.Builder, dt: common.Date_Components) {
+ name := fmt.tprintf("%s", time.Month(dt.month))
+ strings.write_string(b, name[:3 if len(name) >= 3 else len(name)])
+}
+
+emit_weekday :: proc(b: ^strings.Builder, dt: common.Date_Components, full: bool) {
+ date := datetime.Date {
+ year = i64(dt.year),
+ month = i8(dt.month),
+ day = i8(dt.day),
+ }
+ ordinal, err := datetime.date_to_ordinal(date)
+ if err != .None {
+ strings.write_string(b, "???")
+ return
+ }
+ weekday := datetime.day_of_week(ordinal)
+ name := fmt.tprintf("%s", weekday)
+ if full {
+ strings.write_string(b, name)
+ } else {
+ strings.write_string(b, name[:3 if len(name) >= 3 else len(name)])
+ }
+}
+
+emit_hour_12 :: proc(b: ^strings.Builder, dt: common.Date_Components, pad: bool) {
+ h12 := dt.hour % 12
+ if h12 == 0 {h12 = 12}
+ format := "%02d" if pad else "%d"
+
+ fmt.sbprintf(b, format, h12)
+}
+
diff --git a/flake.nix b/flake.nix
index c3c2a16..ebbcd3a 100644
--- a/flake.nix
+++ b/flake.nix
@@ -149,6 +149,8 @@
cmark
tree-sitter
+ gdb
+
# IDE
unstable.helix
typescript-language-server
diff --git a/main.odin b/main.odin
index b833548..1a5b087 100644
--- a/main.odin
+++ b/main.odin
@@ -1,11 +1,9 @@
package main
import "base:runtime"
-import "core:fmt"
import "core:log"
import "core:os"
import "core:prof/spall"
-import "core:strings"
import "core:sync"
import "core:time"
@@ -36,7 +34,7 @@ main :: proc() {
logger_opts: log.Options =
(log.Default_Console_Logger_Opts - log.Full_Timestamp_Opts - {.Short_File_Path})
- console_logger := log.create_console_logger(.Debug, logger_opts)
+ console_logger := log.create_console_logger(.Info, logger_opts)
context.logger = console_logger
defer log.destroy_console_logger(console_logger)
diff --git a/mustache/format.odin b/mustache/format.odin
new file mode 100644
index 0000000..8c0773d
--- /dev/null
+++ b/mustache/format.odin
@@ -0,0 +1,169 @@
+package mustache
+
+import "core:fmt"
+import "core:log"
+import "core:strings"
+import "core:time"
+import "core:time/datetime"
+
+Date_Components :: struct {
+ year: int,
+ month: int,
+ day: int,
+ hour: int,
+ minute: int,
+ second: int,
+}
+
+// TODO: Use some kind of scanner interface
+parse_iso_date :: proc(iso: string) -> (c: Date_Components, ok: bool) {
+ if len(iso) < 10 {
+ return {}, false
+ }
+
+ c.year = parse_2_digits(iso, 0) * 100 + parse_2_digits(iso, 2)
+ c.month = parse_2_digits(iso, 5)
+ c.day = parse_2_digits(iso, 8)
+
+ if c.month < 1 || c.month > 12 {
+ return {}, false
+ }
+ if c.day < 1 || c.day > 31 {
+ return {}, false
+ }
+
+ if len(iso) >= 19 && (iso[10] == 'T' || iso[10] == 't') {
+ c.hour = parse_2_digits(iso, 11)
+ c.minute = parse_2_digits(iso, 14)
+ c.second = parse_2_digits(iso, 17)
+ }
+
+ return c, true
+}
+
+parse_2_digits :: proc(s: string, offset: int) -> int {
+ if offset + 1 >= len(s) {
+ return 0
+ }
+ return (int(s[offset]) - 0x30) * 10 + (int(s[offset + 1]) - 0x30)
+}
+
+format_date :: proc(
+ dt: Date_Components,
+ fmt: string,
+ allocator := context.temp_allocator,
+) -> string {
+ b: strings.Builder
+ strings.builder_init(&b, allocator)
+
+ for i := 0; i < len(fmt); {
+ matched := match_token(&b, dt, fmt[i:])
+ if matched > 0 {
+ i += matched
+ } else {
+ strings.write_byte(&b, fmt[i])
+ i += 1
+ }
+ }
+
+ log.debugf("formatted date: '%s'", b.buf)
+ return strings.to_string(b)
+}
+
+match_token :: proc(b: ^strings.Builder, dt: Date_Components, s: string) -> int {
+ if strings.has_prefix(
+ s,
+ "January",
+ ) {strings.write_string(b, fmt.tprintf("%s", time.Month(dt.month))); return 7}
+ if strings.has_prefix(s, "Monday") {emit_weekday(b, dt, full = true); return 6}
+ if strings.has_prefix(
+ s,
+ "2006",
+ ) {strings.write_string(b, fmt.tprintf("%04d", dt.year)); return 4}
+ if strings.has_prefix(s, "MST") {strings.write_string(b, "UTC"); return 3}
+ if strings.has_prefix(s, "Jan") {emit_month_abbr(b, dt); return 3}
+ if strings.has_prefix(s, "Mon") {emit_weekday(b, dt, full = false); return 3}
+ if strings.has_prefix(
+ s,
+ "06",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.year % 100)); return 2}
+ if strings.has_prefix(s, "02") {strings.write_string(b, fmt.tprintf("%02d", dt.day)); return 2}
+ if strings.has_prefix(
+ s,
+ "15",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.hour)); return 2}
+ if strings.has_prefix(
+ s,
+ "04",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.minute)); return 2}
+ if strings.has_prefix(
+ s,
+ "05",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.second)); return 2}
+ if strings.has_prefix(
+ s,
+ "01",
+ ) {strings.write_string(b, fmt.tprintf("%02d", dt.month)); return 2}
+ if strings.has_prefix(s, "03") {emit_hour_12(b, dt, pad = true); return 2}
+ if strings.has_prefix(s, "PM") {emit_am_pm(b, dt); return 2}
+ if strings.has_prefix(s, "pm") {emit_am_pm_lower(b, dt); return 2}
+ if len(s) >= 1 {
+ switch s[0] {
+ case '2':
+ strings.write_string(b, fmt.tprintf("%d", dt.day)); return 1
+ case '1':
+ strings.write_string(b, fmt.tprintf("%d", dt.month)); return 1
+ case '4':
+ strings.write_string(b, fmt.tprintf("%d", dt.minute)); return 1
+ case '5':
+ strings.write_string(b, fmt.tprintf("%d", dt.second)); return 1
+ case '3':
+ emit_hour_12(b, dt, pad = false); return 1
+ case:
+ return 0
+ }
+ }
+ return 0
+}
+
+emit_month_abbr :: proc(b: ^strings.Builder, dt: Date_Components) {
+ name := fmt.tprintf("%s", time.Month(dt.month))
+ strings.write_string(b, name[:3 if len(name) >= 3 else len(name)])
+}
+
+emit_weekday :: proc(b: ^strings.Builder, dt: Date_Components, full: bool) {
+ date := datetime.Date {
+ year = i64(dt.year),
+ month = i8(dt.month),
+ day = i8(dt.day),
+ }
+ ordinal, err := datetime.date_to_ordinal(date)
+ if err != .None {
+ strings.write_string(b, "???")
+ return
+ }
+ weekday := datetime.day_of_week(ordinal)
+ name := fmt.tprintf("%s", weekday)
+ if full {
+ strings.write_string(b, name)
+ } else {
+ strings.write_string(b, name[:3 if len(name) >= 3 else len(name)])
+ }
+}
+
+emit_hour_12 :: proc(b: ^strings.Builder, dt: Date_Components, pad: bool) {
+ h12 := dt.hour % 12
+ if h12 == 0 {h12 = 12}
+ format := "%02d" if pad else "%d"
+
+ fmt.sbprintf(b, format, h12)
+}
+
+emit_am_pm :: proc(b: ^strings.Builder, dt: Date_Components) {
+ strings.write_string(b, "PM" if dt.hour >= 12 else "AM")
+}
+
+emit_am_pm_lower :: proc(b: ^strings.Builder, dt: Date_Components) {
+ strings.write_string(b, "pm" if dt.hour >= 12 else "am")
+}
+
diff --git a/mustache/format_test.odin b/mustache/format_test.odin
new file mode 100644
index 0000000..0c57fb2
--- /dev/null
+++ b/mustache/format_test.odin
@@ -0,0 +1,164 @@
+#+test
+package mustache
+
+import "core:testing"
+
+// ---------------------------------------------------------------------------
+// parse_iso_date
+// ---------------------------------------------------------------------------
+
+@(test)
+test_parse_iso_date_extracts_time :: proc(t: ^testing.T) {
+ c, ok := parse_iso_date("2026-03-15T08:49:54-04:00")
+ testing.expect(t, ok, "should parse")
+ testing.expect_value(t, c.year, 2026)
+ testing.expect_value(t, c.month, 3)
+ testing.expect_value(t, c.day, 15)
+ testing.expect_value(t, c.hour, 8)
+ testing.expect_value(t, c.minute, 49)
+ testing.expect_value(t, c.second, 54)
+}
+
+@(test)
+test_parse_iso_date_lowercase_t :: proc(t: ^testing.T) {
+ c, ok := parse_iso_date("2026-03-15t08:49:54Z")
+ testing.expect(t, ok, "should parse lowercase t separator")
+ testing.expect_value(t, c.hour, 8)
+ testing.expect_value(t, c.minute, 49)
+ testing.expect_value(t, c.second, 54)
+}
+
+@(test)
+test_parse_iso_date_date_only_zero_time :: proc(t: ^testing.T) {
+ c, ok := parse_iso_date("2026-03-15")
+ testing.expect(t, ok, "should parse date-only")
+ testing.expect_value(t, c.hour, 0)
+ testing.expect_value(t, c.minute, 0)
+ testing.expect_value(t, c.second, 0)
+}
+
+@(test)
+test_parse_iso_date_invalid_day_errors :: proc(t: ^testing.T) {
+ _, ok := parse_iso_date("2026-03-32")
+ testing.expect(t, !ok, "day > 31 should fail")
+}
+
+@(test)
+test_parse_iso_date_too_short_errors :: proc(t: ^testing.T) {
+ _, ok := parse_iso_date("2026-03")
+ testing.expect(t, !ok, "input shorter than 10 chars should fail")
+}
+
+// ---------------------------------------------------------------------------
+// format_date / match_token
+// ---------------------------------------------------------------------------
+
+@(test)
+test_format_date_weekday_full :: proc(t: ^testing.T) {
+ // 2026-01-01 is a Thursday.
+ dt := Date_Components{year = 2026, month = 1, day = 1}
+ result := format_date(dt, "Monday")
+ testing.expect_value(t, result, "Thursday")
+}
+
+@(test)
+test_format_date_weekday_abbr :: proc(t: ^testing.T) {
+ dt := Date_Components{year = 2026, month = 1, day = 1}
+ result := format_date(dt, "Mon")
+ testing.expect_value(t, result, "Thu")
+}
+
+@(test)
+test_format_date_month_full_name :: proc(t: ^testing.T) {
+ dt := Date_Components{year = 2026, month = 3, day = 15}
+ result := format_date(dt, "January")
+ testing.expect_value(t, result, "March")
+}
+
+@(test)
+test_format_date_two_digit_year :: proc(t: ^testing.T) {
+ dt := Date_Components{year = 2026, month = 3, day = 15}
+ result := format_date(dt, "06")
+ testing.expect_value(t, result, "26")
+}
+
+@(test)
+test_format_date_hour24_padded :: proc(t: ^testing.T) {
+ midnight := Date_Components{year = 2026, month = 1, day = 1, hour = 0}
+ testing.expect_value(t, format_date(midnight, "15"), "00")
+
+ afternoon := Date_Components{year = 2026, month = 1, day = 1, hour = 13}
+ testing.expect_value(t, format_date(afternoon, "15"), "13")
+}
+
+@(test)
+test_format_date_hour12_padded_am_pm_boundaries :: proc(t: ^testing.T) {
+ cases := [4]struct {
+ hour: int,
+ expected: string,
+ }{{0, "12 AM"}, {12, "12 PM"}, {13, "01 PM"}, {23, "11 PM"}}
+
+ for &c in cases {
+ dt := Date_Components{year = 2026, month = 1, day = 1, hour = c.hour}
+ result := format_date(dt, "03 PM")
+ testing.expect_value(t, result, c.expected)
+ }
+}
+
+@(test)
+test_format_date_hour12_unpadded :: proc(t: ^testing.T) {
+ one_am := Date_Components{year = 2026, month = 1, day = 1, hour = 1}
+ testing.expect_value(t, format_date(one_am, "3"), "1")
+
+ one_pm := Date_Components{year = 2026, month = 1, day = 1, hour = 13}
+ testing.expect_value(t, format_date(one_pm, "3"), "1")
+}
+
+@(test)
+test_format_date_am_pm_lowercase :: proc(t: ^testing.T) {
+ afternoon := Date_Components{year = 2026, month = 1, day = 1, hour = 13}
+ testing.expect_value(t, format_date(afternoon, "pm"), "pm")
+
+ morning := Date_Components{year = 2026, month = 1, day = 1, hour = 9}
+ testing.expect_value(t, format_date(morning, "pm"), "am")
+}
+
+@(test)
+test_format_date_minute_second_padding :: proc(t: ^testing.T) {
+ dt := Date_Components{year = 2026, month = 1, day = 1, minute = 4, second = 5}
+ testing.expect_value(t, format_date(dt, "04:05"), "04:05")
+ testing.expect_value(t, format_date(dt, "4:5"), "4:5")
+}
+
+@(test)
+test_format_date_month_day_numeric_padding :: proc(t: ^testing.T) {
+ dt := Date_Components{year = 2026, month = 3, day = 5}
+ testing.expect_value(t, format_date(dt, "01"), "03")
+ testing.expect_value(t, format_date(dt, "1"), "3")
+ testing.expect_value(t, format_date(dt, "02"), "05")
+ testing.expect_value(t, format_date(dt, "2"), "5")
+}
+
+@(test)
+test_format_date_mst_always_utc :: proc(t: ^testing.T) {
+ // Date_Components carries no offset yet, so MST is a hardcoded
+ // placeholder until real timezone support lands.
+ dt := Date_Components{year = 2026, month = 1, day = 1, hour = 12}
+ result := format_date(dt, "MST")
+ testing.expect_value(t, result, "UTC")
+}
+
+@(test)
+test_format_date_literal_passthrough :: proc(t: ^testing.T) {
+ dt := Date_Components{year = 2026, month = 1, day = 1}
+ result := format_date(dt, "Year: 2006!")
+ testing.expect_value(t, result, "Year: 2026!")
+}
+
+@(test)
+test_format_date_combined_go_reference_layout :: proc(t: ^testing.T) {
+ // 2023-10-15 is a Sunday.
+ dt := Date_Components{year = 2023, month = 10, day = 15, hour = 13, minute = 18, second = 50}
+ result := format_date(dt, "Mon Jan 2 15:04:05 MST 2006")
+ testing.expect_value(t, result, "Sun Oct 15 13:18:50 UTC 2023")
+}
diff --git a/mustache/pipes.odin b/mustache/pipes.odin
index 9f708cd..a68975c 100644
--- a/mustache/pipes.odin
+++ b/mustache/pipes.odin
@@ -1,6 +1,7 @@
package mustache
import "core:fmt"
+import "core:log"
import "core:reflect"
import "core:strings"
import "core:time"
@@ -12,6 +13,8 @@ MAX_PIPES :: 8
// No filter accepts more than 2 args.
MAX_PIPE_ARGS :: 2
+DEFAULT_DATE_FORMAT :: "2 Jan 2006"
+
Pipe_Filter :: struct {
op: string,
args: [dynamic; MAX_PIPE_ARGS]string,
@@ -111,6 +114,7 @@ apply_pipeline :: proc(
current = value
for &filter in filters {
current = apply_filter(current, &filter, pos, ctx) or_return
+ log.debugf("applied: filter=%v before=%s after=%s pos=%d", filter, value, current, pos)
}
return
}
@@ -128,11 +132,42 @@ apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) ->
pos = pos,
kind = .Data,
}
- } else {
- return apply_format(str, filter.args[:], pos)
}
+
+ 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,
+ }
+ } 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,
+ }
+ }
+ }
+ }
+
+ str2, err := apply_format(str, filter.args[:], pos, date_format)
+ if err != nil {
+ return value, err
+ } else {
+ return any{new_clone(str2, context.temp_allocator), typeid_of(string)}, nil
+ }
+
case:
- return nil, Error_Body{
+ return nil, Error_Body {
msg = fmt.tprintf("unknown pipe op '%s'", filter.op),
pos = pos,
kind = .Data,
@@ -140,51 +175,40 @@ apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) ->
}
}
-// apply_format formats an ISO 8601 date string as a display string
-// (e.g. "2026-03-15T08:49:54-04:00" → "15 Mar 2026"). Invalid input
-// (empty, too-short, or unparseable) returns a Data-kind `Error`. Templates
-// that need to skip dateless pages should gate with a section:
-// {{#date}}{{/date}}
-// The section's truthiness check catches empty before the filter runs.
-//
-// Currently ignores args; planned to accept Go reference-date format
-// strings in the future.
-//
-// Accepts any of these ISO 8601 forms (date prefix is invariant):
-// 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
-apply_format :: proc(iso: string, args: []string, pos: int) -> (result: any, err: Error) {
- if len(iso) < 10 {
- return nil, Error_Body{
- msg = "format may only be used on dates",
- pos = pos,
- kind = .Data,
- }
+apply_format :: proc(
+ iso: string,
+ args: []string,
+ pos: int,
+ date_format: string,
+) -> (
+ result: string,
+ err: Error,
+) {
+ fmt_str := date_format
+ if fmt_str == "" {
+ log.errorf(
+ "format pipe used but no date format configured (set date.format in thor.json) Default will be used",
+ )
+ fmt_str = DEFAULT_DATE_FORMAT
}
- year := iso[:4]
- month_num := (int(iso[5]) - 0x30) * 10 + (int(iso[6]) - 0x30)
- day_num := (int(iso[8]) - 0x30) * 10 + (int(iso[9]) - 0x30)
-
- if month_num < 1 || month_num > 12 {
- return nil, Error_Body{
+ components, ok := parse_iso_date(iso)
+ if !ok {
+ return "", Error_Body {
msg = fmt.tprintf("invalid date: \"%s\"", iso),
pos = pos,
kind = .Data,
}
}
- month := fmt.tprintf("%s", time.Month(month_num))[:3]
- return fmt.tprintf("%d %s %s", day_num, month, year), nil
+ log.debugf("date: '%s' format: '%s'", iso, date_format)
+ return format_date(components, fmt_str), nil
}
// Groups preserve first-appearance order from the input list.
apply_group_by :: proc(value: any, args: []string, pos: int) -> (result: any, err: Error) {
if len(args) != 1 {
- return nil, Error_Body{
+ return nil, Error_Body {
msg = fmt.tprintf("group_by expects 1 argument, got %d", len(args)),
pos = pos,
kind = .Data,
@@ -194,11 +218,7 @@ apply_group_by :: proc(value: any, args: []string, pos: int) -> (result: any, er
elem_info, count, data := list_info(value)
if elem_info == nil {
- return nil, Error_Body{
- msg = "group_by expects a list",
- pos = pos,
- kind = .Data,
- }
+ return nil, Error_Body{msg = "group_by expects a list", pos = pos, kind = .Data}
}
groups := make([dynamic]Group, 0, 8, context.temp_allocator)
@@ -219,7 +239,7 @@ apply_group_by :: proc(value: any, args: []string, pos: int) -> (result: any, er
}
key_str := any_to_string(key_val)
if len(key_str) == 0 {
- return nil, Error_Body{
+ return nil, Error_Body {
msg = fmt.tprintf("group_by: field '%s' is empty", field),
pos = pos,
kind = .Data,
diff --git a/mustache/pipes_test.odin b/mustache/pipes_test.odin
index ff01a66..5caf1ef 100644
--- a/mustache/pipes_test.odin
+++ b/mustache/pipes_test.odin
@@ -358,6 +358,28 @@ test_format_inside_section_skips_when_empty :: proc(t: ^testing.T) {
testing.expect_value(t, result, "[]")
}
+@(test)
+test_format_context_date_format_weekday :: proc(t: ^testing.T) {
+ data := Format_Data {
+ date = "2026-01-01T00:00:00Z",
+ date_format = "Monday, January 2, 2006",
+ }
+ tpl, _ := parse("{{date | format}}", "", allocator = context.temp_allocator)
+ result, _ := render(tpl, data, {}, context.temp_allocator)
+ testing.expect_value(t, result, "Thursday, January 1, 2026")
+}
+
+@(test)
+test_format_context_date_format_time_of_day :: proc(t: ^testing.T) {
+ data := Format_Data {
+ date = "2026-01-01T15:30:00Z",
+ date_format = "3:04 PM",
+ }
+ tpl, _ := parse("{{date | format}}", "", allocator = context.temp_allocator)
+ result, _ := render(tpl, data, {}, context.temp_allocator)
+ testing.expect_value(t, result, "3:30 PM")
+}
+
@(test)
test_format_handles_all_iso8601_variants :: proc(t: ^testing.T) {
cases := [5]struct {