Skip to content

Commit b6659f4

Browse files
committed
feat: support timezone in frontmatter date field
Allow specifying a timezone in the frontmatter date field as either a UTC offset (+02:00) or an IANA timezone name (Europe/Helsinki). Dates without a timezone are still interpreted as UTC. The timezone database from the zones package is cached via persistent_term for efficient repeated lookups during builds. Closes #40
1 parent e959337 commit b6659f4

9 files changed

Lines changed: 256 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# Changelog
22

3+
## 5.1.0
4+
5+
Released on 2026-03-25
6+
7+
### Added
8+
9+
- support timezone in frontmatter date field
10+
> Allow specifying a timezone in the frontmatter date field as either a
11+
> UTC offset (+02:00) or an IANA timezone name (Europe/Helsinki).
12+
> Dates without a timezone are still interpreted as UTC.
13+
>
14+
> The timezone database from the zones package is cached via
15+
> persistent_term for efficient repeated lookups during builds.
16+
317
## 5.0.2
418

519
Released on 2026-03-20

docs/blog-posts.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ Your markdown content here...
4646
| Field | Format | Description |
4747
|-------|--------|-------------|
4848
| `title` | String | The post title |
49-
| `date` | `YYYY-MM-DD HH:MM:SS` | Publication date (UTC) |
49+
| `date` | `YYYY-MM-DD HH:MM:SS [timezone]` | Publication date (see [Date formats](#date-formats) below) |
5050
| `description` | String | A short description or excerpt |
5151

5252
### Optional fields
@@ -81,6 +81,28 @@ case dict.get(post.extras, "author") {
8181
}
8282
```
8383

84+
### Date formats
85+
86+
The `date` field supports three formats. All dates are internally normalized to UTC.
87+
88+
| Format | Example | Description |
89+
|--------|---------|-------------|
90+
| Naive | `2025-01-15 00:00:00` | Interpreted as UTC |
91+
| UTC offset | `2025-01-15 02:00:00 +02:00` | Converted to UTC using the given offset |
92+
| IANA timezone | `2025-01-15 02:00:00 Europe/Helsinki` | Converted to UTC using the [IANA timezone database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) |
93+
94+
When a timezone is specified, the date is converted to UTC before being stored in the `Post` type. This means you can write post dates in your local timezone without manually converting to UTC:
95+
96+
```markdown
97+
---
98+
title: My Post
99+
date: 2025-01-15 10:30:00 America/New_York
100+
description: Written at 10:30 AM Eastern Time
101+
---
102+
```
103+
104+
DST transitions are handled automatically — the correct offset is applied based on the date and the timezone's rules.
105+
84106
## Multilingual posts
85107

86108
Language variants use the `index-{lang}.md` naming convention:

examples/simple_blog/blog/getting-started/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Getting Started with Blogatto
33
slug: getting-started
4-
date: 2025-01-20 00:00:00
4+
date: 2025-01-20 00:00:00 Europe/Rome
55
description: Learn how to set up your first static blog with Blogatto
66
---
77

examples/simple_blog/manifest.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# You typically do not need to edit this file
33

44
packages = [
5-
{ name = "blogatto", version = "5.0.2", build_tools = ["gleam"], requirements = ["filepath", "filespy", "frontmatter", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_time", "gtempo", "houdini", "lustre", "marceau", "maud", "mist", "mork", "simplifile", "smalto", "smalto_lustre", "str", "webls"], source = "local", path = "../.." },
5+
{ name = "blogatto", version = "5.1.0", build_tools = ["gleam"], requirements = ["filepath", "filespy", "frontmatter", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_time", "gtempo", "houdini", "lustre", "marceau", "maud", "mist", "mork", "simplifile", "smalto", "smalto_lustre", "str", "tzif", "webls", "zones"], source = "local", path = "../.." },
66
{ name = "casefold", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "casefold", source = "hex", outer_checksum = "F09530B6F771BB7B0BCACD3014089C20DFDA31775BA4793266C3814607C0A468" },
77
{ name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" },
88
{ name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" },
@@ -38,7 +38,9 @@ packages = [
3838
{ name = "smalto_lustre_themes", version = "3.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "lustre", "smalto_lustre"], otp_app = "smalto_lustre_themes", source = "hex", outer_checksum = "50EAA03DB51FF7F3FA4457EC78D522A8C27B8DE11DE04407100473587E453499" },
3939
{ name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" },
4040
{ name = "str", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "houdini", "odysseus"], otp_app = "str", source = "hex", outer_checksum = "9DABB9C97E3B88F13BD71E4ABF5781C6D12F88BFA4839D7FC9F99BED8E390C07" },
41+
{ name = "tzif", version = "1.1.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib", "gleam_time", "simplifile"], otp_app = "tzif", source = "hex", outer_checksum = "04C8EBAFB7F6A6E61B8B0C16E06F96E7C7F76C31172EF07204CE9813B2E54AD2" },
4142
{ name = "webls", version = "1.6.2", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time", "gtempo"], otp_app = "webls", source = "hex", outer_checksum = "515CA019E3B09FF7605D44ABBCDCF8FF56EC528AA29017741E0184278E3423DD" },
43+
{ name = "zones", version = "1.26010.1", build_tools = ["gleam"], requirements = ["tzif"], otp_app = "zones", source = "hex", outer_checksum = "6F79507E564D1F578C282BFE9241558744818D5957FE5CB859D64B5096F4211D" },
4244
]
4345

4446
[requirements]

gleam.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name = "blogatto"
2-
version = "5.0.2"
2+
version = "5.1.0"
33
description = "A Gleam framework for building static blogs with Lustre and Markdown. Generates HTML pages, RSS feeds, sitemaps, and robots.txt from markdown files with frontmatter, with multilingual support."
44
repository = { type = "github", user = "veeso", repo = "blogatto" }
55
licenses = ["MIT"]
@@ -30,7 +30,9 @@ simplifile = ">= 2.3.2 and < 3.0.0"
3030
smalto = ">= 3.0.0 and < 4.0.0"
3131
smalto_lustre = ">= 3.0.0 and < 4.0.0"
3232
str = ">= 2.0.1 and < 3.0.0"
33+
tzif = ">= 1.1.2 and < 2.0.0"
3334
webls = ">= 1.6.2 and < 2.0.0"
35+
zones = ">= 1.26010.1 and < 2.0.0"
3436

3537
[dev-dependencies]
3638
gleeunit = ">= 1.0.0 and < 2.0.0"

manifest.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ packages = [
3838
{ name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" },
3939
{ name = "str", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "houdini", "odysseus"], otp_app = "str", source = "hex", outer_checksum = "9DABB9C97E3B88F13BD71E4ABF5781C6D12F88BFA4839D7FC9F99BED8E390C07" },
4040
{ name = "temporary", version = "1.0.0", build_tools = ["gleam"], requirements = ["envoy", "exception", "filepath", "gleam_crypto", "gleam_stdlib", "simplifile"], otp_app = "temporary", source = "hex", outer_checksum = "51C0FEF4D72CE7CA507BD188B21C1F00695B3D5B09D7DFE38240BFD3A8E1E9B3" },
41+
{ name = "tzif", version = "1.1.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib", "gleam_time", "simplifile"], otp_app = "tzif", source = "hex", outer_checksum = "04C8EBAFB7F6A6E61B8B0C16E06F96E7C7F76C31172EF07204CE9813B2E54AD2" },
4142
{ name = "webls", version = "1.6.2", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time", "gtempo"], otp_app = "webls", source = "hex", outer_checksum = "515CA019E3B09FF7605D44ABBCDCF8FF56EC528AA29017741E0184278E3423DD" },
43+
{ name = "zones", version = "1.26010.1", build_tools = ["gleam"], requirements = ["tzif"], otp_app = "zones", source = "hex", outer_checksum = "6F79507E564D1F578C282BFE9241558744818D5957FE5CB859D64B5096F4211D" },
4244
]
4345

4446
[requirements]
@@ -63,4 +65,6 @@ smalto = { version = ">= 3.0.0 and < 4.0.0" }
6365
smalto_lustre = { version = ">= 3.0.0 and < 4.0.0" }
6466
str = { version = ">= 2.0.1 and < 3.0.0" }
6567
temporary = { version = ">= 1.0.0 and < 2.0.0" }
68+
tzif = { version = ">= 1.1.2 and < 2.0.0" }
6669
webls = { version = ">= 1.6.2 and < 2.0.0" }
70+
zones = { version = ">= 1.26010.1 and < 2.0.0" }

src/blogatto/internal/date.gleam

Lines changed: 117 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,144 @@
22
////
33
//// Parses date strings in the `YYYY-MM-DD HH:MM:SS` format into
44
//// `gleam/time/timestamp.Timestamp` values using the tempo library.
5-
//// All dates are interpreted as UTC.
5+
//// Dates without a timezone are interpreted as UTC.
6+
//// An optional timezone can be specified as a UTC offset (e.g. `+02:00`)
7+
//// or as an IANA timezone name (e.g. `Europe/Helsinki`).
68

79
import blogatto/error
10+
import gleam/int
11+
import gleam/list
812
import gleam/result
13+
import gleam/string
14+
import gleam/time/duration
915
import gleam/time/timestamp.{type Timestamp}
1016
import tempo
1117
import tempo/datetime
1218
import tempo/naive_datetime
19+
import tzif/database
1320

14-
/// Parse a date string in `YYYY-MM-DD HH:MM:SS` format into a UTC timestamp.
21+
/// Parse a date string into a UTC timestamp.
1522
///
16-
/// The date and time components are separated by a single space. The time
17-
/// portion uses 24-hour format. Returns `FrontmatterInvalidDate` if the
18-
/// string does not match the expected format or contains out-of-range values.
23+
/// Accepted formats:
24+
/// - `YYYY-MM-DD HH:MM:SS` — interpreted as UTC
25+
/// - `YYYY-MM-DD HH:MM:SS +HH:MM` or `YYYY-MM-DD HH:MM:SS -HH:MM` — UTC offset
26+
/// - `YYYY-MM-DD HH:MM:SS Continent/City` — IANA timezone name
27+
///
28+
/// Returns `FrontmatterInvalidDate` if the string does not match any
29+
/// expected format or contains out-of-range values.
1930
///
2031
/// ## Examples
2132
///
2233
/// ```gleam
2334
/// parse("2023-01-25 00:00:00")
2435
/// // -> Ok(timestamp representing 2023-01-25T00:00:00Z)
36+
///
37+
/// parse("2023-01-25 02:00:00 +02:00")
38+
/// // -> Ok(timestamp representing 2023-01-25T00:00:00Z)
39+
///
40+
/// parse("2023-01-25 02:00:00 Europe/Helsinki")
41+
/// // -> Ok(timestamp representing 2023-01-25T00:00:00Z)
2542
/// ```
2643
pub fn parse(raw: String) -> Result(Timestamp, error.BlogattoError) {
44+
let parts = string.split(raw, " ")
45+
case list.length(parts) {
46+
// "YYYY-MM-DD HH:MM:SS" — no timezone, interpret as UTC
47+
2 -> parse_naive(raw)
48+
// "YYYY-MM-DD HH:MM:SS <tz>" — has timezone part
49+
3 -> parse_with_tz(raw)
50+
_ -> Error(error.FrontmatterInvalidDate(raw))
51+
}
52+
}
53+
54+
/// Parse a date string without a timezone (e.g. "2023-01-25 00:00:00") into a UTC timestamp.
55+
fn parse_naive(raw: String) -> Result(Timestamp, error.BlogattoError) {
2756
let fmt = tempo.CustomNaive(format: "YYYY-MM-DD HH:mm:ss")
2857
raw
2958
|> naive_datetime.parse(fmt)
3059
|> result.map(naive_datetime.as_utc)
3160
|> result.map(datetime.to_timestamp)
3261
|> result.map_error(fn(_) { error.FrontmatterInvalidDate(raw) })
3362
}
63+
64+
/// Parse a date string with a timezone at the end.
65+
/// Handles both UTC offsets (e.g. "+02:00") and IANA timezone names
66+
/// (e.g. "Europe/Helsinki") by first resolving names to offsets.
67+
fn parse_with_tz(raw: String) -> Result(Timestamp, error.BlogattoError) {
68+
let fmt = tempo.Custom(format: "YYYY-MM-DD HH:mm:ss Z")
69+
raw
70+
|> resolve_tz_name
71+
|> datetime.parse(fmt)
72+
|> result.map(datetime.to_timestamp)
73+
|> result.map_error(fn(_) { error.FrontmatterInvalidDate(raw) })
74+
}
75+
76+
/// If the string ends with an IANA timezone name (e.g. "Europe/Helsinki"),
77+
/// replaces it with the equivalent UTC offset (e.g. "+02:00").
78+
/// If the last token does not contain a "/" it is returned unchanged,
79+
/// allowing offset formats like "+02:00" to pass through to tempo.
80+
fn resolve_tz_name(raw: String) -> String {
81+
case string.split(raw, " ") {
82+
[date_part, time_part, tz_part] ->
83+
case string.contains(tz_part, "/") {
84+
True -> {
85+
let datetime_part = date_part <> " " <> time_part
86+
case resolve_offset(datetime_part, tz_part) {
87+
Ok(offset_str) -> datetime_part <> " " <> offset_str
88+
// If resolution fails, return unchanged so parse_with_tz fails
89+
// with FrontmatterInvalidDate
90+
Error(_) -> raw
91+
}
92+
}
93+
// Not a tz name, might be an offset like "+02:00" — pass through
94+
False -> raw
95+
}
96+
_ -> raw
97+
}
98+
}
99+
100+
/// Look up the UTC offset for a timezone name at the given approximate datetime.
101+
/// The datetime string is parsed as naive (UTC) to get a reference timestamp
102+
/// for the lookup, which is close enough for correct DST resolution in
103+
/// nearly all cases.
104+
fn resolve_offset(datetime_str: String, tz_name: String) -> Result(String, Nil) {
105+
let fmt = tempo.CustomNaive(format: "YYYY-MM-DD HH:mm:ss")
106+
107+
use naive <- result.try(
108+
naive_datetime.parse(datetime_str, fmt) |> result.replace_error(Nil),
109+
)
110+
let approximate_ts = naive |> naive_datetime.as_utc |> datetime.to_timestamp
111+
112+
let db = get_tz_database()
113+
use zone_params <- result.try(
114+
database.get_zone_parameters(approximate_ts, tz_name, db)
115+
|> result.replace_error(Nil),
116+
)
117+
118+
Ok(format_offset(zone_params.offset))
119+
}
120+
121+
/// Format a Duration offset as "+HH:MM" or "-HH:MM".
122+
fn format_offset(offset: duration.Duration) -> String {
123+
let total_seconds = {
124+
let #(seconds, _) = duration.to_seconds_and_nanoseconds(offset)
125+
seconds
126+
}
127+
let sign = case total_seconds >= 0 {
128+
True -> "+"
129+
False -> "-"
130+
}
131+
let abs_seconds = int.absolute_value(total_seconds)
132+
let hours = abs_seconds / 3600
133+
let minutes = { abs_seconds % 3600 } / 60
134+
135+
sign
136+
<> int.to_string(hours)
137+
|> string.pad_start(2, "0")
138+
<> ":"
139+
<> int.to_string(minutes) |> string.pad_start(2, "0")
140+
}
141+
142+
/// Get the timezone database, cached via persistent_term.
143+
/// The database is loaded from the prebuilt zones package on first access.
144+
@external(erlang, "blogatto_ffi", "get_tz_database")
145+
fn get_tz_database() -> database.TzDatabase

src/blogatto_ffi.erl

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-module(blogatto_ffi).
2+
-export([get_tz_database/0]).
3+
4+
get_tz_database() ->
5+
try persistent_term:get(blogatto_tz_database)
6+
catch
7+
error:badarg ->
8+
Db = zones:database(),
9+
persistent_term:put(blogatto_tz_database, Db),
10+
Db
11+
end.

test/blogatto/internal/date_test.gleam

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,83 @@ pub fn parse_returns_error_for_wrong_separator_test() {
6565
|> should.be_error()
6666
|> should.equal(error.FrontmatterInvalidDate("2023/01/25 00:00:00"))
6767
}
68+
69+
// Timezone offset tests
70+
71+
pub fn parse_datetime_with_positive_utc_offset_test() {
72+
// 2023-01-25 02:00:00 +02:00 should be 2023-01-25 00:00:00 UTC
73+
date.parse("2023-01-25 02:00:00 +02:00")
74+
|> should.be_ok()
75+
|> should.equal(timestamp.from_calendar(
76+
date: calendar.Date(year: 2023, month: calendar.January, day: 25),
77+
time: calendar.TimeOfDay(hours: 0, minutes: 0, seconds: 0, nanoseconds: 0),
78+
offset: duration.seconds(0),
79+
))
80+
}
81+
82+
pub fn parse_datetime_with_negative_utc_offset_test() {
83+
// 2023-01-24 19:00:00 -05:00 should be 2023-01-25 00:00:00 UTC
84+
date.parse("2023-01-24 19:00:00 -05:00")
85+
|> should.be_ok()
86+
|> should.equal(timestamp.from_calendar(
87+
date: calendar.Date(year: 2023, month: calendar.January, day: 25),
88+
time: calendar.TimeOfDay(hours: 0, minutes: 0, seconds: 0, nanoseconds: 0),
89+
offset: duration.seconds(0),
90+
))
91+
}
92+
93+
pub fn parse_datetime_with_utc_offset_zero_test() {
94+
date.parse("2023-01-25 00:00:00 +00:00")
95+
|> should.be_ok()
96+
|> should.equal(timestamp.from_calendar(
97+
date: calendar.Date(year: 2023, month: calendar.January, day: 25),
98+
time: calendar.TimeOfDay(hours: 0, minutes: 0, seconds: 0, nanoseconds: 0),
99+
offset: duration.seconds(0),
100+
))
101+
}
102+
103+
// IANA timezone name tests
104+
105+
pub fn parse_datetime_with_iana_tz_name_test() {
106+
// Europe/Helsinki is UTC+2 in January (EET, no DST)
107+
// 2023-01-25 02:00:00 Europe/Helsinki should be 2023-01-25 00:00:00 UTC
108+
date.parse("2023-01-25 02:00:00 Europe/Helsinki")
109+
|> should.be_ok()
110+
|> should.equal(timestamp.from_calendar(
111+
date: calendar.Date(year: 2023, month: calendar.January, day: 25),
112+
time: calendar.TimeOfDay(hours: 0, minutes: 0, seconds: 0, nanoseconds: 0),
113+
offset: duration.seconds(0),
114+
))
115+
}
116+
117+
pub fn parse_datetime_with_negative_iana_tz_test() {
118+
// America/New_York is UTC-5 in January (EST, no DST)
119+
// 2023-01-24 19:00:00 America/New_York should be 2023-01-25 00:00:00 UTC
120+
date.parse("2023-01-24 19:00:00 America/New_York")
121+
|> should.be_ok()
122+
|> should.equal(timestamp.from_calendar(
123+
date: calendar.Date(year: 2023, month: calendar.January, day: 25),
124+
time: calendar.TimeOfDay(hours: 0, minutes: 0, seconds: 0, nanoseconds: 0),
125+
offset: duration.seconds(0),
126+
))
127+
}
128+
129+
pub fn parse_datetime_with_iana_tz_during_dst_test() {
130+
// America/New_York is UTC-4 in July (EDT, DST active)
131+
// 2023-07-25 20:00:00 America/New_York should be 2023-07-26 00:00:00 UTC
132+
date.parse("2023-07-25 20:00:00 America/New_York")
133+
|> should.be_ok()
134+
|> should.equal(timestamp.from_calendar(
135+
date: calendar.Date(year: 2023, month: calendar.July, day: 26),
136+
time: calendar.TimeOfDay(hours: 0, minutes: 0, seconds: 0, nanoseconds: 0),
137+
offset: duration.seconds(0),
138+
))
139+
}
140+
141+
pub fn parse_returns_error_for_invalid_tz_name_test() {
142+
date.parse("2023-01-25 00:00:00 Invalid/Timezone")
143+
|> should.be_error()
144+
|> should.equal(error.FrontmatterInvalidDate(
145+
"2023-01-25 00:00:00 Invalid/Timezone",
146+
))
147+
}

0 commit comments

Comments
 (0)