My personal website, in gleam+lustre!
0
fork

Configure Feed

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

Add sitemap.xml and an in-site sitemap!


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

+276 -166
+3
.gitignore
··· 13 13 /dist 14 14 15 15 node_modules 16 + 17 + # Generated on prepare 16 18 assets/styles.css 19 + assets/sitemap.xml
+46
dev/homepage/prepare.gleam
··· 3 3 4 4 import gleam/erlang/application 5 5 import gleam/io 6 + import homepage 6 7 import gleam/json 7 8 import gleam/list 8 9 import gleam/result ··· 34 35 } 35 36 36 37 pub fn main() { 38 + case embed_files() { 39 + Ok(_) -> Nil 40 + Error(msg) -> panic as msg 41 + } 42 + case generate_sitemap() { 43 + Ok(_) -> Nil 44 + Error(msg) -> panic as msg 45 + } 46 + } 47 + 48 + fn generate_sitemap() { 49 + let contents = "<?xml version=\"1.0\" encoding=\"UTF-8\"?> 50 + 51 + <urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\"> 52 + " <> 53 + 54 + list.map(homepage.sitemap(), fn(entry) { 55 + "<url> 56 + 57 + <loc>https://strawmelonjuice.com" <> entry.route |> homepage.to_url <> "</loc> 58 + 59 + <lastmod>" <> entry.last_updated |> homepage.date_to_yyyy_mm_dd <> "</lastmod> 60 + 61 + <changefreq>monthly</changefreq> 62 + 63 + <priority>0.8</priority> 64 + 65 + </url>" 66 + 67 + })|> string.join("\n")<>"</urlset>" 68 + 69 + use _ <- result.try( 70 + simplifile.write( 71 + "./assets/sitemap.xml", 72 + contents:, 73 + ) 74 + |> result.replace_error( 75 + "❌\tSomething went wrong writing `./assets/sitemap.xml`.", 76 + ), 77 + ) 78 + io.println("✅\tWrote: `./assets/sitemap.xml`") 79 + |> Ok 80 + } 81 + 82 + fn embed_files() { 37 83 use files <- result.try(list_files()) 38 84 39 85 use dir <- result.try(
+1
justfile
··· 13 13 14 14 # Produce a JavaScript bundle in dist. 15 15 build: prepare 16 + rm -fr ./dist 16 17 gleam run -m lustre/dev build 17 18 18 19 # Runs two watchers and allows you to always get a proper preview before pushing!
+104 -111
site.css
··· 4 4 @plugin "daisyui"; 5 5 6 6 @plugin "daisyui/theme" { 7 - name: "strawmelonjuicedotcom"; 8 - default: true; 9 - prefersdark: true; 10 - color-scheme: "dark"; 11 - --color-base-100: oklch(96% 0.059 95.617); 12 - --color-base-200: oklch(93% 0.032 17.717); 13 - --color-base-300: oklch(92% 0.12 95.746); 14 - --color-base-content: oklch(25% 0.09 281.288); 15 - --color-primary: #edbcbc; 16 - --color-primary-content: oklch(44% 0.043 257.281); 17 - --color-secondary: #ceddd9; 18 - --color-secondary-content: oklch(29% 0.066 243.157); 19 - --color-accent: oklch(71% 0.203 305.504); 20 - --color-accent-content: oklch(38% 0.063 188.416); 21 - --color-neutral: #adebb3; 22 - --color-neutral-content: oklch(28% 0.141 291.089); 23 - --color-info: oklch(96% 0.059 95.617); 24 - --color-info-content: oklch(42% 0.095 57.708); 25 - --color-success: oklch(76% 0.177 163.223); 26 - --color-success-content: oklch(37% 0.077 168.94); 27 - --color-warning: oklch(70% 0.213 47.604); 28 - --color-warning-content: oklch(0% 0 0); 29 - --color-error: oklch(63% 0.237 25.331); 30 - --color-error-content: oklch(0% 0 0); 31 - --radius-selector: 0.5rem; 32 - --radius-field: 2rem; 33 - --radius-box: 0.25rem; 34 - --size-selector: 0.25rem; 35 - --size-field: 0.1875rem; 36 - --border: 2px; 37 - --depth: 1; 38 - --noise: 1; 39 - --font-roboto: "Lilex", monospace, system-ui, sans-serif; 7 + name: "strawmelonjuicedotcom"; 8 + default: true; 9 + prefersdark: true; 10 + color-scheme: "dark"; 11 + --color-base-100: oklch(96% 0.059 95.617); 12 + --color-base-200: oklch(93% 0.032 17.717); 13 + --color-base-300: oklch(92% 0.12 95.746); 14 + --color-base-content: oklch(25% 0.09 281.288); 15 + --color-primary: #edbcbc; 16 + --color-primary-content: oklch(44% 0.043 257.281); 17 + --color-secondary: #ceddd9; 18 + --color-secondary-content: oklch(29% 0.066 243.157); 19 + --color-accent: oklch(71% 0.203 305.504); 20 + --color-accent-content: oklch(38% 0.063 188.416); 21 + --color-neutral: #adebb3; 22 + --color-neutral-content: oklch(28% 0.141 291.089); 23 + --color-info: oklch(96% 0.059 95.617); 24 + --color-info-content: oklch(42% 0.095 57.708); 25 + --color-success: oklch(76% 0.177 163.223); 26 + --color-success-content: oklch(37% 0.077 168.94); 27 + --color-warning: oklch(70% 0.213 47.604); 28 + --color-warning-content: oklch(0% 0 0); 29 + --color-error: oklch(63% 0.237 25.331); 30 + --color-error-content: oklch(0% 0 0); 31 + --radius-selector: 0.5rem; 32 + --radius-field: 2rem; 33 + --radius-box: 0.25rem; 34 + --size-selector: 0.25rem; 35 + --size-field: 0.1875rem; 36 + --border: 2px; 37 + --depth: 1; 38 + --noise: 1; 39 + --font-roboto: "Lilex", monospace, system-ui, sans-serif; 40 40 } 41 41 42 42 @layer base { 43 - html { 44 - font-family: "Lilex", monospace, system-ui, sans-serif; 45 - font-optical-sizing: auto; 46 - font-weight: 400; 47 - font-style: normal; 48 - margin: 0; 49 - } 43 + html { 44 + font-family: "Lilex", monospace, system-ui, sans-serif; 45 + font-optical-sizing: auto; 46 + font-weight: 400; 47 + font-style: normal; 48 + margin: 0; 49 + } 50 50 51 - body, 52 - #app { 53 - /*background-color: #f8e4e4;*/ 54 - @apply w-screen h-screen; 55 - } 51 + /* Make sure the 'go comment' button is visible even on small screens. */ 52 + .widget .form-controls { 53 + flex-wrap: wrap; 54 + } 56 55 57 - /* Make sure the 'go comment' button is visible even on small screens. */ 58 - .widget .form-controls { 59 - flex-wrap: wrap; 60 - } 56 + /* As to not collide with chilp's footers, we had to be a bit more specific */ 57 + #app .footer { 58 + position: fixed; 59 + bottom: 0; 60 + -webkit-animation: seconds 1s forwards; 61 + -webkit-animation-iteration-count: 1; 62 + -webkit-animation-delay: 5s; 63 + animation: seconds 1s forwards; 64 + animation-iteration-count: 1; 65 + animation-delay: 5s; 66 + } 61 67 62 - /* As to not collide with chilp's footers, we had to be a bit more specific */ 63 - #app .footer { 64 - position: fixed; 65 - bottom: 0; 66 - -webkit-animation: seconds 1.0s forwards; 67 - -webkit-animation-iteration-count: 1; 68 - -webkit-animation-delay: 5s; 69 - animation: seconds 1.0s forwards; 70 - animation-iteration-count: 1; 71 - animation-delay: 5s; 72 - } 68 + @-webkit-keyframes seconds { 69 + 0% { 70 + opacity: 1; 71 + } 73 72 74 - @-webkit-keyframes seconds { 75 - 0% { 76 - opacity: 1; 73 + 100% { 74 + opacity: 0; 75 + left: -9999px; 76 + } 77 77 } 78 78 79 - 100% { 80 - opacity: 0; 81 - left: -9999px; 79 + @keyframes seconds { 80 + 0% { 81 + opacity: 1; 82 + } 83 + 84 + 100% { 85 + opacity: 0; 86 + left: -9999px; 87 + } 82 88 } 83 - } 84 89 85 - @keyframes seconds { 86 - 0% { 87 - opacity: 1; 90 + .link-list-container { 91 + margin: auto; 88 92 } 89 93 90 - 100% { 91 - opacity: 0; 92 - left: -9999px; 94 + .link-list-container .link-list-button { 95 + display: block; 96 + background: #ff6f91; 97 + color: white; 98 + text-decoration: none; 99 + padding: 1em; 100 + margin: 1em 0; 101 + border-radius: 8px; 102 + font-weight: bold; 103 + transition: background 0.3s ease; 93 104 } 94 - } 95 105 106 + .link-list-container .link-list-button:hover { 107 + background: #ff4f78; 108 + } 96 109 97 - .link-list-container { 98 - margin: auto; 99 - } 110 + .link-list-container .note { 111 + font-size: 0.9em; 112 + margin-top: 1em; 113 + } 100 114 101 - .link-list-container .link-list-button { 102 - display: block; 103 - background: #ff6f91; 104 - color: white; 105 - text-decoration: none; 106 - padding: 1em; 107 - margin: 1em 0; 108 - border-radius: 8px; 109 - font-weight: bold; 110 - transition: background 0.3s ease; 111 - } 115 + .link-list-container .submenu { 116 + border-radius: 10px; 117 + padding: 1em; 118 + margin-top: 2em; 119 + } 112 120 113 - .link-list-container .link-list-button:hover { 114 - background: #ff4f78; 115 - } 116 - 117 - .link-list-container .note { 118 - font-size: 0.9em; 119 - margin-top: 1em; 120 - } 121 - 122 - .link-list-container .submenu { 123 - border-radius: 10px; 124 - padding: 1em; 125 - margin-top: 2em; 126 - } 121 + .link-list-container .submenu .submenu-h3 { 122 + margin-top: 0; 123 + font-size: 1.2em; 124 + } 127 125 128 - .link-list-container .submenu .submenu-h3 { 129 - margin-top: 0; 130 - font-size: 1.2em; 131 - } 132 - 133 - .link-list-container .submenu p { 134 - font-size: 0.95em; 135 - margin-bottom: 1em; 136 - } 126 + .link-list-container .submenu p { 127 + font-size: 0.95em; 128 + margin-bottom: 1em; 129 + } 137 130 }
+122 -55
src/homepage.gleam
··· 1 + /// We want to disable this while previewing, as to not spam ari.lt with requests due to autoreloading. 2 + const arrivertisements_show = False 3 + 1 4 // Post data 2 5 3 6 fn posts() { ··· 29 32 "Creative", 30 33 "Friendship", 31 34 "OCid:AF", 35 + ".creative", 32 36 ".creative-art", 33 37 ], 34 38 ), ··· 60 64 ), 61 65 ] 62 66 } 67 + 68 + 63 69 64 70 const pages = [ 65 - #("/", Index, "Home", Date(2026, March, 16)), 66 - #("/posts", Posts, "Posts", Date(2026, March, 16)), 67 - #("/me", Me, "About me", Date(2026, March, 16)), 68 - #("/me/portfolio", Portfolio, "Portfolio", Date(2026, March, 16)), 69 - #("/me/links", Links, "Links", Date(2026, March, 16)), 71 + // If index isn't fist, things break. 72 + Entry(Index, "Homepage", Date(2026, March, 16), NotFound(uri.empty)), 73 + Entry( Me, "About me", Date(2026, March, 16), Index), 74 + Entry(Portfolio, "Portfolio", Date(2026, March, 16), Me), 75 + Entry( Links, "Links", Date(2026, March, 16), Me), 76 + Entry(Posts, "Posts", Date(2026, March, 16), Index), 77 + Entry(Sitemap, "Sitemap", Date(2026, March, 16), Index), 78 + Entry(AllAndEverything, "/everything page", Date(2026, March, 16), Index), 70 79 ] 71 80 72 - fn sitemap() -> List(#(String, Route, String, calendar.Date)) { 81 + pub fn sitemap() -> List(Entry) { 73 82 let posts = 74 83 posts() 75 84 |> list.map(fn(post) { 76 - #( 77 - "/post/" 78 - <> case post.aliases { 79 - [] -> int.to_string(post.id) 80 - [alias, ..] -> alias 81 - }, 82 - PostById(post.id), 83 - post.title, 84 - case post.revised { 85 + Entry( 86 + route: PostById(post.id), 87 + title: post.title, 88 + last_updated: case post.revised { 85 89 Some(new) -> new 86 90 None -> post.published 87 91 }, 92 + parent: Posts 88 93 ) 89 94 }) 90 95 pages |> list.append(posts) 91 96 } 92 97 93 98 fn view_sitemap() -> List(Element(Msg)) { 94 - let all = sitemap() 95 - todo 99 + let assert Ok(index_entry) = list.first(pages) 100 + // To be clear: 101 + let assert Index = index_entry.route 102 + [ 103 + title("Sitemap"), html.br([attribute.class("mt-4")]) 104 + 105 + ,html.ul([attribute.class("list-['=>'] list-inside")],[render_sitemap_recursively(start_entry: index_entry, level: 0)]) 106 + 107 + ] 96 108 } 109 + // For now we render a boring with-depth list, plan is to create a whole graph out of this! 110 + fn render_sitemap_recursively(start_entry entry: Entry, level level: Int) { 111 + let children = list.filter(sitemap(), fn (maybe_child) {maybe_child.parent == entry.route}) 112 + html.li( 113 + [], 114 + [html.a([href(entry.route),attribute.class("link link-secondary-content ps-2")],[ 115 + element.text(entry.title) 116 + ]), 117 + 118 + case children { 119 + [] -> element.none() 120 + _ -> { 121 + list.map(children, fn (child) { 122 + case child.route == Sitemap { 123 + False -> render_sitemap_recursively(child, level+1) 124 + // A CHILD OF MY OWN?! 125 + True -> html.li([], [html.span([attribute.class("text-info-content ps-2")], [element.text(child.title)]), element.text(" <--- You are here")]) 126 + } 127 + }) |> html.ul([attribute.class({ 128 + case level, entry.route { 129 + 0,_-> "list-['===>']" // Index 130 + 1,Posts -> "list-['----Post:']" 131 + 1,_-> "list-['======>']" 132 + 2,_-> "list-['=========>']" 133 + _,_ -> "list-disc" 134 + } 135 + } <> " list-inside mb-2")],_) 136 + } 137 + } 138 + 139 + ] 140 + ) 141 + 142 + } 143 + 144 + 97 145 98 146 const highlighted_posts = [900] 99 147 ··· 321 369 322 370 // Some types for your consideration 323 371 372 + pub type Entry{ 373 + Entry( 374 + route: Route, 375 + title: String, 376 + last_updated: calendar.Date, 377 + parent: Route 378 + ) 379 + } 380 + 324 381 type Badge { 325 382 Badge( 326 383 clickable_url: String, ··· 390 447 UserNavigatedTo(route: Route) 391 448 } 392 449 393 - type Route { 450 + pub type Route { 394 451 Index 395 452 Posts 396 453 PostById(id: Int) ··· 535 592 } 536 593 } 537 594 538 - fn href(route: Route) -> Attribute(Msg) { 539 - let url = case route { 595 + pub fn to_url (route: Route) -> String { 596 + case route { 540 597 Index -> "/" 541 598 Me -> "/me" 542 599 Posts -> "/posts" ··· 562 619 NotFound(_) -> "/404" 563 620 Tagged(v) -> "/posts/tagged/" <> v 564 621 Category(c) -> "/posts/category/" <> c 565 - Sitemap -> "/sitemap.html" 622 + Sitemap -> "/sitemap" 566 623 } 567 624 568 - attribute.href(url) 625 + } 626 + 627 + fn href(route: Route) -> Attribute(Msg) { 628 + attribute.href(to_url(route)) 569 629 } 570 630 571 631 fn init(_) -> #(Model, Effect(Msg)) { ··· 636 696 |> element.fragment 637 697 } 638 698 let into_main_with_badges = fn(content: List(Element(Msg))) -> Element(Msg) { 699 + let badges_ = list.map(badges |> list.shuffle(), render_badge) 639 700 [ 640 701 content |> main(function.identity), 641 702 html.div( ··· 644 705 "bg-accent mt-4 text-accent-content mx-auto max-w-2xl md:rounded-md md:mb-14 p-2 md:p-12 md:h-fit", 645 706 ), 646 707 ], 647 - [ 648 - list.map(badges |> list.shuffle(), render_badge) |> element.fragment(), 649 - // Arrivertisement! 650 - html.iframe([ 651 - attribute("loading", "lazy"), 652 - attribute.height(98), 653 - // attribute.width(722), 654 - attribute.class("border-none w-full mx-auto h-[98px] p-0 m-0"), 655 - attribute( 656 - "title", 657 - "An embed showing a silly image and author information.", 658 - ), 659 - attribute("referrerpolicy", "no-referrer"), 660 - attribute("sandbox", "allow-popups allow-popups-to-escape-sandbox"), 661 - attribute.src( 662 - "https://ad.ari.lt/ads/embed?from=strawmelonjuice.com", 663 - ), 664 - ]), 665 - ], 708 + case arrivertisements_show { 709 + True -> { 710 + [ 711 + badges_ |> element.fragment(), 712 + // Arrivertisement! 713 + html.iframe([ 714 + attribute("loading", "lazy"), 715 + attribute.height(98), 716 + // attribute.width(722), 717 + attribute.class("border-none w-full mx-auto h-[98px] p-0 m-0"), 718 + attribute( 719 + "title", 720 + "An embed showing a silly image and author information.", 721 + ), 722 + attribute("referrerpolicy", "no-referrer"), 723 + attribute("sandbox", "allow-popups allow-popups-to-escape-sandbox"), 724 + attribute.src( 725 + "https://ad.ari.lt/ads/embed?from=strawmelonjuice.com", 726 + ), 727 + ]), 728 + ] 729 + } 730 + False -> badges_ 731 + } 732 + 666 733 ), 667 734 html.hr([attribute.class("my-20 text-base-100")]), 668 735 ] ··· 807 874 html.ul([attribute.class("menu menu-horizontal px-1 hidden md:flex")], [ 808 875 header_links, 809 876 ]), 810 - // Search 811 - html.a([href(AllAndEverything)], [ 877 + // Sitemap 878 + html.a([href(Sitemap)], [ 812 879 html.button( 813 880 [ 814 881 attribute.class("btn btn-ghost btn-circle"), ··· 816 883 [ 817 884 svg.svg( 818 885 [ 886 + attribute("stroke-linejoin", "round"), 887 + attribute("stroke-linecap", "round"), 888 + attribute("stroke-width", "2"), 819 889 attribute("stroke", "currentColor"), 820 - attribute("viewBox", "0 0 24 24"), 821 890 attribute("fill", "none"), 822 - attribute.class("h-5 w-5"), 891 + attribute("viewBox", "0 0 24 24"), 892 + attribute("height", "24"), 893 + attribute("width", "24"), 823 894 attribute("xmlns", "http://www.w3.org/2000/svg"), 824 895 ], 825 896 [ 826 - svg.path([ 827 - attribute( 828 - "d", 829 - "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z", 830 - ), 831 - attribute("stroke-width", "2"), 832 - attribute("stroke-linejoin", "round"), 833 - attribute("stroke-linecap", "round"), 834 - ]), 897 + svg.path([attribute("d", "M3 5c2-1 5-1 8 0s6 1 8 0")]), 898 + svg.path([attribute("d", "M3 19c2 1 5 1 8 0s6-1 8 0")]), 899 + svg.path([attribute("d", "M3 5v14M21 5v14")]), 900 + svg.path([attribute("d", "M16 5l5 5")]), 901 + svg.path([attribute("d", "M7 10c2-2 4 2 6 0s4-2 6 0")]), 835 902 ], 836 903 ), 837 904 ], ··· 2067 2134 ] 2068 2135 } 2069 2136 2070 - fn date_to_yyyy_mm_dd(date: calendar.Date) -> String { 2137 + pub fn date_to_yyyy_mm_dd(date: calendar.Date) -> String { 2071 2138 let Date(year, month, day) = date 2072 2139 int.to_string(year) 2073 2140 <> "-"