From 6c57b56b74c77bf41b2c559dd19c68246a88b263 Mon Sep 17 00:00:00 2001 From: karitham Date: Sun, 19 Jul 2026 12:12:40 +0200 Subject: [PATCH] refactor: better date handling, with client side hydration --- client/src/browser.gleam | 3 + client/src/browser_ffi.mjs | 21 ++++ client/src/refresh.gleam | 2 + gleam.toml | 1 + manifest.toml | 2 + shared/gleam.toml | 1 + shared/manifest.toml | 1 + shared/src/date.gleam | 112 ++++++++++++----- shared/src/plays.gleam | 29 +++-- shared/src/repos.gleam | 29 ++++- shared/test/date_test.gleam | 120 ++++++++++++++++++ src/blog_tools.gleam | 52 +------- src/data/frontmatter.gleam | 147 +++------------------- src/view/components/post_view.gleam | 4 +- src/view/layout.gleam | 5 +- test/data/frontmatter_test.gleam | 24 +++- test/view/components/post_view_test.gleam | 41 ------ 17 files changed, 327 insertions(+), 267 deletions(-) create mode 100644 shared/test/date_test.gleam delete mode 100644 test/view/components/post_view_test.gleam diff --git a/client/src/browser.gleam b/client/src/browser.gleam index 4446a3a..04fad94 100644 --- a/client/src/browser.gleam +++ b/client/src/browser.gleam @@ -21,3 +21,6 @@ pub fn is_visible() -> Bool @external(javascript, "./browser_ffi.mjs", "on_visibility_change") pub fn on_visibility_change(callback: fn(Bool) -> Nil) -> Nil + +@external(javascript, "./browser_ffi.mjs", "localize_dates") +pub fn localize_dates() -> Nil diff --git a/client/src/browser_ffi.mjs b/client/src/browser_ffi.mjs index 1bce6dd..6bef4ab 100644 --- a/client/src/browser_ffi.mjs +++ b/client/src/browser_ffi.mjs @@ -61,3 +61,24 @@ export function on_visibility_change(callback) { callback(!document.hidden); }); } + +// Re-localize every `[data-iso]` play-time element from its build-time UTC +// rendering into the visitor's local timezone. The element already shows +// correct UTC text; we only swap the text content to local HH:MM. +export function localize_dates() { + var els = document.querySelectorAll("[data-iso]"); + for (var i = 0; i < els.length; i++) { + var el = els[i]; + var iso = el.getAttribute("data-iso"); + if (!iso) continue; + try { + var d = new Date(iso); + if (isNaN(d.getTime())) continue; + var h = d.getHours(); + var m = d.getMinutes(); + el.textContent = (h < 10 ? "0" : "") + h + ":" + (m < 10 ? "0" : "") + m; + } catch (_) { + // Keep the build-time UTC text on any failure. + } + } +} diff --git a/client/src/refresh.gleam b/client/src/refresh.gleam index 3ea8a1b..b15d0ea 100644 --- a/client/src/refresh.gleam +++ b/client/src/refresh.gleam @@ -21,6 +21,7 @@ const plays_poll_ms = 30_000 /// Wires up initial fetches, periodic poll, and visibility listener. pub fn start() -> Nil { refresh_all() + browser.localize_dates() browser.set_interval(plays_poll_ms, poll_tick) browser.on_visibility_change(on_visibility_change) } @@ -97,6 +98,7 @@ fn commit_plays(plays_data: List(AlphaFeedPlay)) -> Nil { "plays", dynamic.render(plays_view.plays_section(plays_data)), ) + browser.localize_dates() mark_plays_fresh() } diff --git a/gleam.toml b/gleam.toml index a872d92..35bb522 100644 --- a/gleam.toml +++ b/gleam.toml @@ -13,6 +13,7 @@ mork = ">= 1.12.1 and < 2.0.0" argv = ">= 1.0.0 and < 2.0.0" shared = { path = "shared" } atproto_client = ">= 0.1.0 and < 1.0.0" +gleam_time = ">= 1.0.0 and < 2.0.0" [dev_dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index f7c59ec..a1b2e08 100644 --- a/manifest.toml +++ b/manifest.toml @@ -160,6 +160,7 @@ packages = [ "atproto_client", "gleam_json", "gleam_stdlib", + "gleam_time", "lustre", ], source = "local", path = "shared" }, { name = "simplifile", version = "2.5.0", build_tools = [ @@ -184,6 +185,7 @@ gleam_http = { version = ">= 4.0.0 and < 5.0.0" } gleam_httpc = { version = ">= 5.0.0 and < 6.0.0" } gleam_json = { version = ">= 2.0.0 and < 4.0.0" } gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } +gleam_time = { version = ">= 1.0.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } lustre = { version = ">= 4.0.0 and < 6.0.0" } mork = { version = ">= 1.12.1 and < 2.0.0" } diff --git a/shared/gleam.toml b/shared/gleam.toml index f48be01..eaef371 100644 --- a/shared/gleam.toml +++ b/shared/gleam.toml @@ -6,6 +6,7 @@ gleam_stdlib = ">= 1.0.0 and < 2.0.0" lustre = ">= 4.0.0 and < 6.0.0" gleam_json = ">= 3.1.0 and < 4.0.0" atproto_client = ">= 0.1.0 and < 1.0.0" +gleam_time = ">= 1.0.0 and < 2.0.0" [dev_dependencies] gleeunit = ">= 1.9.0 and < 2.0.0" diff --git a/shared/manifest.toml b/shared/manifest.toml index 64785d0..b4baea8 100644 --- a/shared/manifest.toml +++ b/shared/manifest.toml @@ -103,5 +103,6 @@ packages = [ atproto_client = { version = ">= 0.1.0 and < 1.0.0" } gleam_json = { version = ">= 3.1.0 and < 4.0.0" } gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } +gleam_time = { version = ">= 1.0.0 and < 2.0.0" } gleeunit = { version = ">= 1.9.0 and < 2.0.0" } lustre = { version = ">= 4.0.0 and < 6.0.0" } diff --git a/shared/src/date.gleam b/shared/src/date.gleam index 7c44c08..e8f8e99 100644 --- a/shared/src/date.gleam +++ b/shared/src/date.gleam @@ -1,38 +1,90 @@ -//// Pure formatters shared between the SSG and the client. +//// Shared date formatting utilities. -import gleam/option.{type Option, None, Some} -import gleam/string +import gleam/float +import gleam/int +import gleam/time/calendar +import gleam/time/timestamp -/// Format a `YYYY-MM-DD` date string as `Month DD, YYYY` (e.g. -/// `"2024-09-21"` → `"September 21, 2024"`). Returns the input -/// unchanged when it can't be parsed so a malformed date surfaces -/// in the page rather than silently becoming a wrong-looking but -/// plausible string. -pub fn format_date(date_str: String) -> String { - case string.split(date_str, "-") { - [year, month, day] -> - case month_name(month) { - Some(name) -> name <> " " <> day <> ", " <> year - None -> date_str - } - _ -> date_str +/// Format a `timestamp.Timestamp` as `Month DD, YYYY` in UTC. +pub fn format_month_day_year(ts: timestamp.Timestamp) -> String { + let #(date, _) = timestamp.to_calendar(ts, calendar.utc_offset) + calendar.month_to_string(date.month) + <> " " + <> int.to_string(date.day) + <> ", " + <> int.to_string(date.year) +} + +/// Format a `YYYY-MM-DD` string as `Month DD, YYYY` via `gleam_time`. +/// Returns the original string unmodified on parse failure. +pub fn format_ymd(date_str: String) -> String { + case timestamp.parse_rfc3339(date_str <> "T00:00:00Z") { + Ok(ts) -> format_month_day_year(ts) + Error(_) -> date_str + } +} + +/// Format a `YYYY-MM-DD` string as an RFC 822 timestamp for RSS +/// ``, e.g. `"Sat, 21 Sep 2024 00:00:00 +0000"`. The +/// weekday is derived from the Unix epoch seconds via `gleam_time`. +pub fn to_rfc822(date_str: String) -> String { + case timestamp.parse_rfc3339(date_str <> "T00:00:00Z") { + Ok(ts) -> { + let #(date, _) = timestamp.to_calendar(ts, calendar.utc_offset) + let seconds = timestamp.to_unix_seconds(ts) |> float.round + let weekday = weekday_name(seconds) + let month = month_abbr(date.month) + weekday + <> ", " + <> pad2(date.day) + <> " " + <> month + <> " " + <> int.to_string(date.year) + <> " 00:00:00 +0000" + } + Error(_) -> date_str } } -fn month_name(month: String) -> Option(String) { +/// Derive the three-letter weekday name from Unix epoch seconds. +/// 1970-01-01 (unix epoch) is a Thursday (index 4). +fn weekday_name(unix_seconds: Int) -> String { + let w = { 4 + unix_seconds / 86_400 } % 7 + case w { + 0 -> "Sun" + 1 -> "Mon" + 2 -> "Tue" + 3 -> "Wed" + 4 -> "Thu" + 5 -> "Fri" + 6 -> "Sat" + _ -> "Thu" + } +} + +/// Three-letter month abbreviation for RFC 822. +fn month_abbr(month: calendar.Month) -> String { case month { - "01" | "1" -> Some("January") - "02" | "2" -> Some("February") - "03" | "3" -> Some("March") - "04" | "4" -> Some("April") - "05" | "5" -> Some("May") - "06" | "6" -> Some("June") - "07" | "7" -> Some("July") - "08" | "8" -> Some("August") - "09" | "9" -> Some("September") - "10" -> Some("October") - "11" -> Some("November") - "12" -> Some("December") - _ -> None + calendar.January -> "Jan" + calendar.February -> "Feb" + calendar.March -> "Mar" + calendar.April -> "Apr" + calendar.May -> "May" + calendar.June -> "Jun" + calendar.July -> "Jul" + calendar.August -> "Aug" + calendar.September -> "Sep" + calendar.October -> "Oct" + calendar.November -> "Nov" + calendar.December -> "Dec" + } +} + +/// Zero-pad an integer to two digits. +pub fn pad2(n: Int) -> String { + case n < 10 { + True -> "0" <> int.to_string(n) + False -> int.to_string(n) } } diff --git a/shared/src/plays.gleam b/shared/src/plays.gleam index 2fa9ecb..f7b4656 100644 --- a/shared/src/plays.gleam +++ b/shared/src/plays.gleam @@ -1,8 +1,11 @@ +import date import gen/alpha/feed/play.{type AlphaFeedPlay, type ArtistView} import gleam/list import gleam/option.{unwrap} import gleam/string -import lustre/attribute.{class, href, target} +import gleam/time/calendar +import gleam/time/timestamp +import lustre/attribute.{attribute, class, href, target} import lustre/element.{type Element, none, text} import lustre/element/html.{a, div, span} import section @@ -21,7 +24,7 @@ pub fn plays_section(plays: List(AlphaFeedPlay)) -> Element(msg) { } fn render_play_row(play: AlphaFeedPlay) -> Element(msg) { - let time = extract_time(play.played_time) + let #(time, iso) = format_play_time(play.played_time) let artists_str = play.artists @@ -32,7 +35,13 @@ fn render_play_row(play: AlphaFeedPlay) -> Element(msg) { let release_name = unwrap(play.release_name, "") div([class("play-row")], [ - span([class("play-time")], [text(time)]), + span( + [ + class("play-time"), + attribute("data-iso", iso), + ], + [text(time)], + ), span( [ class("play-track"), @@ -55,10 +64,14 @@ fn render_play_row(play: AlphaFeedPlay) -> Element(msg) { ]) } -fn extract_time(iso: String) -> String { - // "2026-07-18T15:33:46Z" → "15:33" - case string.split(iso, "T") { - [_, rest] -> string.slice(rest, 0, 5) - _ -> "" +/// Render a play's `played_time` as `HH:MM` in UTC, and return the +/// original ISO string for client-side re-localization. +fn format_play_time(iso: String) -> #(String, String) { + case timestamp.parse_rfc3339(iso) { + Ok(ts) -> { + let #(_, time) = timestamp.to_calendar(ts, calendar.utc_offset) + #(date.pad2(time.hours) <> ":" <> date.pad2(time.minutes), iso) + } + Error(_) -> #("", iso) } } diff --git a/shared/src/repos.gleam b/shared/src/repos.gleam index 9db6620..be08ea9 100644 --- a/shared/src/repos.gleam +++ b/shared/src/repos.gleam @@ -1,25 +1,38 @@ import card +import date import gen/repo.{type Repo} import gleam/list import gleam/option.{Some, unwrap} +import gleam/order import gleam/string +import gleam/time/timestamp import lustre/element.{type Element, none} import section const max_repos = 5 /// Dedupe by repo_did, drop auto-generated hash names, sort newest -/// first, and take the top N. Pure — testable in isolation from -/// `repos_section`'s rendering. +/// first, and take the top N. Sorting uses the parsed timestamp so +/// different source offsets compare correctly. pub fn select_top_repos(repos: List(Repo)) -> List(Repo) { repos |> dedup_by_did - |> list.sort(by: fn(a: Repo, b: Repo) { - string.compare(b.created_at, a.created_at) - }) + |> list.sort(by: compare_repos_newest_first) |> list.take(max_repos) } +fn compare_repos_newest_first(a: Repo, b: Repo) -> order.Order { + case + timestamp.parse_rfc3339(a.created_at), + timestamp.parse_rfc3339(b.created_at) + { + Ok(ta), Ok(tb) -> timestamp.compare(tb, ta) + Ok(_), Error(_) -> order.Gt + Error(_), Ok(_) -> order.Lt + Error(_), Error(_) -> string.compare(b.created_at, a.created_at) + } +} + pub fn repos_section(repos: List(Repo)) -> Element(msg) { case select_top_repos(repos) { [] -> none() @@ -51,11 +64,15 @@ fn is_hash_name(name: String) -> Bool { } fn render_repo_card(repo: Repo) -> Element(msg) { + let date_str = case timestamp.parse_rfc3339(repo.created_at) { + Ok(ts) -> date.format_month_day_year(ts) + Error(_) -> repo.created_at + } card.card( title_href: "https://tangled.org/" <> repo.repo_did, title_text: repo.name, title_target: Some("_blank"), - date: repo.created_at, + date: date_str, description: unwrap(repo.description, ""), topics: unwrap(repo.topics, []), ) diff --git a/shared/test/date_test.gleam b/shared/test/date_test.gleam new file mode 100644 index 0000000..46b87df --- /dev/null +++ b/shared/test/date_test.gleam @@ -0,0 +1,120 @@ +import date +import gleam/time/timestamp +import gleeunit/should + +// --- pad2 --- + +pub fn pad2_single_digit_test() { + date.pad2(0) |> should.equal("00") + date.pad2(5) |> should.equal("05") + date.pad2(9) |> should.equal("09") +} + +pub fn pad2_double_digit_test() { + date.pad2(10) |> should.equal("10") + date.pad2(23) |> should.equal("23") + date.pad2(59) |> should.equal("59") +} + +pub fn pad2_large_numbers_test() { + date.pad2(100) |> should.equal("100") + date.pad2(999) |> should.equal("999") +} + +// --- format_month_day_year --- + +fn ts(iso: String) -> timestamp.Timestamp { + let assert Ok(t) = timestamp.parse_rfc3339(iso) + t +} + +pub fn format_month_day_year_january_test() { + ts("2026-01-15T00:00:00Z") + |> date.format_month_day_year + |> should.equal("January 15, 2026") +} + +pub fn format_month_day_year_december_test() { + ts("2026-12-31T23:59:59Z") + |> date.format_month_day_year + |> should.equal("December 31, 2026") +} + +pub fn format_month_day_year_midnight_test() { + // Midnight is still the same day in UTC + ts("2026-07-04T00:00:00Z") + |> date.format_month_day_year + |> should.equal("July 4, 2026") +} + +pub fn format_month_day_year_non_utc_offset_test() { + // An RFC 3339 timestamp with +03:00 offset — to_calendar with + // utc_offset converts it to UTC first, so July 4 03:00+03 is + // still July 4 00:00 UTC. + ts("2026-07-04T03:00:00+03:00") + |> date.format_month_day_year + |> should.equal("July 4, 2026") +} + +pub fn format_month_day_year_utc_minus_offset_test() { + // 2026-07-03T22:00:00-02:00 is 2026-07-04T00:00:00Z + ts("2026-07-03T22:00:00-02:00") + |> date.format_month_day_year + |> should.equal("July 4, 2026") +} + +pub fn format_month_day_year_leap_year_test() { + ts("2028-02-29T12:00:00Z") + |> date.format_month_day_year + |> should.equal("February 29, 2028") +} + +// --- format_ymd --- + +pub fn format_ymd_basic_test() { + date.format_ymd("2026-07-18") |> should.equal("July 18, 2026") +} + +pub fn format_ymd_january_first_test() { + date.format_ymd("2024-01-01") |> should.equal("January 1, 2024") +} + +pub fn format_ymd_december_test() { + date.format_ymd("2026-12-31") |> should.equal("December 31, 2026") +} + +pub fn format_ymd_invalid_returns_original_test() { + date.format_ymd("garbage") |> should.equal("garbage") + date.format_ymd("") |> should.equal("") + date.format_ymd("2024/01/01") |> should.equal("2024/01/01") +} + +// --- to_rfc822 --- + +pub fn to_rfc822_basic_test() { + // 2024-09-21 is a Saturday + date.to_rfc822("2024-09-21") + |> should.equal("Sat, 21 Sep 2024 00:00:00 +0000") +} + +pub fn to_rfc822_weekday_varies_test() { + // 2024-09-23 is a Monday + date.to_rfc822("2024-09-23") + |> should.equal("Mon, 23 Sep 2024 00:00:00 +0000") +} + +pub fn to_rfc822_january_first_1970_test() { + // 1970-01-01 is a Thursday + date.to_rfc822("1970-01-01") + |> should.equal("Thu, 01 Jan 1970 00:00:00 +0000") +} + +pub fn to_rfc822_leap_day_test() { + // 2028-02-29 is a Tuesday + date.to_rfc822("2028-02-29") + |> should.equal("Tue, 29 Feb 2028 00:00:00 +0000") +} + +pub fn to_rfc822_invalid_returns_original_test() { + date.to_rfc822("not-a-date") |> should.equal("not-a-date") +} diff --git a/src/blog_tools.gleam b/src/blog_tools.gleam index 436262f..828fa0f 100644 --- a/src/blog_tools.gleam +++ b/src/blog_tools.gleam @@ -13,6 +13,7 @@ //// For a one-command build, use `make build` which chains them. import build +import date import gleam/int import gleam/io import gleam/list @@ -111,45 +112,9 @@ pub fn slugify(input: String) -> String { } fn slugify_char(c: String) -> String { - case c { - "a" - | "b" - | "c" - | "d" - | "e" - | "f" - | "g" - | "h" - | "i" - | "j" - | "k" - | "l" - | "m" - | "n" - | "o" - | "p" - | "q" - | "r" - | "s" - | "t" - | "u" - | "v" - | "w" - | "x" - | "y" - | "z" - | "0" - | "1" - | "2" - | "3" - | "4" - | "5" - | "6" - | "7" - | "8" - | "9" - | "-" -> c - _ -> "-" + case string.contains("abcdefghijklmnopqrstuvwxyz0123456789-", c) { + True -> c + False -> "-" } } @@ -191,7 +156,7 @@ separately; for a one-shot build use `make build`.", /// so the scaffolded post sorts to the top of the timeline. fn template(slug: String) -> String { let #(y, m, d) = today() - let date = int.to_string(y) <> "-" <> pad2(m) <> "-" <> pad2(d) + let date = int.to_string(y) <> "-" <> date.pad2(m) <> "-" <> date.pad2(d) "---\n" <> "title: " <> title_from_slug(slug) @@ -223,13 +188,6 @@ fn capitalize(word: String) -> String { } } -fn pad2(n: Int) -> String { - case n < 10 { - True -> "0" <> int.to_string(n) - False -> int.to_string(n) - } -} - @external(erlang, "erlang", "date") fn erlang_date() -> #(Int, Int, Int) diff --git a/src/data/frontmatter.gleam b/src/data/frontmatter.gleam index 20e6565..23745c6 100644 --- a/src/data/frontmatter.gleam +++ b/src/data/frontmatter.gleam @@ -16,7 +16,9 @@ import data/model.{type Post, Post} import data/util import gleam/list +import gleam/result import gleam/string +import gleam/time/timestamp import mork pub type ParseError { @@ -48,11 +50,11 @@ fn parse_with_frontmatter( let draft_str = util.extract_field(lines, "draft:", "false") let image = util.extract_field(lines, "image:", "") - case title == "", date == "", is_iso_date(date) { + case title == "", date == "", parse_article_date(date) { True, _, _ -> Error(MissingField(slug, "title")) _, True, _ -> Error(MissingField(slug, "date")) - _, _, False -> Error(InvalidDate(slug, date)) - False, False, True -> + _, _, Error(_) -> Error(InvalidDate(slug, date)) + False, False, Ok(_) -> Ok(build_post( slug, title, @@ -121,34 +123,16 @@ fn parse_bool(s: String) -> Bool { } } -/// Check that a string matches the `YYYY-MM-DD` shape. Does not -/// validate calendar correctness (e.g. "2024-02-31" passes). -pub fn is_iso_date(s: String) -> Bool { - case string.split(s, on: "-") { - [y, m, d] -> - string.length(y) == 4 - && string.length(m) == 2 - && string.length(d) == 2 - && is_all_digits(y) - && is_all_digits(m) - && is_all_digits(d) - _ -> False - } +/// Validate a `YYYY-MM-DD` or RFC 3339 frontmatter date via `gleam_time`. +/// The raw string is still stored on `Post` for RSS/OG output; this is +/// only used to fail the build loudly on a malformed date. +fn parse_article_date(s: String) -> Result(timestamp.Timestamp, Nil) { + timestamp.parse_rfc3339(s <> "T00:00:00Z") } -fn is_all_digits(s: String) -> Bool { - case s { - "" -> False - _ -> s |> string.to_graphemes |> list.all(is_digit_char) - } -} +const slug_chars = "abcdefghijklmnopqrstuvwxyz0123456789-" -fn is_digit_char(c: String) -> Bool { - case c { - "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" -> True - _ -> False - } -} +const slug_start_chars = "abcdefghijklmnopqrstuvwxyz0123456789" /// Validates that a string is a usable post slug: starts with a /// lowercase letter or digit, contains only lowercase letters, @@ -156,105 +140,12 @@ fn is_digit_char(c: String) -> Bool { pub fn is_valid_slug(slug: String) -> Bool { case slug { "" -> False - _ -> - slug - |> string.to_graphemes - |> list.all(is_slug_char) - && is_slug_start(slug |> string.first |> result_unwrap("")) - } -} - -fn is_slug_char(c: String) -> Bool { - case c { - "a" - | "b" - | "c" - | "d" - | "e" - | "f" - | "g" - | "h" - | "i" - | "j" - | "k" - | "l" - | "m" - | "n" - | "o" - | "p" - | "q" - | "r" - | "s" - | "t" - | "u" - | "v" - | "w" - | "x" - | "y" - | "z" - | "0" - | "1" - | "2" - | "3" - | "4" - | "5" - | "6" - | "7" - | "8" - | "9" - | "-" -> True - _ -> False - } -} - -fn is_slug_start(c: String) -> Bool { - case c { - "a" - | "b" - | "c" - | "d" - | "e" - | "f" - | "g" - | "h" - | "i" - | "j" - | "k" - | "l" - | "m" - | "n" - | "o" - | "p" - | "q" - | "r" - | "s" - | "t" - | "u" - | "v" - | "w" - | "x" - | "y" - | "z" - | "0" - | "1" - | "2" - | "3" - | "4" - | "5" - | "6" - | "7" - | "8" - | "9" -> True - _ -> False - } -} - -// Silly wrapper to make the type checker happy when string.first -// returns Error — the empty string default makes is_slug_start -// return False, which is what we want. -fn result_unwrap(r: Result(a, b), default: a) -> a { - case r { - Ok(v) -> v - Error(_) -> default + _ -> { + let first = string.first(slug) |> result.unwrap("") + string.contains(slug_start_chars, first) + && list.all(string.to_graphemes(slug), fn(c) { + string.contains(slug_chars, c) + }) + } } } diff --git a/src/view/components/post_view.gleam b/src/view/components/post_view.gleam index 371ead2..2b602fb 100644 --- a/src/view/components/post_view.gleam +++ b/src/view/components/post_view.gleam @@ -19,7 +19,7 @@ fn render_article_card(post: Post) -> Element(Nil) { title_href: "/posts/" <> post.slug <> "/", title_text: post.title, title_target: None, - date: date.format_date(post.date), + date: date.format_ymd(post.date), description: post.description, topics: post.tags, ) @@ -40,7 +40,7 @@ pub fn render_single(post: Post) -> Element(Nil) { div([class("post-meta")], [ span([], [ text("Written "), - span([class("emph")], [text(date.format_date(post.date))]), + span([class("emph")], [text(date.format_ymd(post.date))]), ]), tags, ]), diff --git a/src/view/layout.gleam b/src/view/layout.gleam index 899d4de..43a17bd 100644 --- a/src/view/layout.gleam +++ b/src/view/layout.gleam @@ -1,3 +1,4 @@ +import date import gleam/list import gleam/option.{type Option, None, Some} import gleam/string @@ -354,12 +355,12 @@ fn icon_email() -> Element(Nil) { pub fn rss_feed(posts: List(#(String, String, String, String))) -> String { let items = list.map(posts, fn(t) { - let #(title, description, slug, date) = t + let #(title, description, slug, date_str) = t " " <> title <> " " <> description <> " https://karitham.dev/posts/" <> slug <> "/ - " <> date <> " + " <> date.to_rfc822(date_str) <> " " }) diff --git a/test/data/frontmatter_test.gleam b/test/data/frontmatter_test.gleam index 188b28f..6e275c7 100644 --- a/test/data/frontmatter_test.gleam +++ b/test/data/frontmatter_test.gleam @@ -104,14 +104,32 @@ pub fn parse_malformed_date_fails_test() { pub fn parse_valid_iso_date_test() { let cases = ["2024-01-01", "2024-12-31", "2024-02-29", "1999-09-09"] list.each(cases, fn(good) { - frontmatter.is_iso_date(good) |> should.equal(True) + let post = + frontmatter.parse( + "valid-date", + "---\ntitle: t\ndate: " <> good <> "\n---\nbody", + ) + post |> should.be_ok() }) } pub fn parse_invalid_iso_date_test() { - let cases = ["", "2024", "2024-1", "2024-01", "2024-1-1", "abcd-ef-gh"] + let cases = [ + "", + "2024", + "2024-1", + "2024-01", + "2024-1-1", + "abcd-ef-gh", + "2024-02-30", + ] list.each(cases, fn(bad) { - frontmatter.is_iso_date(bad) |> should.equal(False) + let post = + frontmatter.parse( + "invalid-date", + "---\ntitle: t\ndate: " <> bad <> "\n---\nbody", + ) + post |> should.be_error() }) } diff --git a/test/view/components/post_view_test.gleam b/test/view/components/post_view_test.gleam deleted file mode 100644 index e3723dd..0000000 --- a/test/view/components/post_view_test.gleam +++ /dev/null @@ -1,41 +0,0 @@ -import date -import gleeunit/should - -pub fn format_date_standard_test() { - date.format_date("2024-09-21") - |> should.equal("September 21, 2024") -} - -pub fn format_date_january_test() { - date.format_date("2024-01-01") - |> should.equal("January 01, 2024") -} - -pub fn format_date_december_test() { - date.format_date("2024-12-31") - |> should.equal("December 31, 2024") -} - -pub fn format_date_different_year_test() { - date.format_date("2023-07-04") - |> should.equal("July 04, 2023") -} - -pub fn format_date_unparseable_month_test() { - // Out-of-range month — surface the input rather than silently - // emitting a wrong-looking but plausible string. (Previous - // implementation used the raw "13" as the month name, which - // produced "13 01, 2024" — almost-right enough to miss in review.) - date.format_date("2024-13-01") - |> should.equal("2024-13-01") -} - -pub fn format_date_malformed_test() { - date.format_date("garbage") - |> should.equal("garbage") -} - -pub fn format_date_short_test() { - date.format_date("2024-09") - |> should.equal("2024-09") -} -- 2.51.2