karitham.dev website! atproto & fancy ssg + csr & blog articles karitham.dev
atproto gleam blog nix
0

Configure Feed

Select the types of activity you want to include in your feed.

refactor: better date handling, with client side hydration

karitham (Jul 19, 2026, 12:26 PM +0200) 6c57b56b 074e4722

+327 -267
+3
client/src/browser.gleam
··· 21 21 22 22 @external(javascript, "./browser_ffi.mjs", "on_visibility_change") 23 23 pub fn on_visibility_change(callback: fn(Bool) -> Nil) -> Nil 24 + 25 + @external(javascript, "./browser_ffi.mjs", "localize_dates") 26 + pub fn localize_dates() -> Nil
+21
client/src/browser_ffi.mjs
··· 61 61 callback(!document.hidden); 62 62 }); 63 63 } 64 + 65 + // Re-localize every `[data-iso]` play-time element from its build-time UTC 66 + // rendering into the visitor's local timezone. The element already shows 67 + // correct UTC text; we only swap the text content to local HH:MM. 68 + export function localize_dates() { 69 + var els = document.querySelectorAll("[data-iso]"); 70 + for (var i = 0; i < els.length; i++) { 71 + var el = els[i]; 72 + var iso = el.getAttribute("data-iso"); 73 + if (!iso) continue; 74 + try { 75 + var d = new Date(iso); 76 + if (isNaN(d.getTime())) continue; 77 + var h = d.getHours(); 78 + var m = d.getMinutes(); 79 + el.textContent = (h < 10 ? "0" : "") + h + ":" + (m < 10 ? "0" : "") + m; 80 + } catch (_) { 81 + // Keep the build-time UTC text on any failure. 82 + } 83 + } 84 + }
+2
client/src/refresh.gleam
··· 21 21 /// Wires up initial fetches, periodic poll, and visibility listener. 22 22 pub fn start() -> Nil { 23 23 refresh_all() 24 + browser.localize_dates() 24 25 browser.set_interval(plays_poll_ms, poll_tick) 25 26 browser.on_visibility_change(on_visibility_change) 26 27 } ··· 97 98 "plays", 98 99 dynamic.render(plays_view.plays_section(plays_data)), 99 100 ) 101 + browser.localize_dates() 100 102 mark_plays_fresh() 101 103 } 102 104
+1
gleam.toml
··· 13 13 argv = ">= 1.0.0 and < 2.0.0" 14 14 shared = { path = "shared" } 15 15 atproto_client = ">= 0.1.0 and < 1.0.0" 16 + gleam_time = ">= 1.0.0 and < 2.0.0" 16 17 17 18 [dev_dependencies] 18 19 gleeunit = ">= 1.0.0 and < 2.0.0"
+2
manifest.toml
··· 160 160 "atproto_client", 161 161 "gleam_json", 162 162 "gleam_stdlib", 163 + "gleam_time", 163 164 "lustre", 164 165 ], source = "local", path = "shared" }, 165 166 { name = "simplifile", version = "2.5.0", build_tools = [ ··· 184 185 gleam_httpc = { version = ">= 5.0.0 and < 6.0.0" } 185 186 gleam_json = { version = ">= 2.0.0 and < 4.0.0" } 186 187 gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } 188 + gleam_time = { version = ">= 1.0.0 and < 2.0.0" } 187 189 gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 188 190 lustre = { version = ">= 4.0.0 and < 6.0.0" } 189 191 mork = { version = ">= 1.12.1 and < 2.0.0" }
+1
shared/gleam.toml
··· 6 6 lustre = ">= 4.0.0 and < 6.0.0" 7 7 gleam_json = ">= 3.1.0 and < 4.0.0" 8 8 atproto_client = ">= 0.1.0 and < 1.0.0" 9 + gleam_time = ">= 1.0.0 and < 2.0.0" 9 10 10 11 [dev_dependencies] 11 12 gleeunit = ">= 1.9.0 and < 2.0.0"
+1
shared/manifest.toml
··· 103 103 atproto_client = { version = ">= 0.1.0 and < 1.0.0" } 104 104 gleam_json = { version = ">= 3.1.0 and < 4.0.0" } 105 105 gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } 106 + gleam_time = { version = ">= 1.0.0 and < 2.0.0" } 106 107 gleeunit = { version = ">= 1.9.0 and < 2.0.0" } 107 108 lustre = { version = ">= 4.0.0 and < 6.0.0" }
+82 -30
shared/src/date.gleam
··· 1 - //// Pure formatters shared between the SSG and the client. 1 + //// Shared date formatting utilities. 2 2 3 - import gleam/option.{type Option, None, Some} 4 - import gleam/string 3 + import gleam/float 4 + import gleam/int 5 + import gleam/time/calendar 6 + import gleam/time/timestamp 5 7 6 - /// Format a `YYYY-MM-DD` date string as `Month DD, YYYY` (e.g. 7 - /// `"2024-09-21"` → `"September 21, 2024"`). Returns the input 8 - /// unchanged when it can't be parsed so a malformed date surfaces 9 - /// in the page rather than silently becoming a wrong-looking but 10 - /// plausible string. 11 - pub fn format_date(date_str: String) -> String { 12 - case string.split(date_str, "-") { 13 - [year, month, day] -> 14 - case month_name(month) { 15 - Some(name) -> name <> " " <> day <> ", " <> year 16 - None -> date_str 17 - } 18 - _ -> date_str 8 + /// Format a `timestamp.Timestamp` as `Month DD, YYYY` in UTC. 9 + pub fn format_month_day_year(ts: timestamp.Timestamp) -> String { 10 + let #(date, _) = timestamp.to_calendar(ts, calendar.utc_offset) 11 + calendar.month_to_string(date.month) 12 + <> " " 13 + <> int.to_string(date.day) 14 + <> ", " 15 + <> int.to_string(date.year) 16 + } 17 + 18 + /// Format a `YYYY-MM-DD` string as `Month DD, YYYY` via `gleam_time`. 19 + /// Returns the original string unmodified on parse failure. 20 + pub fn format_ymd(date_str: String) -> String { 21 + case timestamp.parse_rfc3339(date_str <> "T00:00:00Z") { 22 + Ok(ts) -> format_month_day_year(ts) 23 + Error(_) -> date_str 19 24 } 20 25 } 21 26 22 - fn month_name(month: String) -> Option(String) { 27 + /// Format a `YYYY-MM-DD` string as an RFC 822 timestamp for RSS 28 + /// `<pubDate>`, e.g. `"Sat, 21 Sep 2024 00:00:00 +0000"`. The 29 + /// weekday is derived from the Unix epoch seconds via `gleam_time`. 30 + pub fn to_rfc822(date_str: String) -> String { 31 + case timestamp.parse_rfc3339(date_str <> "T00:00:00Z") { 32 + Ok(ts) -> { 33 + let #(date, _) = timestamp.to_calendar(ts, calendar.utc_offset) 34 + let seconds = timestamp.to_unix_seconds(ts) |> float.round 35 + let weekday = weekday_name(seconds) 36 + let month = month_abbr(date.month) 37 + weekday 38 + <> ", " 39 + <> pad2(date.day) 40 + <> " " 41 + <> month 42 + <> " " 43 + <> int.to_string(date.year) 44 + <> " 00:00:00 +0000" 45 + } 46 + Error(_) -> date_str 47 + } 48 + } 49 + 50 + /// Derive the three-letter weekday name from Unix epoch seconds. 51 + /// 1970-01-01 (unix epoch) is a Thursday (index 4). 52 + fn weekday_name(unix_seconds: Int) -> String { 53 + let w = { 4 + unix_seconds / 86_400 } % 7 54 + case w { 55 + 0 -> "Sun" 56 + 1 -> "Mon" 57 + 2 -> "Tue" 58 + 3 -> "Wed" 59 + 4 -> "Thu" 60 + 5 -> "Fri" 61 + 6 -> "Sat" 62 + _ -> "Thu" 63 + } 64 + } 65 + 66 + /// Three-letter month abbreviation for RFC 822. 67 + fn month_abbr(month: calendar.Month) -> String { 23 68 case month { 24 - "01" | "1" -> Some("January") 25 - "02" | "2" -> Some("February") 26 - "03" | "3" -> Some("March") 27 - "04" | "4" -> Some("April") 28 - "05" | "5" -> Some("May") 29 - "06" | "6" -> Some("June") 30 - "07" | "7" -> Some("July") 31 - "08" | "8" -> Some("August") 32 - "09" | "9" -> Some("September") 33 - "10" -> Some("October") 34 - "11" -> Some("November") 35 - "12" -> Some("December") 36 - _ -> None 69 + calendar.January -> "Jan" 70 + calendar.February -> "Feb" 71 + calendar.March -> "Mar" 72 + calendar.April -> "Apr" 73 + calendar.May -> "May" 74 + calendar.June -> "Jun" 75 + calendar.July -> "Jul" 76 + calendar.August -> "Aug" 77 + calendar.September -> "Sep" 78 + calendar.October -> "Oct" 79 + calendar.November -> "Nov" 80 + calendar.December -> "Dec" 81 + } 82 + } 83 + 84 + /// Zero-pad an integer to two digits. 85 + pub fn pad2(n: Int) -> String { 86 + case n < 10 { 87 + True -> "0" <> int.to_string(n) 88 + False -> int.to_string(n) 37 89 } 38 90 }
+21 -8
shared/src/plays.gleam
··· 1 + import date 1 2 import gen/alpha/feed/play.{type AlphaFeedPlay, type ArtistView} 2 3 import gleam/list 3 4 import gleam/option.{unwrap} 4 5 import gleam/string 5 - import lustre/attribute.{class, href, target} 6 + import gleam/time/calendar 7 + import gleam/time/timestamp 8 + import lustre/attribute.{attribute, class, href, target} 6 9 import lustre/element.{type Element, none, text} 7 10 import lustre/element/html.{a, div, span} 8 11 import section ··· 21 24 } 22 25 23 26 fn render_play_row(play: AlphaFeedPlay) -> Element(msg) { 24 - let time = extract_time(play.played_time) 27 + let #(time, iso) = format_play_time(play.played_time) 25 28 26 29 let artists_str = 27 30 play.artists ··· 32 35 let release_name = unwrap(play.release_name, "") 33 36 34 37 div([class("play-row")], [ 35 - span([class("play-time")], [text(time)]), 38 + span( 39 + [ 40 + class("play-time"), 41 + attribute("data-iso", iso), 42 + ], 43 + [text(time)], 44 + ), 36 45 span( 37 46 [ 38 47 class("play-track"), ··· 55 64 ]) 56 65 } 57 66 58 - fn extract_time(iso: String) -> String { 59 - // "2026-07-18T15:33:46Z" → "15:33" 60 - case string.split(iso, "T") { 61 - [_, rest] -> string.slice(rest, 0, 5) 62 - _ -> "" 67 + /// Render a play's `played_time` as `HH:MM` in UTC, and return the 68 + /// original ISO string for client-side re-localization. 69 + fn format_play_time(iso: String) -> #(String, String) { 70 + case timestamp.parse_rfc3339(iso) { 71 + Ok(ts) -> { 72 + let #(_, time) = timestamp.to_calendar(ts, calendar.utc_offset) 73 + #(date.pad2(time.hours) <> ":" <> date.pad2(time.minutes), iso) 74 + } 75 + Error(_) -> #("", iso) 63 76 } 64 77 }
+23 -6
shared/src/repos.gleam
··· 1 1 import card 2 + import date 2 3 import gen/repo.{type Repo} 3 4 import gleam/list 4 5 import gleam/option.{Some, unwrap} 6 + import gleam/order 5 7 import gleam/string 8 + import gleam/time/timestamp 6 9 import lustre/element.{type Element, none} 7 10 import section 8 11 9 12 const max_repos = 5 10 13 11 14 /// Dedupe by repo_did, drop auto-generated hash names, sort newest 12 - /// first, and take the top N. Pure — testable in isolation from 13 - /// `repos_section`'s rendering. 15 + /// first, and take the top N. Sorting uses the parsed timestamp so 16 + /// different source offsets compare correctly. 14 17 pub fn select_top_repos(repos: List(Repo)) -> List(Repo) { 15 18 repos 16 19 |> dedup_by_did 17 - |> list.sort(by: fn(a: Repo, b: Repo) { 18 - string.compare(b.created_at, a.created_at) 19 - }) 20 + |> list.sort(by: compare_repos_newest_first) 20 21 |> list.take(max_repos) 21 22 } 22 23 24 + fn compare_repos_newest_first(a: Repo, b: Repo) -> order.Order { 25 + case 26 + timestamp.parse_rfc3339(a.created_at), 27 + timestamp.parse_rfc3339(b.created_at) 28 + { 29 + Ok(ta), Ok(tb) -> timestamp.compare(tb, ta) 30 + Ok(_), Error(_) -> order.Gt 31 + Error(_), Ok(_) -> order.Lt 32 + Error(_), Error(_) -> string.compare(b.created_at, a.created_at) 33 + } 34 + } 35 + 23 36 pub fn repos_section(repos: List(Repo)) -> Element(msg) { 24 37 case select_top_repos(repos) { 25 38 [] -> none() ··· 51 64 } 52 65 53 66 fn render_repo_card(repo: Repo) -> Element(msg) { 67 + let date_str = case timestamp.parse_rfc3339(repo.created_at) { 68 + Ok(ts) -> date.format_month_day_year(ts) 69 + Error(_) -> repo.created_at 70 + } 54 71 card.card( 55 72 title_href: "https://tangled.org/" <> repo.repo_did, 56 73 title_text: repo.name, 57 74 title_target: Some("_blank"), 58 - date: repo.created_at, 75 + date: date_str, 59 76 description: unwrap(repo.description, ""), 60 77 topics: unwrap(repo.topics, []), 61 78 )
+120
shared/test/date_test.gleam
··· 1 + import date 2 + import gleam/time/timestamp 3 + import gleeunit/should 4 + 5 + // --- pad2 --- 6 + 7 + pub fn pad2_single_digit_test() { 8 + date.pad2(0) |> should.equal("00") 9 + date.pad2(5) |> should.equal("05") 10 + date.pad2(9) |> should.equal("09") 11 + } 12 + 13 + pub fn pad2_double_digit_test() { 14 + date.pad2(10) |> should.equal("10") 15 + date.pad2(23) |> should.equal("23") 16 + date.pad2(59) |> should.equal("59") 17 + } 18 + 19 + pub fn pad2_large_numbers_test() { 20 + date.pad2(100) |> should.equal("100") 21 + date.pad2(999) |> should.equal("999") 22 + } 23 + 24 + // --- format_month_day_year --- 25 + 26 + fn ts(iso: String) -> timestamp.Timestamp { 27 + let assert Ok(t) = timestamp.parse_rfc3339(iso) 28 + t 29 + } 30 + 31 + pub fn format_month_day_year_january_test() { 32 + ts("2026-01-15T00:00:00Z") 33 + |> date.format_month_day_year 34 + |> should.equal("January 15, 2026") 35 + } 36 + 37 + pub fn format_month_day_year_december_test() { 38 + ts("2026-12-31T23:59:59Z") 39 + |> date.format_month_day_year 40 + |> should.equal("December 31, 2026") 41 + } 42 + 43 + pub fn format_month_day_year_midnight_test() { 44 + // Midnight is still the same day in UTC 45 + ts("2026-07-04T00:00:00Z") 46 + |> date.format_month_day_year 47 + |> should.equal("July 4, 2026") 48 + } 49 + 50 + pub fn format_month_day_year_non_utc_offset_test() { 51 + // An RFC 3339 timestamp with +03:00 offset — to_calendar with 52 + // utc_offset converts it to UTC first, so July 4 03:00+03 is 53 + // still July 4 00:00 UTC. 54 + ts("2026-07-04T03:00:00+03:00") 55 + |> date.format_month_day_year 56 + |> should.equal("July 4, 2026") 57 + } 58 + 59 + pub fn format_month_day_year_utc_minus_offset_test() { 60 + // 2026-07-03T22:00:00-02:00 is 2026-07-04T00:00:00Z 61 + ts("2026-07-03T22:00:00-02:00") 62 + |> date.format_month_day_year 63 + |> should.equal("July 4, 2026") 64 + } 65 + 66 + pub fn format_month_day_year_leap_year_test() { 67 + ts("2028-02-29T12:00:00Z") 68 + |> date.format_month_day_year 69 + |> should.equal("February 29, 2028") 70 + } 71 + 72 + // --- format_ymd --- 73 + 74 + pub fn format_ymd_basic_test() { 75 + date.format_ymd("2026-07-18") |> should.equal("July 18, 2026") 76 + } 77 + 78 + pub fn format_ymd_january_first_test() { 79 + date.format_ymd("2024-01-01") |> should.equal("January 1, 2024") 80 + } 81 + 82 + pub fn format_ymd_december_test() { 83 + date.format_ymd("2026-12-31") |> should.equal("December 31, 2026") 84 + } 85 + 86 + pub fn format_ymd_invalid_returns_original_test() { 87 + date.format_ymd("garbage") |> should.equal("garbage") 88 + date.format_ymd("") |> should.equal("") 89 + date.format_ymd("2024/01/01") |> should.equal("2024/01/01") 90 + } 91 + 92 + // --- to_rfc822 --- 93 + 94 + pub fn to_rfc822_basic_test() { 95 + // 2024-09-21 is a Saturday 96 + date.to_rfc822("2024-09-21") 97 + |> should.equal("Sat, 21 Sep 2024 00:00:00 +0000") 98 + } 99 + 100 + pub fn to_rfc822_weekday_varies_test() { 101 + // 2024-09-23 is a Monday 102 + date.to_rfc822("2024-09-23") 103 + |> should.equal("Mon, 23 Sep 2024 00:00:00 +0000") 104 + } 105 + 106 + pub fn to_rfc822_january_first_1970_test() { 107 + // 1970-01-01 is a Thursday 108 + date.to_rfc822("1970-01-01") 109 + |> should.equal("Thu, 01 Jan 1970 00:00:00 +0000") 110 + } 111 + 112 + pub fn to_rfc822_leap_day_test() { 113 + // 2028-02-29 is a Tuesday 114 + date.to_rfc822("2028-02-29") 115 + |> should.equal("Tue, 29 Feb 2028 00:00:00 +0000") 116 + } 117 + 118 + pub fn to_rfc822_invalid_returns_original_test() { 119 + date.to_rfc822("not-a-date") |> should.equal("not-a-date") 120 + }
+5 -47
src/blog_tools.gleam
··· 13 13 //// For a one-command build, use `make build` which chains them. 14 14 15 15 import build 16 + import date 16 17 import gleam/int 17 18 import gleam/io 18 19 import gleam/list ··· 111 112 } 112 113 113 114 fn slugify_char(c: String) -> String { 114 - case c { 115 - "a" 116 - | "b" 117 - | "c" 118 - | "d" 119 - | "e" 120 - | "f" 121 - | "g" 122 - | "h" 123 - | "i" 124 - | "j" 125 - | "k" 126 - | "l" 127 - | "m" 128 - | "n" 129 - | "o" 130 - | "p" 131 - | "q" 132 - | "r" 133 - | "s" 134 - | "t" 135 - | "u" 136 - | "v" 137 - | "w" 138 - | "x" 139 - | "y" 140 - | "z" 141 - | "0" 142 - | "1" 143 - | "2" 144 - | "3" 145 - | "4" 146 - | "5" 147 - | "6" 148 - | "7" 149 - | "8" 150 - | "9" 151 - | "-" -> c 152 - _ -> "-" 115 + case string.contains("abcdefghijklmnopqrstuvwxyz0123456789-", c) { 116 + True -> c 117 + False -> "-" 153 118 } 154 119 } 155 120 ··· 191 156 /// so the scaffolded post sorts to the top of the timeline. 192 157 fn template(slug: String) -> String { 193 158 let #(y, m, d) = today() 194 - let date = int.to_string(y) <> "-" <> pad2(m) <> "-" <> pad2(d) 159 + let date = int.to_string(y) <> "-" <> date.pad2(m) <> "-" <> date.pad2(d) 195 160 "---\n" 196 161 <> "title: " 197 162 <> title_from_slug(slug) ··· 220 185 case string.to_graphemes(word) { 221 186 [] -> "" 222 187 [first, ..rest] -> string.uppercase(first) <> string.join(rest, "") 223 - } 224 - } 225 - 226 - fn pad2(n: Int) -> String { 227 - case n < 10 { 228 - True -> "0" <> int.to_string(n) 229 - False -> int.to_string(n) 230 188 } 231 189 } 232 190
+19 -128
src/data/frontmatter.gleam
··· 16 16 import data/model.{type Post, Post} 17 17 import data/util 18 18 import gleam/list 19 + import gleam/result 19 20 import gleam/string 21 + import gleam/time/timestamp 20 22 import mork 21 23 22 24 pub type ParseError { ··· 48 50 let draft_str = util.extract_field(lines, "draft:", "false") 49 51 let image = util.extract_field(lines, "image:", "") 50 52 51 - case title == "", date == "", is_iso_date(date) { 53 + case title == "", date == "", parse_article_date(date) { 52 54 True, _, _ -> Error(MissingField(slug, "title")) 53 55 _, True, _ -> Error(MissingField(slug, "date")) 54 - _, _, False -> Error(InvalidDate(slug, date)) 55 - False, False, True -> 56 + _, _, Error(_) -> Error(InvalidDate(slug, date)) 57 + False, False, Ok(_) -> 56 58 Ok(build_post( 57 59 slug, 58 60 title, ··· 121 123 } 122 124 } 123 125 124 - /// Check that a string matches the `YYYY-MM-DD` shape. Does not 125 - /// validate calendar correctness (e.g. "2024-02-31" passes). 126 - pub fn is_iso_date(s: String) -> Bool { 127 - case string.split(s, on: "-") { 128 - [y, m, d] -> 129 - string.length(y) == 4 130 - && string.length(m) == 2 131 - && string.length(d) == 2 132 - && is_all_digits(y) 133 - && is_all_digits(m) 134 - && is_all_digits(d) 135 - _ -> False 136 - } 126 + /// Validate a `YYYY-MM-DD` or RFC 3339 frontmatter date via `gleam_time`. 127 + /// The raw string is still stored on `Post` for RSS/OG output; this is 128 + /// only used to fail the build loudly on a malformed date. 129 + fn parse_article_date(s: String) -> Result(timestamp.Timestamp, Nil) { 130 + timestamp.parse_rfc3339(s <> "T00:00:00Z") 137 131 } 138 132 139 - fn is_all_digits(s: String) -> Bool { 140 - case s { 141 - "" -> False 142 - _ -> s |> string.to_graphemes |> list.all(is_digit_char) 143 - } 144 - } 133 + const slug_chars = "abcdefghijklmnopqrstuvwxyz0123456789-" 145 134 146 - fn is_digit_char(c: String) -> Bool { 147 - case c { 148 - "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" -> True 149 - _ -> False 150 - } 151 - } 135 + const slug_start_chars = "abcdefghijklmnopqrstuvwxyz0123456789" 152 136 153 137 /// Validates that a string is a usable post slug: starts with a 154 138 /// lowercase letter or digit, contains only lowercase letters, ··· 156 140 pub fn is_valid_slug(slug: String) -> Bool { 157 141 case slug { 158 142 "" -> False 159 - _ -> 160 - slug 161 - |> string.to_graphemes 162 - |> list.all(is_slug_char) 163 - && is_slug_start(slug |> string.first |> result_unwrap("")) 164 - } 165 - } 166 - 167 - fn is_slug_char(c: String) -> Bool { 168 - case c { 169 - "a" 170 - | "b" 171 - | "c" 172 - | "d" 173 - | "e" 174 - | "f" 175 - | "g" 176 - | "h" 177 - | "i" 178 - | "j" 179 - | "k" 180 - | "l" 181 - | "m" 182 - | "n" 183 - | "o" 184 - | "p" 185 - | "q" 186 - | "r" 187 - | "s" 188 - | "t" 189 - | "u" 190 - | "v" 191 - | "w" 192 - | "x" 193 - | "y" 194 - | "z" 195 - | "0" 196 - | "1" 197 - | "2" 198 - | "3" 199 - | "4" 200 - | "5" 201 - | "6" 202 - | "7" 203 - | "8" 204 - | "9" 205 - | "-" -> True 206 - _ -> False 207 - } 208 - } 209 - 210 - fn is_slug_start(c: String) -> Bool { 211 - case c { 212 - "a" 213 - | "b" 214 - | "c" 215 - | "d" 216 - | "e" 217 - | "f" 218 - | "g" 219 - | "h" 220 - | "i" 221 - | "j" 222 - | "k" 223 - | "l" 224 - | "m" 225 - | "n" 226 - | "o" 227 - | "p" 228 - | "q" 229 - | "r" 230 - | "s" 231 - | "t" 232 - | "u" 233 - | "v" 234 - | "w" 235 - | "x" 236 - | "y" 237 - | "z" 238 - | "0" 239 - | "1" 240 - | "2" 241 - | "3" 242 - | "4" 243 - | "5" 244 - | "6" 245 - | "7" 246 - | "8" 247 - | "9" -> True 248 - _ -> False 249 - } 250 - } 251 - 252 - // Silly wrapper to make the type checker happy when string.first 253 - // returns Error — the empty string default makes is_slug_start 254 - // return False, which is what we want. 255 - fn result_unwrap(r: Result(a, b), default: a) -> a { 256 - case r { 257 - Ok(v) -> v 258 - Error(_) -> default 143 + _ -> { 144 + let first = string.first(slug) |> result.unwrap("") 145 + string.contains(slug_start_chars, first) 146 + && list.all(string.to_graphemes(slug), fn(c) { 147 + string.contains(slug_chars, c) 148 + }) 149 + } 259 150 } 260 151 }
+2 -2
src/view/components/post_view.gleam
··· 19 19 title_href: "/posts/" <> post.slug <> "/", 20 20 title_text: post.title, 21 21 title_target: None, 22 - date: date.format_date(post.date), 22 + date: date.format_ymd(post.date), 23 23 description: post.description, 24 24 topics: post.tags, 25 25 ) ··· 40 40 div([class("post-meta")], [ 41 41 span([], [ 42 42 text("Written "), 43 - span([class("emph")], [text(date.format_date(post.date))]), 43 + span([class("emph")], [text(date.format_ymd(post.date))]), 44 44 ]), 45 45 tags, 46 46 ]),
+3 -2
src/view/layout.gleam
··· 1 + import date 1 2 import gleam/list 2 3 import gleam/option.{type Option, None, Some} 3 4 import gleam/string ··· 354 355 pub fn rss_feed(posts: List(#(String, String, String, String))) -> String { 355 356 let items = 356 357 list.map(posts, fn(t) { 357 - let #(title, description, slug, date) = t 358 + let #(title, description, slug, date_str) = t 358 359 " <item> 359 360 <title>" <> title <> "</title> 360 361 <description>" <> description <> "</description> 361 362 <link>https://karitham.dev/posts/" <> slug <> "/</link> 362 - <pubDate>" <> date <> "</pubDate> 363 + <pubDate>" <> date.to_rfc822(date_str) <> "</pubDate> 363 364 </item>" 364 365 }) 365 366
+21 -3
test/data/frontmatter_test.gleam
··· 104 104 pub fn parse_valid_iso_date_test() { 105 105 let cases = ["2024-01-01", "2024-12-31", "2024-02-29", "1999-09-09"] 106 106 list.each(cases, fn(good) { 107 - frontmatter.is_iso_date(good) |> should.equal(True) 107 + let post = 108 + frontmatter.parse( 109 + "valid-date", 110 + "---\ntitle: t\ndate: " <> good <> "\n---\nbody", 111 + ) 112 + post |> should.be_ok() 108 113 }) 109 114 } 110 115 111 116 pub fn parse_invalid_iso_date_test() { 112 - let cases = ["", "2024", "2024-1", "2024-01", "2024-1-1", "abcd-ef-gh"] 117 + let cases = [ 118 + "", 119 + "2024", 120 + "2024-1", 121 + "2024-01", 122 + "2024-1-1", 123 + "abcd-ef-gh", 124 + "2024-02-30", 125 + ] 113 126 list.each(cases, fn(bad) { 114 - frontmatter.is_iso_date(bad) |> should.equal(False) 127 + let post = 128 + frontmatter.parse( 129 + "invalid-date", 130 + "---\ntitle: t\ndate: " <> bad <> "\n---\nbody", 131 + ) 132 + post |> should.be_error() 115 133 }) 116 134 } 117 135
-41
test/view/components/post_view_test.gleam
··· 1 - import date 2 - import gleeunit/should 3 - 4 - pub fn format_date_standard_test() { 5 - date.format_date("2024-09-21") 6 - |> should.equal("September 21, 2024") 7 - } 8 - 9 - pub fn format_date_january_test() { 10 - date.format_date("2024-01-01") 11 - |> should.equal("January 01, 2024") 12 - } 13 - 14 - pub fn format_date_december_test() { 15 - date.format_date("2024-12-31") 16 - |> should.equal("December 31, 2024") 17 - } 18 - 19 - pub fn format_date_different_year_test() { 20 - date.format_date("2023-07-04") 21 - |> should.equal("July 04, 2023") 22 - } 23 - 24 - pub fn format_date_unparseable_month_test() { 25 - // Out-of-range month — surface the input rather than silently 26 - // emitting a wrong-looking but plausible string. (Previous 27 - // implementation used the raw "13" as the month name, which 28 - // produced "13 01, 2024" — almost-right enough to miss in review.) 29 - date.format_date("2024-13-01") 30 - |> should.equal("2024-13-01") 31 - } 32 - 33 - pub fn format_date_malformed_test() { 34 - date.format_date("garbage") 35 - |> should.equal("garbage") 36 - } 37 - 38 - pub fn format_date_short_test() { 39 - date.format_date("2024-09") 40 - |> should.equal("2024-09") 41 - }