My personal website, in gleam+lustre!
0
fork

Configure Feed

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

untested,,, should pregen all html


Signed-off-by: MLC Bloeiman <mar@strawmelonjuice.com>

+296 -58
+133 -36
dev/homepage/prepare.gleam
··· 15 15 import gleam/time/timestamp 16 16 import glentities 17 17 import homepage 18 - import homepage/stuff.{Entry} 18 + import homepage/stuff.{type Entry, type Model, type Route, Entry, Index, Model} 19 + import lustre/element 19 20 import simplifile 20 21 import webls/rss 21 22 ··· 45 46 46 47 pub fn main() { 47 48 let prod_mode = argv.load().arguments |> list.contains("--prod") 49 + let prepopulation_mode = 50 + argv.load().arguments |> list.contains("--prepopulate") 48 51 49 - case embed_data(prod_mode) { 50 - Ok(_) -> Nil 51 - Error(msg) -> panic as msg 52 + case prepopulation_mode { 53 + True if prod_mode -> { 54 + // Writing pre-filled versions of index.html to each sitemapped location, allowing for meta data and faster content loads. 55 + case prepopulate_html() { 56 + Ok(_) -> Nil 57 + Error(msg) -> panic as msg 58 + } 59 + } 60 + True -> { 61 + panic as "Pregeneration mode is only available for production builds." 62 + } 63 + False -> { 64 + // Prepare script's "usual" bussiness. 65 + case embed_data(prod_mode) { 66 + Ok(_) -> Nil 67 + Error(msg) -> panic as msg 68 + } 69 + case generate_sitemap() { 70 + Ok(_) -> Nil 71 + Error(msg) -> panic as msg 72 + } 73 + case generate_rss() { 74 + Ok(_) -> Nil 75 + Error(msg) -> panic as msg 76 + } 77 + } 78 + } 79 + } 80 + 81 + fn try_annot( 82 + result: Result(a, e), 83 + otherwise msg: String, 84 + apply fun: fn(a) -> Result(b, String), 85 + ) -> Result(b, String) { 86 + case result { 87 + Ok(x) -> fun(x) 88 + Error(e) -> Error(msg <> "\n\n\n(" <> string.inspect(e) <> ")") 89 + } 90 + } 91 + 92 + fn prepopulate_html() { 93 + use main_index_html: String <- try_annot( 94 + simplifile.read("./dist/index.html"), 95 + "Prepopulation mode expects that the site already be built, but could not load ./dist/index.html", 96 + ) 97 + let main_model: Model = homepage.init() 98 + let map_entries = fn(cb: fn(Entry) -> Result(Nil, String)) -> Result( 99 + Nil, 100 + String, 101 + ) { 102 + list.map(homepage.sitemap(), cb) |> result.all |> result.replace(Nil) 52 103 } 53 - case generate_sitemap() { 54 - Ok(_) -> Nil 55 - Error(msg) -> panic as msg 104 + use entry: Entry <- map_entries() 105 + let html_file_path = "./dist" <> homepage.to_url(entry.route) <> "/index.html" 106 + let pregenerated_html = 107 + homepage.view(Model(..main_model, route: entry.route)) 108 + |> element.to_string() 109 + let meta_description_tag: String = 110 + "<meta name=\"description\" content=\"" <> entry.description <> "\">" 111 + let link_canonical = case entry.route { 112 + Index -> "" 113 + _ -> 114 + "<link rel=\"canonical\" href=\"" <> to_canonical(entry.route) <> "\" />" 56 115 } 57 - case generate_rss() { 58 - Ok(_) -> Nil 59 - Error(msg) -> panic as msg 116 + let title = 117 + homepage.to_title(entry.route) 118 + |> glentities.encode(glentities.Hex) 119 + use html <- try_annot( 120 + string_insert_after(main_index_html, "<div id=\"app\">", pregenerated_html) 121 + |> result.map(string_insert_after(_, "<head>", meta_description_tag)) 122 + |> result.flatten() 123 + |> result.map(string_insert_after(_, meta_description_tag, link_canonical)) 124 + |> result.flatten() 125 + |> result.map(string.replace( 126 + _, 127 + each: "<title>Mar&#39;s site</title>", 128 + with: "<title>" <> title <> "</title>", 129 + )), 130 + "Failed to generate html for " <> html_file_path, 131 + ) 132 + use _ <- try_annot( 133 + simplifile.create_directory_all( 134 + html_file_path |> string.replace("index.html", ""), 135 + ), 136 + "Could not create parent folder for " <> html_file_path, 137 + ) 138 + 139 + use _ <- try_annot( 140 + simplifile.write(html, html_file_path), 141 + "Could not write html to " <> html_file_path, 142 + ) 143 + Ok(io.println("✅\tWrote: `" <> html_file_path <> "`")) 144 + } 145 + 146 + /// I used to just replace with self+new but this seems more neat 147 + /// need to see what is actually more efficient, I doubt it makes much difference though. 148 + /// 149 + /// The final string is either stored within an Ok (if been appended), or within an Error 150 + /// (if the string remained the same), which is where it may be an improvement over my 151 + /// replacement approach 152 + fn string_insert_after( 153 + in string: String, 154 + on pattern: String, 155 + with new: String, 156 + ) -> Result(String, String) { 157 + case string.split_once(string, on: pattern) { 158 + Error(Nil) -> Error(string) 159 + Ok(#(before, after)) -> { 160 + Ok(before <> new <> after) 161 + } 60 162 } 163 + } 164 + 165 + fn to_canonical(route: Route) { 166 + "https://strawmelonjuice.com" <> route |> homepage.to_url 61 167 } 62 168 63 169 pub fn generate_rss() { ··· 77 183 ) 78 184 |> rss.with_channel_language("en") 79 185 |> rss.with_channel_items( 80 - list.map(homepage.posts_entries(), fn(entry) { 186 + list.map(homepage.posts_entries(homepage.posts()), fn(entry) { 81 187 let permalink = 82 188 "https://strawmelonjuice.com/post/" <> int.to_string(entry.id) 83 - let Entry(route:, title:, last_updated:, parent: _) = entry.entry 189 + let Entry(route:, title:, last_updated:, parent: _, description: _) = 190 + entry.entry 84 191 let title = glentities.encode(title, glentities.Hex) 85 - let description = glentities.encode(entry.description, glentities.Hex) 86 - rss.item(title, description) 87 - |> rss.with_item_link( 88 - "https://strawmelonjuice.com" <> route |> homepage.to_url, 89 - ) 192 + let summary = glentities.encode(entry.description, glentities.Hex) 193 + rss.item(title, summary) 194 + |> rss.with_item_link(to_canonical(route)) 90 195 |> rss.with_item_author("rss-webmaster@strawmelonjuice.com") 91 196 |> rss.with_item_pub_date({ 92 197 last_updated ··· 100 205 ), 101 206 ] 102 207 |> rss.to_string() 103 - use _ <- result.try( 104 - simplifile.write("./assets/feed.xml", contents:) 105 - |> result.replace_error( 106 - "❌\tSomething went wrong writing `./assets/sitemap.xml`.", 107 - ), 208 + use _ <- try_annot( 209 + simplifile.write("./assets/feed.xml", contents:), 210 + "❌\tSomething went wrong writing `./assets/sitemap.xml`.", 108 211 ) 109 212 io.println("✅\tWrote: `./assets/feed.xml`") 110 213 |> Ok ··· 128 231 </url>" }) 129 232 |> string.join("\n") <> "</urlset>" 130 233 131 - use _ <- result.try( 132 - simplifile.write("./assets/sitemap.xml", contents:) 133 - |> result.replace_error( 134 - "❌\tSomething went wrong writing `./assets/sitemap.xml`.", 135 - ), 234 + use _ <- try_annot( 235 + simplifile.write("./assets/sitemap.xml", contents:), 236 + "❌\tSomething went wrong writing `./assets/sitemap.xml`.", 136 237 ) 137 238 io.println("✅\tWrote: `./assets/sitemap.xml`") 138 239 |> Ok ··· 165 266 |> result.flatten, 166 267 ) 167 268 io.println(dir) 168 - use _ <- result.try( 269 + use _ <- try_annot( 169 270 simplifile.write( 170 271 "./src/homepage/from_prebuild/data.gleam", 171 272 assertive_priv_file_read("codegen-templates/data.gleam") ··· 174 275 "pub const arrivertisements_show = False", 175 276 "pub const arrivertisements_show = " <> bool.to_string(prod_mode), 176 277 ), 177 - ) 178 - |> result.replace_error( 179 - "❌\tSomething went wrong writing `src/homepage/from_prebuild/data.gleam`.", 180 278 ), 279 + "❌\tSomething went wrong writing `src/homepage/from_prebuild/data.gleam`.", 181 280 ) 182 281 io.println("✅\tWrote: `src/homepage/from_prebuild/data.gleam`") 183 282 |> Ok ··· 216 315 ) 217 316 218 317 list.try_map(files, fn(file) { 219 - use contents <- result.try( 220 - simplifile.read(file) 221 - |> result.replace_error( 222 - "❌\tSomething went wrong reading file `" <> file <> "`.", 223 - ), 318 + use contents <- try_annot( 319 + simplifile.read(file), 320 + "❌\tSomething went wrong reading file `" <> file <> "`.", 224 321 ) 225 322 Ok( 226 323 "#("
+1
justfile
··· 16 16 just prepare -- --prod 17 17 rm -fr ./dist 18 18 gleam run -m lustre/dev build 19 + just prepare -- --prod --prepopulate 19 20 20 21 # Runs two watchers and allows you to always get a proper preview before pushing! 21 22 preview: prepare preview-inner
+111 -21
src/homepage.gleam
··· 1 + import gleam/bool 2 + 1 3 /// Post data ------------------------------------------------------------------- 2 4 /// 3 5 /// Some special tags: ··· 14 16 /// Posts with ID 880 to 900 are imported from the old website. I'll have to skip those once you hit 880+ posts xD 15 17 /// 16 18 /// OH, future me, ID's are used to generate (perma)-links. Do NOT edit them post-pushes. 17 - fn posts() { 19 + pub fn posts() { 18 20 [ 19 21 Post( 20 22 2, ··· 431 433 const highlighted_posts = [900] 432 434 433 435 const pages = [ 434 - // If index isn't fist, things break. 435 - Entry(Index, "Homepage", Date(2026, March, 16), NotFound(uri.empty)), 436 - Entry(Me, "About me", Date(2026, March, 16), Index), 437 - Entry(Portfolio, "Portfolio", Date(2026, March, 16), Me), 438 - Entry(Links, "Links", Date(2026, March, 16), Me), 439 - Entry(Posts, "Posts", Date(2026, March, 16), Index), 440 - Entry(Sitemap, "Sitemap", Date(2026, March, 16), Index), 441 - Entry(AllAndEverything, "/everything page", Date(2026, March, 16), Index), 436 + // If index isn't first, things break. 437 + Entry( 438 + Index, 439 + "Homepage", 440 + Date(2026, March, 16), 441 + NotFound(uri.empty), 442 + "Welcome to my personal site!", 443 + ), 444 + Entry( 445 + Me, 446 + "About me", 447 + Date(2026, March, 16), 448 + Index, 449 + "My about-me page, on here I try to describe myself a bit", 450 + ), 451 + Entry( 452 + Portfolio, 453 + "Portfolio", 454 + Date(2026, March, 16), 455 + Me, 456 + "On this page you'll find my portfolio and some active projects!", 457 + ), 458 + Entry( 459 + Links, 460 + "Links", 461 + Date(2026, March, 16), 462 + Me, 463 + "Some links to places I can be found on the internet!", 464 + ), 465 + Entry( 466 + Posts, 467 + "Posts", 468 + Date(2026, March, 16), 469 + Index, 470 + "Mostly blog posts from Mar Bloeiman (@strawmelonjuice)", 471 + ), 472 + Entry( 473 + Sitemap, 474 + "Sitemap", 475 + Date(2026, March, 16), 476 + Index, 477 + "Visual sitemap for strawmelonjuice.com.", 478 + ), 479 + Entry( 480 + AllAndEverything, 481 + "/everything page", 482 + Date(2026, March, 16), 483 + Index, 484 + "A collection of content from all visible pages and posts on strawmelonjuice.com, useful for quickly finding specific sentences etc.", 485 + ), 442 486 ] 443 487 444 488 // Links on my php ··· 672 716 673 717 pub fn main() { 674 718 let assert Ok(_) = widget.register() 675 - let app = lustre.application(init, update, view) 719 + let app = lustre.application(app_init, update, view) 676 720 let assert Ok(_) = lustre.start(app, "#app", Nil) 677 721 678 722 Nil 679 723 } 680 724 681 - pub fn posts_entries() -> List(FeedEntry) { 682 - posts() 725 + pub fn posts_entries(posts: List(Post)) -> List(FeedEntry) { 726 + posts 683 727 |> list.map(fn(post) { 684 728 FeedEntry( 685 729 post.id, ··· 692 736 None -> post.published 693 737 }, 694 738 parent: Posts, 739 + description: "Author: Mar Bloeiman (@strawmelonjuice), Summary: `" 740 + <> post.summary 741 + <> "´, Category: " 742 + <> post.category, 695 743 ), 696 744 ) 697 745 }) ··· 733 781 } 734 782 735 783 pub fn sitemap() -> List(Entry) { 736 - let posts = 737 - posts_entries() 784 + let posts = posts() 785 + let posts_as_entries = 786 + posts_entries(posts) 738 787 |> list.map(fn(entry) { entry.entry }) 739 - pages |> list.append(posts) 788 + // This creates doubles, but the `unique_entry` fn will deal with those later. 789 + let categories_and_tags = 790 + posts 791 + |> list.map(fn(post) { 792 + let category = post.category 793 + [ 794 + // One entry for each tag 795 + list.filter_map(post.tags, fn(tag) { 796 + // No hidden tags to be included. 797 + use <- bool.guard(tag |> string.starts_with("."), Error(Nil)) 798 + Ok(Entry( 799 + title: to_title(Tagged(tag)), 800 + last_updated: post.revised |> option.unwrap(post.published), 801 + parent: Posts, 802 + route: Tagged(tag), 803 + description: "Blog posts from Mar Bloeiman (@strawmelonjuice) that are tagged with '" 804 + <> tag 805 + <> "'.", 806 + )) 807 + }), 808 + 809 + // One entry for the one category 810 + list.wrap(Entry( 811 + title: to_title(Category(category)), 812 + last_updated: post.revised |> option.unwrap(post.published), 813 + parent: Posts, 814 + route: Category(category), 815 + description: "Blog posts from Mar Bloeiman (@strawmelonjuice) that are categorised as '" 816 + <> category 817 + <> "'.", 818 + )), 819 + ] 820 + |> list.flatten() 821 + }) 822 + |> list.flatten() 823 + 824 + [pages, posts_as_entries, categories_and_tags] 825 + |> list.flatten() 826 + |> stuff.unique_entry() 740 827 } 741 828 742 829 fn view_sitemap() -> List(Element(Msg)) { 743 - let assert Ok(index_entry) = list.first(pages) 744 - // To be clear: 745 - let assert Index = index_entry.route 830 + let assert Ok(index_entry) = 831 + list.find(pages, fn(page) { page.route == Index }) 746 832 [ 747 833 title("Sitemap"), 748 834 html.br([attribute.class("mt-4")]), ··· 968 1054 } 969 1055 } 970 1056 971 - fn init(_) -> #(Model, Effect(Msg)) { 1057 + pub fn init() -> Model { 972 1058 let raw_posts = posts() 973 1059 let aliases = 974 1060 list.map(posts(), fn(post) { ··· 982 1068 posts() 983 1069 |> list.map(fn(post) { #(post.id, post |> post_normalize) }) 984 1070 |> dict.from_list 985 - let base_model = Model(raw_posts:, route: Index, posts:, aliases:) 1071 + Model(raw_posts:, route: Index, posts:, aliases:) 1072 + } 1073 + 1074 + fn app_init(_) -> #(Model, Effect(Msg)) { 1075 + let base_model = init() 986 1076 let route = case modem.initial_uri() { 987 1077 Ok(uri) -> parse_route(uri, base_model) 988 1078 Error(_) -> Index ··· 1019 1109 } 1020 1110 } 1021 1111 1022 - fn view(model: Model) -> Element(Msg) { 1112 + pub fn view(model: Model) -> Element(Msg) { 1023 1113 let main = fn(content: List(Element(Msg)), alter: fn(String) -> String) -> Element( 1024 1114 Msg, 1025 1115 ) {
+51 -1
src/homepage/stuff.gleam
··· 3 3 import gleam/int 4 4 import gleam/list 5 5 import gleam/option.{type Option} 6 + import gleam/result 6 7 import gleam/time/calendar 7 8 import gleam/uri.{type Uri} 8 9 import lustre/attribute.{type Attribute} ··· 14 15 LustreElement(Msg) 15 16 16 17 pub type Entry { 17 - Entry(route: Route, title: String, last_updated: calendar.Date, parent: Route) 18 + Entry( 19 + /// The route for this entry 20 + route: Route, 21 + /// The title for this entry, this is not the same as 22 + /// `to_title(Route) -> String`, because this is the 'link-friendly' 23 + /// name, whereas `to_title` generates a document-friendly title. 24 + title: String, 25 + /// When last updated 26 + last_updated: calendar.Date, 27 + /// Parent of this entry 28 + parent: Route, 29 + /// Description, in this case NOT the body-friendly one, but rather 30 + /// the document-friendly `<meta name="description">` one. This does 31 + /// not align with the title value in this same type, for the 32 + /// body-friendly version of posts, the description field on 33 + /// the `FeedEntry` type or the summary value of the post itself is used. 34 + description: String, 35 + ) 18 36 } 19 37 20 38 pub type FeedEntry { ··· 113 131 Links 114 132 PersonalStartPage 115 133 CuriculumVitae(in_dutch: Bool) 134 + } 135 + 136 + /// Removes all doubles from a list of entries, keeping only the latest items 137 + pub fn unique_entry(from: List(Entry)) { 138 + // Weed out the obvious ones 139 + let from = list.unique(from) 140 + unique_entry_loop([], from:) 141 + } 142 + 143 + fn unique_entry_loop(accumulated: List(Entry), from rest: List(Entry)) { 144 + case rest { 145 + [] -> 146 + list.sort(accumulated, fn(item_a, item_b) { 147 + calendar.naive_date_compare(item_a.last_updated, item_b.last_updated) 148 + }) 149 + [item, ..rest] -> { 150 + // we filter out all the items that match route with this one, making sure we don't have to go over them again later. 151 + // not_matching is the rest, which means all routes we have not treated at all yet. 152 + let #(not_matching, matching) = 153 + list.partition(rest, fn(match_against) { 154 + match_against.route != item.route 155 + }) 156 + let newest = 157 + [item, ..matching] 158 + |> list.sort(fn(item_a, item_b) { 159 + calendar.naive_date_compare(item_a.last_updated, item_b.last_updated) 160 + }) 161 + |> list.first() 162 + |> result.unwrap(item) 163 + unique_entry_loop([newest, ..accumulated], not_matching) 164 + } 165 + } 116 166 } 117 167 118 168 pub fn href(route: Route, posts: List(Post)) -> Attribute(Msg) {