My personal website

Add atom feed

fruno.win 0a7146ed 766b0b7d

verified
+138 -2
+2
gleam.toml
··· 34 34 tom = ">= 2.0.0 and < 3.0.0" 35 35 gleam_javascript = ">= 1.0.0 and < 2.0.0" 36 36 envoy = ">= 1.1.0 and < 2.0.0" 37 + atomb = ">= 1.0.0 and < 2.0.0" 38 + youid = ">= 1.5.1 and < 2.0.0"
+5
manifest.toml
··· 2 2 # You typically do not need to edit this file 3 3 4 4 packages = [ 5 + { name = "atomb", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time", "xmb"], otp_app = "atomb", source = "hex", outer_checksum = "5BA533CDB8F29ECD8FF08835A79DBE6E0724E7D2C5D515898C3DA9B9D006DEA0" }, 5 6 { name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" }, 6 7 { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, 7 8 { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, ··· 21 22 { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, 22 23 { 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" }, 23 24 { name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" }, 25 + { name = "xmb", version = "1.4.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "xmb", source = "hex", outer_checksum = "1C4B67ACBFE703122414691A63DC3591AE6FCACF7DC13A78A1C1E96D7B56B757" }, 26 + { name = "youid", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_stdlib", "gleam_time"], otp_app = "youid", source = "hex", outer_checksum = "580E909FD704DB16416D5CB080618EDC2DA0F1BE4D21B490C0683335E3FFC5AF" }, 24 27 ] 25 28 26 29 [requirements] 30 + atomb = { version = ">= 1.0.0 and < 2.0.0" } 27 31 envoy = { version = ">= 1.1.0 and < 2.0.0" } 28 32 gleam_javascript = { version = ">= 1.0.0 and < 2.0.0" } 29 33 gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } ··· 33 37 lustre_ssg = { git = "https://github.com/fruno-bulax/lustre_ssg", ref = "6c132bd34ab75a1144d31c0f896cab0e3cbf80fc" } 34 38 simplifile = { version = ">= 2.3.2 and < 3.0.0" } 35 39 tom = { version = ">= 2.0.0 and < 3.0.0" } 40 + youid = { version = ">= 1.5.1 and < 2.0.0" }
+2
posts/2026-01-03-initial-commit.djot
··· 1 1 --- 2 2 title = "making a blog for dummies" 3 3 listed = false 4 + published = 2026-01-11T12:46:00+02:00 5 + summary = "This will be a summary." 4 6 --- 5 7 6 8 # this is an example for a draft!
+28 -2
src/blog.gleam
··· 2 2 import gleam/list 3 3 import gleam/result 4 4 import gleam/string 5 + import gleam/time/timestamp.{type Timestamp} 5 6 import lustre/attribute 6 7 import lustre/element.{type Element} 7 8 import lustre/element/html ··· 10 11 import tom 11 12 12 13 pub type Post { 13 - Post(slug: String, title: String, content: String, listed: Bool) 14 + Post( 15 + slug: String, 16 + title: String, 17 + published: Timestamp, 18 + updated: Timestamp, 19 + summary: String, 20 + content: String, 21 + listed: Bool, 22 + ) 14 23 } 15 24 16 25 const posts_dir = "./posts" ··· 32 41 as "Failed to read file" 33 42 let assert Ok(meta) = djot.metadata(content) as "Failed to read post metadata" 34 43 let assert Ok(title) = tom.get_string(meta, ["title"]) as "Missing post title" 44 + let assert Ok(summary) = tom.get_string(meta, ["summary"]) 45 + as "Missing post summary" 46 + 47 + let assert Ok(#(date, time, tom.Offset(offset))) = 48 + tom.get_calendar_time(meta, ["published"]) 49 + as "No publication timestamp or missing offset" 50 + let published = timestamp.from_calendar(date, time, offset) 51 + let updated = case tom.get(meta, ["updated"]) { 52 + Ok(updated) -> { 53 + let assert Ok(#(date, time, tom.Offset(offset))) = 54 + tom.as_calendar_time(updated) 55 + as "Invalid updated timestamp" 56 + timestamp.from_calendar(date, time, offset) 57 + } 58 + Error(_) -> published 59 + } 60 + 35 61 let listed = result.unwrap(tom.get_bool(meta, ["listed"]), True) 36 62 37 - Post(slug:, title:, content:, listed:) 63 + Post(slug:, title:, summary:, published:, updated:, content:, listed:) 38 64 } 39 65 40 66 pub fn list_posts(posts: List(Post)) -> List(Element(msg)) {
+88
src/feed.gleam
··· 1 + import atomb 2 + import blog 3 + import gleam/list 4 + import gleam/option.{None, Some} 5 + import gleam/result 6 + import gleam/time/calendar 7 + import gleam/time/duration 8 + import gleam/time/timestamp 9 + import youid/uuid.{type Uuid} 10 + 11 + // chosen by fair dice roll. 12 + // guaranteed to be random. 13 + /// The blog's v4 UUID 14 + const feed_id = "b8b90946-da45-4c55-8141-111205089967" 15 + 16 + const site_url = "https://fruno.win" 17 + 18 + const max_entries = 20 19 + 20 + fn feed_uuid() -> Uuid { 21 + let assert Ok(id) = uuid.from_string(feed_id) 22 + id 23 + } 24 + 25 + fn meeeeee() { 26 + atomb.Person("fruno", Some(site_url), None) 27 + } 28 + 29 + pub fn feed(posts: List(blog.Post)) -> atomb.Feed { 30 + let entries = 31 + posts 32 + |> list.filter(fn(post) { post.listed }) 33 + |> list.take(max_entries) 34 + |> list.map(feed_entry) 35 + 36 + let updated = 37 + entries 38 + |> list.map(fn(entry) { entry.updated }) 39 + |> list.max(timestamp.compare) 40 + |> result.unwrap(timestamp.from_calendar( 41 + calendar.Date(2026, calendar.January, 1), 42 + calendar.TimeOfDay(0, 0, 0, 0), 43 + duration.hours(0), 44 + )) 45 + 46 + atomb.Feed( 47 + id: id(feed_uuid()), 48 + title: "fruno.win", 49 + updated:, 50 + subtitle: Some(atomb.TextContent( 51 + atomb.Text, 52 + "a blog about Gleam and/or other tech things", 53 + )), 54 + rights: None, 55 + icon: None, 56 + logo: None, 57 + authors: [meeeeee()], 58 + links: [atomb.Link(site_url, None)], 59 + categories: [], 60 + contributors: [], 61 + generator: None, 62 + entries:, 63 + ) 64 + } 65 + 66 + fn feed_entry(post: blog.Post) { 67 + let #(timestamp, _) = 68 + timestamp.to_unix_seconds_and_nanoseconds(post.published) 69 + let assert Ok(uuid) = uuid.v5(feed_uuid(), <<timestamp:int-64>>) 70 + 71 + atomb.Entry( 72 + id: id(uuid), 73 + title: post.title, 74 + published: Some(post.published), 75 + updated: post.updated, 76 + content: None, 77 + summary: Some(atomb.TextContent(atomb.Text, post.summary)), 78 + categories: [], 79 + links: [atomb.Link(site_url <> "/blog/" <> post.slug, Some("alternate"))], 80 + authors: [meeeeee()], 81 + contributors: [], 82 + rights: None, 83 + ) 84 + } 85 + 86 + fn id(uuid: Uuid) -> String { 87 + "urn:uuid:" <> uuid.to_string(uuid) 88 + }
+5
src/page.gleam
··· 59 59 attribute.rel("stylesheet"), 60 60 attribute.href("/style.css"), 61 61 ]), 62 + html.link([ 63 + attribute.rel("alternate"), 64 + attribute.type_("application/atom+xml"), 65 + attribute.href("/feed.xml"), 66 + ]), 62 67 ]), 63 68 html.body([], [navbar(page, info.meta), html.main([], content(page, info))]), 64 69 ])
+8
src/webbed_site.gleam
··· 1 + import atomb 1 2 import blog 2 3 import context 4 + import feed 3 5 import gleam/dict 4 6 import gleam/javascript/promise 5 7 import gleam/list 8 + import gleam/string_tree 6 9 import lustre/ssg 7 10 import page 8 11 ··· 10 13 use context <- promise.map(context.fetch()) 11 14 let posts = blog.posts() 12 15 let info = page.SiteInfo(posts:, meta: context) 16 + let feed = feed.feed(posts) 13 17 14 18 let site = 15 19 ssg.new("./dist") ··· 29 33 page.render(_, info), 30 34 ) 31 35 |> ssg.add_static_dir("./assets") 36 + |> ssg.add_static_asset( 37 + "/feed.xml", 38 + feed |> atomb.render() |> string_tree.to_string, 39 + ) 32 40 |> ssg.build 33 41 34 42 let assert Ok(_) = site as "Build failed"