···1515import gleam/time/timestamp
1616import glentities
1717import homepage
1818-import homepage/stuff.{Entry}
1818+import homepage/stuff.{type Entry, type Model, type Route, Entry, Index, Model}
1919+import lustre/element
1920import simplifile
2021import webls/rss
2122···45464647pub fn main() {
4748 let prod_mode = argv.load().arguments |> list.contains("--prod")
4949+ let prepopulation_mode =
5050+ argv.load().arguments |> list.contains("--prepopulate")
48514949- case embed_data(prod_mode) {
5050- Ok(_) -> Nil
5151- Error(msg) -> panic as msg
5252+ case prepopulation_mode {
5353+ True if prod_mode -> {
5454+ // Writing pre-filled versions of index.html to each sitemapped location, allowing for meta data and faster content loads.
5555+ case prepopulate_html() {
5656+ Ok(_) -> Nil
5757+ Error(msg) -> panic as msg
5858+ }
5959+ }
6060+ True -> {
6161+ panic as "Pregeneration mode is only available for production builds."
6262+ }
6363+ False -> {
6464+ // Prepare script's "usual" bussiness.
6565+ case embed_data(prod_mode) {
6666+ Ok(_) -> Nil
6767+ Error(msg) -> panic as msg
6868+ }
6969+ case generate_sitemap() {
7070+ Ok(_) -> Nil
7171+ Error(msg) -> panic as msg
7272+ }
7373+ case generate_rss() {
7474+ Ok(_) -> Nil
7575+ Error(msg) -> panic as msg
7676+ }
7777+ }
7878+ }
7979+}
8080+8181+fn try_annot(
8282+ result: Result(a, e),
8383+ otherwise msg: String,
8484+ apply fun: fn(a) -> Result(b, String),
8585+) -> Result(b, String) {
8686+ case result {
8787+ Ok(x) -> fun(x)
8888+ Error(e) -> Error(msg <> "\n\n\n(" <> string.inspect(e) <> ")")
8989+ }
9090+}
9191+9292+fn prepopulate_html() {
9393+ use main_index_html: String <- try_annot(
9494+ simplifile.read("./dist/index.html"),
9595+ "Prepopulation mode expects that the site already be built, but could not load ./dist/index.html",
9696+ )
9797+ let main_model: Model = homepage.init()
9898+ let map_entries = fn(cb: fn(Entry) -> Result(Nil, String)) -> Result(
9999+ Nil,
100100+ String,
101101+ ) {
102102+ list.map(homepage.sitemap(), cb) |> result.all |> result.replace(Nil)
52103 }
5353- case generate_sitemap() {
5454- Ok(_) -> Nil
5555- Error(msg) -> panic as msg
104104+ use entry: Entry <- map_entries()
105105+ let html_file_path = "./dist" <> homepage.to_url(entry.route) <> "/index.html"
106106+ let pregenerated_html =
107107+ homepage.view(Model(..main_model, route: entry.route))
108108+ |> element.to_string()
109109+ let meta_description_tag: String =
110110+ "<meta name=\"description\" content=\"" <> entry.description <> "\">"
111111+ let link_canonical = case entry.route {
112112+ Index -> ""
113113+ _ ->
114114+ "<link rel=\"canonical\" href=\"" <> to_canonical(entry.route) <> "\" />"
56115 }
5757- case generate_rss() {
5858- Ok(_) -> Nil
5959- Error(msg) -> panic as msg
116116+ let title =
117117+ homepage.to_title(entry.route)
118118+ |> glentities.encode(glentities.Hex)
119119+ use html <- try_annot(
120120+ string_insert_after(main_index_html, "<div id=\"app\">", pregenerated_html)
121121+ |> result.map(string_insert_after(_, "<head>", meta_description_tag))
122122+ |> result.flatten()
123123+ |> result.map(string_insert_after(_, meta_description_tag, link_canonical))
124124+ |> result.flatten()
125125+ |> result.map(string.replace(
126126+ _,
127127+ each: "<title>Mar's site</title>",
128128+ with: "<title>" <> title <> "</title>",
129129+ )),
130130+ "Failed to generate html for " <> html_file_path,
131131+ )
132132+ use _ <- try_annot(
133133+ simplifile.create_directory_all(
134134+ html_file_path |> string.replace("index.html", ""),
135135+ ),
136136+ "Could not create parent folder for " <> html_file_path,
137137+ )
138138+139139+ use _ <- try_annot(
140140+ simplifile.write(html, html_file_path),
141141+ "Could not write html to " <> html_file_path,
142142+ )
143143+ Ok(io.println("✅\tWrote: `" <> html_file_path <> "`"))
144144+}
145145+146146+/// I used to just replace with self+new but this seems more neat
147147+/// need to see what is actually more efficient, I doubt it makes much difference though.
148148+///
149149+/// The final string is either stored within an Ok (if been appended), or within an Error
150150+/// (if the string remained the same), which is where it may be an improvement over my
151151+/// replacement approach
152152+fn string_insert_after(
153153+ in string: String,
154154+ on pattern: String,
155155+ with new: String,
156156+) -> Result(String, String) {
157157+ case string.split_once(string, on: pattern) {
158158+ Error(Nil) -> Error(string)
159159+ Ok(#(before, after)) -> {
160160+ Ok(before <> new <> after)
161161+ }
60162 }
163163+}
164164+165165+fn to_canonical(route: Route) {
166166+ "https://strawmelonjuice.com" <> route |> homepage.to_url
61167}
6216863169pub fn generate_rss() {
···77183 )
78184 |> rss.with_channel_language("en")
79185 |> rss.with_channel_items(
8080- list.map(homepage.posts_entries(), fn(entry) {
186186+ list.map(homepage.posts_entries(homepage.posts()), fn(entry) {
81187 let permalink =
82188 "https://strawmelonjuice.com/post/" <> int.to_string(entry.id)
8383- let Entry(route:, title:, last_updated:, parent: _) = entry.entry
189189+ let Entry(route:, title:, last_updated:, parent: _, description: _) =
190190+ entry.entry
84191 let title = glentities.encode(title, glentities.Hex)
8585- let description = glentities.encode(entry.description, glentities.Hex)
8686- rss.item(title, description)
8787- |> rss.with_item_link(
8888- "https://strawmelonjuice.com" <> route |> homepage.to_url,
8989- )
192192+ let summary = glentities.encode(entry.description, glentities.Hex)
193193+ rss.item(title, summary)
194194+ |> rss.with_item_link(to_canonical(route))
90195 |> rss.with_item_author("rss-webmaster@strawmelonjuice.com")
91196 |> rss.with_item_pub_date({
92197 last_updated
···100205 ),
101206 ]
102207 |> rss.to_string()
103103- use _ <- result.try(
104104- simplifile.write("./assets/feed.xml", contents:)
105105- |> result.replace_error(
106106- "❌\tSomething went wrong writing `./assets/sitemap.xml`.",
107107- ),
208208+ use _ <- try_annot(
209209+ simplifile.write("./assets/feed.xml", contents:),
210210+ "❌\tSomething went wrong writing `./assets/sitemap.xml`.",
108211 )
109212 io.println("✅\tWrote: `./assets/feed.xml`")
110213 |> Ok
···128231 </url>" })
129232 |> string.join("\n") <> "</urlset>"
130233131131- use _ <- result.try(
132132- simplifile.write("./assets/sitemap.xml", contents:)
133133- |> result.replace_error(
134134- "❌\tSomething went wrong writing `./assets/sitemap.xml`.",
135135- ),
234234+ use _ <- try_annot(
235235+ simplifile.write("./assets/sitemap.xml", contents:),
236236+ "❌\tSomething went wrong writing `./assets/sitemap.xml`.",
136237 )
137238 io.println("✅\tWrote: `./assets/sitemap.xml`")
138239 |> Ok
···165266 |> result.flatten,
166267 )
167268 io.println(dir)
168168- use _ <- result.try(
269269+ use _ <- try_annot(
169270 simplifile.write(
170271 "./src/homepage/from_prebuild/data.gleam",
171272 assertive_priv_file_read("codegen-templates/data.gleam")
···174275 "pub const arrivertisements_show = False",
175276 "pub const arrivertisements_show = " <> bool.to_string(prod_mode),
176277 ),
177177- )
178178- |> result.replace_error(
179179- "❌\tSomething went wrong writing `src/homepage/from_prebuild/data.gleam`.",
180278 ),
279279+ "❌\tSomething went wrong writing `src/homepage/from_prebuild/data.gleam`.",
181280 )
182281 io.println("✅\tWrote: `src/homepage/from_prebuild/data.gleam`")
183282 |> Ok
···216315 )
217316218317 list.try_map(files, fn(file) {
219219- use contents <- result.try(
220220- simplifile.read(file)
221221- |> result.replace_error(
222222- "❌\tSomething went wrong reading file `" <> file <> "`.",
223223- ),
318318+ use contents <- try_annot(
319319+ simplifile.read(file),
320320+ "❌\tSomething went wrong reading file `" <> file <> "`.",
224321 )
225322 Ok(
226323 "#("
+1
justfile
···1616 just prepare -- --prod
1717 rm -fr ./dist
1818 gleam run -m lustre/dev build
1919+ just prepare -- --prod --prepopulate
19202021# Runs two watchers and allows you to always get a proper preview before pushing!
2122preview: prepare preview-inner
+111-21
src/homepage.gleam
···11+import gleam/bool
22+13/// Post data -------------------------------------------------------------------
24///
35/// Some special tags:
···1416/// Posts with ID 880 to 900 are imported from the old website. I'll have to skip those once you hit 880+ posts xD
1517///
1618/// OH, future me, ID's are used to generate (perma)-links. Do NOT edit them post-pushes.
1717-fn posts() {
1919+pub fn posts() {
1820 [
1921 Post(
2022 2,
···431433const highlighted_posts = [900]
432434433435const pages = [
434434- // If index isn't fist, things break.
435435- Entry(Index, "Homepage", Date(2026, March, 16), NotFound(uri.empty)),
436436- Entry(Me, "About me", Date(2026, March, 16), Index),
437437- Entry(Portfolio, "Portfolio", Date(2026, March, 16), Me),
438438- Entry(Links, "Links", Date(2026, March, 16), Me),
439439- Entry(Posts, "Posts", Date(2026, March, 16), Index),
440440- Entry(Sitemap, "Sitemap", Date(2026, March, 16), Index),
441441- Entry(AllAndEverything, "/everything page", Date(2026, March, 16), Index),
436436+ // If index isn't first, things break.
437437+ Entry(
438438+ Index,
439439+ "Homepage",
440440+ Date(2026, March, 16),
441441+ NotFound(uri.empty),
442442+ "Welcome to my personal site!",
443443+ ),
444444+ Entry(
445445+ Me,
446446+ "About me",
447447+ Date(2026, March, 16),
448448+ Index,
449449+ "My about-me page, on here I try to describe myself a bit",
450450+ ),
451451+ Entry(
452452+ Portfolio,
453453+ "Portfolio",
454454+ Date(2026, March, 16),
455455+ Me,
456456+ "On this page you'll find my portfolio and some active projects!",
457457+ ),
458458+ Entry(
459459+ Links,
460460+ "Links",
461461+ Date(2026, March, 16),
462462+ Me,
463463+ "Some links to places I can be found on the internet!",
464464+ ),
465465+ Entry(
466466+ Posts,
467467+ "Posts",
468468+ Date(2026, March, 16),
469469+ Index,
470470+ "Mostly blog posts from Mar Bloeiman (@strawmelonjuice)",
471471+ ),
472472+ Entry(
473473+ Sitemap,
474474+ "Sitemap",
475475+ Date(2026, March, 16),
476476+ Index,
477477+ "Visual sitemap for strawmelonjuice.com.",
478478+ ),
479479+ Entry(
480480+ AllAndEverything,
481481+ "/everything page",
482482+ Date(2026, March, 16),
483483+ Index,
484484+ "A collection of content from all visible pages and posts on strawmelonjuice.com, useful for quickly finding specific sentences etc.",
485485+ ),
442486]
443487444488// Links on my php
···672716673717pub fn main() {
674718 let assert Ok(_) = widget.register()
675675- let app = lustre.application(init, update, view)
719719+ let app = lustre.application(app_init, update, view)
676720 let assert Ok(_) = lustre.start(app, "#app", Nil)
677721678722 Nil
679723}
680724681681-pub fn posts_entries() -> List(FeedEntry) {
682682- posts()
725725+pub fn posts_entries(posts: List(Post)) -> List(FeedEntry) {
726726+ posts
683727 |> list.map(fn(post) {
684728 FeedEntry(
685729 post.id,
···692736 None -> post.published
693737 },
694738 parent: Posts,
739739+ description: "Author: Mar Bloeiman (@strawmelonjuice), Summary: `"
740740+ <> post.summary
741741+ <> "´, Category: "
742742+ <> post.category,
695743 ),
696744 )
697745 })
···733781}
734782735783pub fn sitemap() -> List(Entry) {
736736- let posts =
737737- posts_entries()
784784+ let posts = posts()
785785+ let posts_as_entries =
786786+ posts_entries(posts)
738787 |> list.map(fn(entry) { entry.entry })
739739- pages |> list.append(posts)
788788+ // This creates doubles, but the `unique_entry` fn will deal with those later.
789789+ let categories_and_tags =
790790+ posts
791791+ |> list.map(fn(post) {
792792+ let category = post.category
793793+ [
794794+ // One entry for each tag
795795+ list.filter_map(post.tags, fn(tag) {
796796+ // No hidden tags to be included.
797797+ use <- bool.guard(tag |> string.starts_with("."), Error(Nil))
798798+ Ok(Entry(
799799+ title: to_title(Tagged(tag)),
800800+ last_updated: post.revised |> option.unwrap(post.published),
801801+ parent: Posts,
802802+ route: Tagged(tag),
803803+ description: "Blog posts from Mar Bloeiman (@strawmelonjuice) that are tagged with '"
804804+ <> tag
805805+ <> "'.",
806806+ ))
807807+ }),
808808+809809+ // One entry for the one category
810810+ list.wrap(Entry(
811811+ title: to_title(Category(category)),
812812+ last_updated: post.revised |> option.unwrap(post.published),
813813+ parent: Posts,
814814+ route: Category(category),
815815+ description: "Blog posts from Mar Bloeiman (@strawmelonjuice) that are categorised as '"
816816+ <> category
817817+ <> "'.",
818818+ )),
819819+ ]
820820+ |> list.flatten()
821821+ })
822822+ |> list.flatten()
823823+824824+ [pages, posts_as_entries, categories_and_tags]
825825+ |> list.flatten()
826826+ |> stuff.unique_entry()
740827}
741828742829fn view_sitemap() -> List(Element(Msg)) {
743743- let assert Ok(index_entry) = list.first(pages)
744744- // To be clear:
745745- let assert Index = index_entry.route
830830+ let assert Ok(index_entry) =
831831+ list.find(pages, fn(page) { page.route == Index })
746832 [
747833 title("Sitemap"),
748834 html.br([attribute.class("mt-4")]),
···9681054 }
9691055}
9701056971971-fn init(_) -> #(Model, Effect(Msg)) {
10571057+pub fn init() -> Model {
9721058 let raw_posts = posts()
9731059 let aliases =
9741060 list.map(posts(), fn(post) {
···9821068 posts()
9831069 |> list.map(fn(post) { #(post.id, post |> post_normalize) })
9841070 |> dict.from_list
985985- let base_model = Model(raw_posts:, route: Index, posts:, aliases:)
10711071+ Model(raw_posts:, route: Index, posts:, aliases:)
10721072+}
10731073+10741074+fn app_init(_) -> #(Model, Effect(Msg)) {
10751075+ let base_model = init()
9861076 let route = case modem.initial_uri() {
9871077 Ok(uri) -> parse_route(uri, base_model)
9881078 Error(_) -> Index
···10191109 }
10201110}
1021111110221022-fn view(model: Model) -> Element(Msg) {
11121112+pub fn view(model: Model) -> Element(Msg) {
10231113 let main = fn(content: List(Element(Msg)), alter: fn(String) -> String) -> Element(
10241114 Msg,
10251115 ) {
+51-1
src/homepage/stuff.gleam
···33import gleam/int
44import gleam/list
55import gleam/option.{type Option}
66+import gleam/result
67import gleam/time/calendar
78import gleam/uri.{type Uri}
89import lustre/attribute.{type Attribute}
···1415 LustreElement(Msg)
15161617pub type Entry {
1717- Entry(route: Route, title: String, last_updated: calendar.Date, parent: Route)
1818+ Entry(
1919+ /// The route for this entry
2020+ route: Route,
2121+ /// The title for this entry, this is not the same as
2222+ /// `to_title(Route) -> String`, because this is the 'link-friendly'
2323+ /// name, whereas `to_title` generates a document-friendly title.
2424+ title: String,
2525+ /// When last updated
2626+ last_updated: calendar.Date,
2727+ /// Parent of this entry
2828+ parent: Route,
2929+ /// Description, in this case NOT the body-friendly one, but rather
3030+ /// the document-friendly `<meta name="description">` one. This does
3131+ /// not align with the title value in this same type, for the
3232+ /// body-friendly version of posts, the description field on
3333+ /// the `FeedEntry` type or the summary value of the post itself is used.
3434+ description: String,
3535+ )
1836}
19372038pub type FeedEntry {
···113131 Links
114132 PersonalStartPage
115133 CuriculumVitae(in_dutch: Bool)
134134+}
135135+136136+/// Removes all doubles from a list of entries, keeping only the latest items
137137+pub fn unique_entry(from: List(Entry)) {
138138+ // Weed out the obvious ones
139139+ let from = list.unique(from)
140140+ unique_entry_loop([], from:)
141141+}
142142+143143+fn unique_entry_loop(accumulated: List(Entry), from rest: List(Entry)) {
144144+ case rest {
145145+ [] ->
146146+ list.sort(accumulated, fn(item_a, item_b) {
147147+ calendar.naive_date_compare(item_a.last_updated, item_b.last_updated)
148148+ })
149149+ [item, ..rest] -> {
150150+ // we filter out all the items that match route with this one, making sure we don't have to go over them again later.
151151+ // not_matching is the rest, which means all routes we have not treated at all yet.
152152+ let #(not_matching, matching) =
153153+ list.partition(rest, fn(match_against) {
154154+ match_against.route != item.route
155155+ })
156156+ let newest =
157157+ [item, ..matching]
158158+ |> list.sort(fn(item_a, item_b) {
159159+ calendar.naive_date_compare(item_a.last_updated, item_b.last_updated)
160160+ })
161161+ |> list.first()
162162+ |> result.unwrap(item)
163163+ unique_entry_loop([newest, ..accumulated], not_matching)
164164+ }
165165+ }
116166}
117167118168pub fn href(route: Route, posts: List(Post)) -> Attribute(Msg) {