My personal website

Initial commit

+5
.gitignore
··· 1 + *.beam 2 + *.ez 3 + /build 4 + erl_crash.dump 5 + /dist
+38
.tangled/workflows/publish.yaml
··· 1 + when: 2 + - event: ['push'] 3 + branch: ['main'] 4 + - event: ['manual'] 5 + 6 + engine: 'nixery' 7 + 8 + dependencies: 9 + nixpkgs: 10 + - coreutils 11 + - curl 12 + github:NixOS/nixpkgs/nixpkgs-unstable: 13 + - gleam 14 + - beamMinimal28Packages.erlang 15 + 16 + environment: 17 + SITE_PATH: 'dist' 18 + SITE_NAME: 'webbed-site' 19 + WISP_HANDLE: 'fruno.win' 20 + 21 + steps: 22 + - name: build site 23 + command: | 24 + export PATH="$HOME/.nix-profile/bin:$PATH" 25 + 26 + gleam run 27 + - name: deploy to wisp 28 + command: | 29 + # Download Wisp CLI 30 + curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 31 + chmod +x wisp-cli 32 + 33 + # Deploy to Wisp 34 + ./wisp-cli \ 35 + "$WISP_HANDLE" \ 36 + --path "$SITE_PATH" \ 37 + --site "$SITE_NAME" \ 38 + --password "$WISP_APP_PASSWORD"
+11
README.md
··· 1 + # webbed site 2 + 3 + ```sh 4 + gleam run 5 + gleam run -m serve 6 + ``` 7 + 8 + # Credits 9 + 10 + - The [Myna](https://github.com/sayyadirfanali/Myna) monospace font 11 + - [Inclusive Sans](https://github.com/LivKing/Inclusive-Sans)
assets/fonts/InclusiveSans-Italic.woff2

This is a binary file and will not be displayed.

assets/fonts/InclusiveSans.woff2

This is a binary file and will not be displayed.

assets/fonts/Myna.woff2

This is a binary file and will not be displayed.

+1
assets/img/gleam.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill="#ffaff3" d="M50.417 7.19c1.816-5.147 8.57-6.338 12.038-2.122L80.63 27.166a12.189 12.189 0 0 0 9.118 4.44l28.629.697c5.466.133 8.676 6.177 5.735 10.771L108.68 67.177a12.165 12.165 0 0 0-1.415 10.04l8.172 27.411c1.557 5.223-3.2 10.155-8.493 8.78l-27.713-7.2a12.194 12.194 0 0 0-9.989 1.76l-23.578 16.245c-4.504 3.103-10.66.096-10.984-5.345l-1.696-28.554a12.169 12.169 0 0 0-4.763-8.95L5.477 63.993c-4.335-3.31-3.385-10.088 1.706-12.082l26.664-10.447a12.188 12.188 0 0 0 7.048-7.29z"/><path fill="#151515" d="M55.39.154c-3.23.57-6.183 2.715-7.405 6.178l-9.523 26.981a9.598 9.598 0 0 1-5.553 5.744L6.243 49.504c-6.842 2.68-8.165 12.092-2.332 16.547l22.744 17.37a9.571 9.571 0 0 1 3.75 7.047l1.696 28.553c.435 7.325 8.98 11.493 15.034 7.322l23.58-16.245v-.001a9.604 9.604 0 0 1 7.87-1.387l27.714 7.199c7.116 1.849 13.72-4.99 11.623-12.023l-8.17-27.41a9.578 9.578 0 0 1 1.114-7.905l15.432-24.105c3.957-6.181-.504-14.572-7.85-14.751l-28.63-.696a9.595 9.595 0 0 1-7.183-3.497L64.46 3.425C62.127.589 58.619-.417 55.389.153m.869 4.932c1.468-.26 3.07.248 4.206 1.627L78.639 28.81a14.78 14.78 0 0 0 11.052 5.383l28.63.695c3.585.088 5.544 3.783 3.618 6.79L106.508 65.78a14.761 14.761 0 0 0-1.716 12.172l8.171 27.41c1.018 3.415-1.892 6.44-5.363 5.538l-27.714-7.2a14.787 14.787 0 0 0-12.108 2.136l-23.58 16.245c-2.954 2.035-6.722.187-6.933-3.368L35.57 90.16a14.762 14.762 0 0 0-5.775-10.852L7.051 61.939c-2.837-2.167-2.26-6.31 1.078-7.619l26.666-10.447a14.782 14.782 0 0 0 8.545-8.84l9.523-26.98c.594-1.685 1.927-2.71 3.395-2.968"/><path fill="#151515" d="M47.093 72.832a5.082 5.082 0 1 0-1.766-10.01 5.082 5.082 0 0 0 1.766 10.01zm40.945-7.22a5.081 5.081 0 1 0-1.764-10.008 5.081 5.081 0 0 0 1.764 10.008zM63.356 71.7a2.594 2.594 0 0 0-1.434 1.365 2.59 2.59 0 0 0-.048 1.98 6.734 6.734 0 0 0 3.562 3.737h.001c.81.358 1.681.554 2.566.575h.002a6.755 6.755 0 0 0 2.591-.451h.001a6.735 6.735 0 0 0 2.222-1.409h.001a6.74 6.74 0 0 0 2.089-4.722 2.588 2.588 0 0 0-4.952-1.106c-.137.31-.212.645-.22.985a1.55 1.55 0 0 1-.134.595v.002a1.578 1.578 0 0 1-.87.828 1.572 1.572 0 0 1-1.201-.03h-.002a1.573 1.573 0 0 1-.5-.35v-.001a1.568 1.568 0 0 1-.329-.518 2.585 2.585 0 0 0-3.345-1.48z"/></svg>
+332
assets/style.css
··· 1 + :root { 2 + color-scheme: light dark; 3 + 4 + --color-fg: light-dark(#1f1f28, #dcd7ba); 5 + --color-bg: light-dark(#dcd7ba, #1f1f28); 6 + 7 + --color-fg-muted: light-dark(#363646, #bab28a); 8 + /* lighter in dark mode, darker in light mode */ 9 + --color-bg-variant: light-dark(#363646, #bab28a); 10 + 11 + --color-primary: light-dark(#957fb8, #938aa9); 12 + --color-primary-variant: light-dark(#938aa9, #957fb8); 13 + --color-secondary: light-dark(#6a9589, #7aa89f); 14 + --color-select: light-dark(#7fb4ca, #7e9cd8); 15 + --color-select-variant: light-dark(#7e9cd8, #7fb4ca); 16 + 17 + --font: "Inclusive Sans", sans-serif; 18 + --font-mono: "Myna", monospace; 19 + 20 + color: var(--color-fg); 21 + background: var(--color-bg); 22 + 23 + font-family: var(--font); 24 + font-size: calc(.333vw + 1em); 25 + } 26 + 27 + /* Fonts */ 28 + 29 + @font-face { 30 + font-family: "Inclusive Sans"; 31 + font-style: normal; 32 + src: url("/fonts/InclusiveSans.woff2") format("woff2-variations"); 33 + font-weight: 125 950; 34 + } 35 + 36 + @font-face { 37 + font-family: "Inclusive Sans"; 38 + font-style: italic; 39 + src: url("/fonts/InclusiveSans-Italic.woff2") format("woff2-variations"); 40 + font-weight: 125 950; 41 + } 42 + 43 + @font-face { 44 + font-family: "Myna"; 45 + font-style: normal; 46 + src: url("/fonts/Myna.woff2"); 47 + font-weight: 400; 48 + } 49 + 50 + 51 + /* Style */ 52 + 53 + main > * { 54 + max-width: 35rem; 55 + margin-left: auto; 56 + margin-right: auto; 57 + } 58 + 59 + ::selection { 60 + background: var(--color-select); 61 + color: var(--color-bg); 62 + } 63 + 64 + h1, h2, h3 { 65 + color: var(--color-primary); 66 + font-family: var(--font-mono); 67 + font-weight: inherit; 68 + 69 + a { 70 + text-decoration: none; 71 + color: inherit; 72 + 73 + &:hover { 74 + text-decoration: underline; 75 + } 76 + } 77 + 78 + &:hover:before { 79 + color: var(--color-fg); 80 + } 81 + } 82 + 83 + h1::before { 84 + content: "# "; 85 + } 86 + 87 + h2::before { 88 + content: "## "; 89 + } 90 + 91 + h3::before { 92 + content: "### "; 93 + } 94 + 95 + /* Only works in flex containers because CSS works in mysterious ways */ 96 + .icon { 97 + height: 1em; 98 + width: 1em; 99 + margin: auto 0.25em; 100 + } 101 + 102 + #navbar { 103 + view-transition-name: navbar; 104 + position: fixed; 105 + display: flex; 106 + flex-wrap: wrap; 107 + row-gap: 0.25em; 108 + font-family: var(--font-mono); 109 + bottom: 0.25rem; 110 + right: 1ch; 111 + left: 1ch; 112 + 113 + /* With a mouse, leave space for the little link hover thing 114 + * in the bottom left 115 + */ 116 + @media (pointer: fine) { 117 + bottom: 1.5em; 118 + } 119 + 120 + .prompt-pointed-right { 121 + width: 3ch; 122 + height: 100%; 123 + div { 124 + width: 2ch; 125 + height: 100%; 126 + clip-path: polygon(0 0, 50% 0, 100% 50%, 50% 100%, 0 100%); 127 + } 128 + } 129 + 130 + #prompt-left .prompt-rounded-left { 131 + background: var(--color-primary); 132 + } 133 + 134 + #prompt-left { 135 + display: flex; 136 + color: var(--color-bg); 137 + 138 + #prompt-user { 139 + display: flex; 140 + background: var(--color-primary); 141 + padding-left: 1ch; 142 + border-radius: 999em 0 0 999em; 143 + 144 + & + .prompt-pointed-right { 145 + background: linear-gradient(90deg, var(--color-primary) 25%, var(--color-secondary) 0 100%); 146 + div { 147 + background: var(--color-primary); 148 + } 149 + } 150 + } 151 + 152 + #prompt-dir { 153 + display: flex; 154 + background: var(--color-secondary); 155 + 156 + & + .prompt-pointed-right { 157 + background: linear-gradient(90deg, var(--color-secondary) 25%, var(--color-bg) 0 100%); 158 + div { 159 + background: var(--color-secondary); 160 + } 161 + } 162 + } 163 + } 164 + 165 + #prompt-right { 166 + float: right; 167 + display: flex; 168 + 169 + --jj-bg: color-mix(in srgb, var(--color-bg) 90%, var(--color-fg) 10%); 170 + --gleam-bg: color-mix(in srgb, var(--color-bg) 80%, var(--color-fg) 20%); 171 + 172 + .prompt-pointed-right:first-child { 173 + background: linear-gradient(90deg, var(--color-bg) 25%, var(--jj-bg) 0 100%); 174 + div { 175 + background: var(--color-bg); 176 + } 177 + } 178 + 179 + #prompt-jj-change-id { 180 + display: flex; 181 + background: var(--jj-bg); 182 + color: var(--color-fg-muted); 183 + 184 + a { color: inherit } 185 + 186 + & + .prompt-pointed-right { 187 + background: linear-gradient(90deg, var(--jj-bg) 25%, var(--gleam-bg) 0 100%); 188 + div { 189 + background: var(--jj-bg); 190 + } 191 + } 192 + } 193 + 194 + #prompt-gleam { 195 + display: flex; 196 + background: var(--gleam-bg); 197 + 198 + & + .prompt-pointed-right { 199 + width: 2ch; 200 + background: linear-gradient(90deg, var(--gleam-bg) 25%, var(--color-bg) 0 100%); 201 + div { 202 + background: var(--gleam-bg); 203 + } 204 + } 205 + } 206 + } 207 + 208 + #nav { 209 + flex-grow: 100; 210 + list-style-type: none; 211 + margin: 0; 212 + padding: 0; 213 + display: flex; 214 + gap: 1ch; 215 + 216 + a { 217 + padding: 0 1ch; 218 + border-radius: 0.25em; 219 + color: var(--color-fg); 220 + 221 + &:visited { 222 + color: inherit; 223 + } 224 + 225 + /* 226 + * Pseudo background element we can animate during page transitions 227 + */ 228 + &.active::before { 229 + view-transition-name: nav-active-bg; 230 + border-radius: 0.25em; 231 + content: ""; 232 + position: absolute; 233 + top: 0; 234 + left: 0; 235 + height: 100%; 236 + width: 100%; 237 + z-index: -100; 238 + background: var(--color-fg); 239 + } 240 + 241 + &.active { 242 + position: relative; 243 + color: var(--color-bg); 244 + background: var(--color-fg); 245 + } 246 + 247 + &:hover { 248 + color: var(--color-bg); 249 + background: var(--color-fg); 250 + } 251 + } 252 + } 253 + 254 + #nav-cursor { 255 + margin-right: 1ch; 256 + } 257 + 258 + &:hover #nav-cursor { 259 + animation: blink 1.5s steps(1, start) infinite; 260 + } 261 + 262 + #nav-chevron { 263 + display: none; 264 + width: 1ch; 265 + margin-right: 1ch; 266 + } 267 + 268 + /* I wanted to avoid breakpoints, but I can't figure out a way around this one */ 269 + @media (max-width: 768px) { 270 + #nav-chevron { display: inherit } 271 + #prompt-right { 272 + position: relative; 273 + left: -2ch; 274 + } 275 + #nav { 276 + order: 3; 277 + width: 100vw; 278 + } 279 + 280 + #prompt-right .prompt-pointed-right:first-child { 281 + background: linear-gradient(90deg, var(--color-secondary) 25%, var(--jj-bg) 0 100%); 282 + div { 283 + background: var(--color-secondary); 284 + } 285 + } 286 + } 287 + } 288 + 289 + a { 290 + color: var(--color-select); 291 + text-decoration: none; 292 + 293 + &:hover { 294 + text-decoration: underline; 295 + color: var(--color-select-variant); 296 + } 297 + } 298 + 299 + @view-transition { 300 + navigation: auto; 301 + } 302 + 303 + @keyframes blink { 304 + 50% { visibility: hidden } 305 + } 306 + 307 + @keyframes slide-out-up { 308 + from { transform: translateY(0) } 309 + to { transform: translateY(-100vh) } 310 + } 311 + 312 + @keyframes slide-in-up { 313 + from { transform: translateY(100vh) } 314 + to { transform: translateY(0) } 315 + } 316 + 317 + main { 318 + view-transition-name: main-content; 319 + } 320 + 321 + ::view-transition-old(main-content) { 322 + animation: 250ms ease-in both slide-out-up; 323 + } 324 + 325 + ::view-transition-new(main-content) { 326 + animation: 250ms ease-in both slide-in-up; 327 + } 328 + 329 + ::view-transition-group(navbar) { 330 + z-index: 100; 331 + } 332 +
+66
dev/serve.gleam
··· 1 + import gleam/bytes_tree 2 + import gleam/erlang/process 3 + import gleam/http/request.{type Request} 4 + import gleam/http/response.{type Response} 5 + import gleam/list 6 + import gleam/string 7 + import mist.{type Connection, type ResponseData} 8 + import simplifile 9 + 10 + pub fn main() { 11 + let assert Ok(_) = 12 + mist.new(handler) 13 + // Listen on all interfaces so I can check the site on my phone 14 + // Careful if you're in a public wifi! 15 + |> mist.bind("0.0.0.0") 16 + |> mist.port(8080) 17 + |> mist.start 18 + 19 + process.sleep_forever() 20 + } 21 + 22 + fn handler(req: Request(Connection)) -> Response(ResponseData) { 23 + let path = case req.path { 24 + // I tinker with the css a lot so I like the direct updates 25 + "/style.css" -> "/../assets/style.css" 26 + "/" -> "/index.html" 27 + p -> 28 + case string.ends_with(p, "/") { 29 + True -> p <> "index.html" 30 + False -> 31 + case string.contains(p, ".") { 32 + True -> p 33 + False -> p <> ".html" 34 + } 35 + } 36 + } 37 + 38 + let file_path = "./dist" <> path 39 + 40 + case simplifile.read_bits(file_path) { 41 + Ok(bits) -> 42 + response.new(200) 43 + |> response.set_header("content-type", get_content_type(path)) 44 + |> response.set_body(mist.Bytes(bytes_tree.from_bit_array(bits))) 45 + Error(simplifile.Enoent) -> 46 + response.new(404) 47 + |> response.set_body(mist.Bytes(bytes_tree.from_string("Not Found"))) 48 + Error(_) -> 49 + response.new(500) 50 + |> response.set_body( 51 + mist.Bytes(bytes_tree.from_string("Internal server error")), 52 + ) 53 + } 54 + } 55 + 56 + fn get_content_type(path: String) -> String { 57 + case string.split(path, ".") |> list.last { 58 + Ok("html") -> "text/html; charset=utf-8" 59 + Ok("css") -> "text/css; charset=utf-8" 60 + Ok("js") -> "application/javascript" 61 + Ok("png") -> "image/png" 62 + Ok("jpg") | Ok("jpeg") -> "image/jpeg" 63 + Ok("svg") -> "image/svg+xml" 64 + _ -> "application/octet-stream" 65 + } 66 + }
+22
gleam.toml
··· 1 + name = "webbed_site" 2 + version = "1.0.0" 3 + 4 + # Fill out these fields if you intend to generate HTML documentation or publish 5 + # your project to the Hex package manager. 6 + # 7 + # description = "" 8 + # licences = ["Apache-2.0"] 9 + # repository = { type = "github", user = "", repo = "" } 10 + # links = [{ title = "Website", href = "" }] 11 + # 12 + # For a full reference of all the available options, you can have a look at 13 + # https://gleam.run/writing-gleam/gleam-toml/. 14 + 15 + [dependencies] 16 + gleam_stdlib = ">= 0.44.0 and < 2.0.0" 17 + lustre_ssg = { git = "https://github.com/fruno-bulax/lustre_ssg", ref = "6c132bd34ab75a1144d31c0f896cab0e3cbf80fc" } 18 + lustre = ">= 5.4.0 and < 6.0.0" 19 + gleam_time = ">= 1.6.0 and < 2.0.0" 20 + simplifile = ">= 2.3.2 and < 3.0.0" 21 + tom = ">= 2.0.0 and < 3.0.0" 22 + shellout = ">= 1.7.0 and < 2.0.0"
+33
manifest.toml
··· 1 + # This file was generated by Gleam 2 + # You typically do not need to edit this file 3 + 4 + packages = [ 5 + { name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" }, 6 + { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, 7 + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 8 + { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, 9 + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, 10 + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, 11 + { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, 12 + { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, 13 + { name = "gleam_stdlib", version = "0.67.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "6CE3E4189A8B8EC2F73AB61A2FBDE49F159D6C9C61C49E3B3082E439F260D3D0" }, 14 + { name = "gleam_time", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "0DF3834D20193F0A38D0EB21F0A78D48F2EC276C285969131B86DF8D4EF9E762" }, 15 + { name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" }, 16 + { name = "jot", version = "8.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "houdini", "splitter"], otp_app = "jot", source = "hex", outer_checksum = "CCE11C8904B129CC9DA3A293B645884B91C96D252183F6DBCAEFA8F2587CAEFD" }, 17 + { name = "lustre", version = "5.4.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "40E097BABCE65FB7C460C073078611F7F5802EB07E1A9BFB5C229F71B60F8E50" }, 18 + { name = "lustre_ssg", version = "0.12.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_regexp", "gleam_stdlib", "jot", "lustre", "simplifile", "temporary", "tom"], source = "git", repo = "https://github.com/fruno-bulax/lustre_ssg", commit = "6c132bd34ab75a1144d31c0f896cab0e3cbf80fc" }, 19 + { name = "shellout", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "1BDC03438FEB97A6AF3E396F4ABEB32BECF20DF2452EC9A8C0ACEB7BDDF70B14" }, 20 + { name = "simplifile", version = "2.3.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "E049B4DACD4D206D87843BCF4C775A50AE0F50A52031A2FFB40C9ED07D6EC70A" }, 21 + { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, 22 + { 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 + { name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" }, 24 + ] 25 + 26 + [requirements] 27 + gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 28 + gleam_time = { version = ">= 1.6.0 and < 2.0.0" } 29 + lustre = { version = ">= 5.4.0 and < 6.0.0" } 30 + lustre_ssg = { git = "https://github.com/fruno-bulax/lustre_ssg", ref = "6c132bd34ab75a1144d31c0f896cab0e3cbf80fc" } 31 + shellout = { version = ">= 1.7.0 and < 2.0.0" } 32 + simplifile = { version = ">= 2.3.2 and < 3.0.0" } 33 + tom = { version = ">= 2.0.0 and < 3.0.0" }
+8
posts/2026-01-03-initial-commit.djot
··· 1 + --- 2 + title = "Initial Commit" 3 + date = 2026-01-03 4 + --- 5 + 6 + # Initial Commit 7 + 8 + Hello there!
+30
serve💅.py
··· 1 + #!/usr/bin/env python3 2 + 3 + import http.server 4 + import socketserver 5 + import sys 6 + import os.path 7 + 8 + PORT = 8080 9 + # Be careful in public networks! 10 + # I just wanna check the site on my phone 11 + HOST = "0.0.0.0" 12 + ROOT = "./dist" 13 + 14 + class CleanUrlHandler(http.server.SimpleHTTPRequestHandler): 15 + def __init__(self, *args, **kwargs): 16 + super().__init__(*args, directory=ROOT, **kwargs) 17 + 18 + def do_GET(self): 19 + # This is a very shitty check, but it's good enough 20 + if not '.' in self.path and not os.path.isdir(self.translate_path(self.path)): 21 + self.path += '.html' 22 + 23 + super().do_GET() 24 + 25 + with socketserver.TCPServer((HOST, PORT), CleanUrlHandler) as http: 26 + print(f"Serving 💅 at {HOST}:{PORT}") 27 + try: 28 + http.serve_forever() 29 + except KeyboardInterrupt: 30 + sys.exit(0)
+18
src/component.gleam
··· 1 + import lustre/attribute 2 + import lustre/element.{type Element} 3 + import lustre/element/html 4 + import gleam/string 5 + 6 + pub fn header(title: String, h) -> Element(Nil) { 7 + let id = slugify(title) 8 + h( 9 + [attribute.id(id)], 10 + [html.a([attribute.href("#" <> id)], [html.text(title)])], 11 + ) 12 + } 13 + 14 + fn slugify(text: String) -> String { 15 + text 16 + |> string.lowercase 17 + |> string.replace(" ", "-") 18 + }
+26
src/meta.gleam
··· 1 + import gleam/result 2 + import shellout 3 + 4 + pub type SiteMeta { 5 + SiteMeta(commit_hash: String, gleam_version: String) 6 + } 7 + 8 + pub fn fetch() -> Result(SiteMeta, String) { 9 + use commit_hash <- result.try(commit_hash()) 10 + use gleam_version <- result.map(gleam_version()) 11 + SiteMeta(commit_hash:, gleam_version:) 12 + } 13 + 14 + fn commit_hash() -> Result(String, String) { 15 + shellout.command(run: "git", with: ["rev-parse", "HEAD"], in: ".", opt: []) 16 + |> result.map_error(fn(error) { "Failed to fetch commit hash: " <> error.1 }) 17 + } 18 + 19 + fn gleam_version() -> Result(String, String) { 20 + let result = shellout.command("gleam", ["--version"], ".", []) 21 + case result { 22 + Ok("gleam " <> version) -> Ok(version) 23 + Ok(_) -> Error("gleam --version returned unexpected result") 24 + Error(error) -> Error("Failed to fetch gleam version: " <> error.1) 25 + } 26 + }
+118
src/page.gleam
··· 1 + import gleam/string 2 + import lustre/attribute 3 + import lustre/element.{type Element} 4 + import lustre/element/html 5 + import meta.{type SiteMeta} 6 + 7 + pub type Nav { 8 + Index 9 + Blog 10 + About 11 + } 12 + 13 + pub fn render( 14 + active_nav: Nav, 15 + title: String, 16 + meta: SiteMeta, 17 + content: List(Element(msg)), 18 + ) -> Element(msg) { 19 + html.html([attribute.lang("en")], [ 20 + html.head([], [ 21 + html.meta([attribute.charset("utf-8")]), 22 + html.meta([ 23 + attribute.name("viewport"), 24 + attribute.content("width=device-width, initial-scale=1.0"), 25 + ]), 26 + html.title([], title), 27 + html.link([ 28 + attribute.rel("stylesheet"), 29 + attribute.href("/style.css"), 30 + ]), 31 + ]), 32 + html.body([], [navbar(active_nav, meta), html.main([], content)]), 33 + ]) 34 + } 35 + 36 + fn navbar(active: Nav, meta: SiteMeta) -> Element(msg) { 37 + html.nav([attribute.id("navbar")], [ 38 + prompt_left(), 39 + nav(active), 40 + prompt_right(meta), 41 + ]) 42 + } 43 + 44 + fn prompt_left() -> Element(msg) { 45 + html.div([attribute.id("prompt-left")], [ 46 + html.div([attribute.id("prompt-user")], [ 47 + html.text("fruno"), 48 + ]), 49 + html.div([attribute.class("prompt-pointed-right")], [html.div([], [])]), 50 + html.div([attribute.id("prompt-dir")], [ 51 + html.text("~/webbed_site"), 52 + ]), 53 + html.div([attribute.class("prompt-pointed-right")], [html.div([], [])]), 54 + ]) 55 + } 56 + 57 + fn nav(active: Nav) -> Element(msg) { 58 + html.ul([attribute.id("nav")], [ 59 + html.span([attribute.id("nav-chevron")], [html.text(">")]), 60 + html.span([attribute.id("nav-cursor")], [html.text("█")]), 61 + html.li([], [ 62 + html.a( 63 + [ 64 + attribute.href("/"), 65 + attribute.id("home"), 66 + attribute.classes([#("active", active == Index)]), 67 + ], 68 + [html.text("/home")], 69 + ), 70 + ]), 71 + html.li([], [ 72 + html.a( 73 + [ 74 + attribute.href("/blog/"), 75 + attribute.id("blog"), 76 + attribute.classes([#("active", active == Blog)]), 77 + ], 78 + [html.text("/blog/")], 79 + ), 80 + ]), 81 + html.li([], [ 82 + html.a( 83 + [ 84 + attribute.href("/about"), 85 + attribute.id("about"), 86 + attribute.classes([#("active", active == About)]), 87 + ], 88 + [html.text("/about")], 89 + ), 90 + ]), 91 + ]) 92 + } 93 + 94 + fn prompt_right(meta: SiteMeta) -> Element(msg) { 95 + html.div([attribute.id("prompt-right")], [ 96 + html.div([attribute.class("prompt-pointed-right")], [html.div([], [])]), 97 + html.div([attribute.id("prompt-jj-change-id")], [ 98 + html.a( 99 + [ 100 + attribute.href( 101 + "https://tangled.org/fruno.win/webbed-site/commit/" <> meta.commit_hash, 102 + ), 103 + ], 104 + [html.text(string.slice(meta.commit_hash, 0, 4))], 105 + ), 106 + ]), 107 + html.div([attribute.class("prompt-pointed-right")], [html.div([], [])]), 108 + html.div([attribute.id("prompt-gleam")], [ 109 + html.img([ 110 + attribute.class("icon"), 111 + attribute.alt("Lucy, the mascot of the gleam programming language"), 112 + attribute.src("/img/gleam.svg"), 113 + ]), 114 + html.text(meta.gleam_version), 115 + ]), 116 + html.div([attribute.class("prompt-pointed-right")], [html.div([], [])]), 117 + ]) 118 + }
+11
src/page/about.gleam
··· 1 + import meta.{type SiteMeta} 2 + import lustre/element.{type Element} 3 + import lustre/element/html 4 + import page 5 + import component 6 + 7 + pub fn view(meta: SiteMeta) -> Element(Nil) { 8 + page.render(page.About, "about", meta, [ 9 + component.header("about", html.h1) 10 + ]) 11 + }
+60
src/page/blog.gleam
··· 1 + import meta.{type SiteMeta} 2 + import gleam/list 3 + import gleam/string 4 + import gleam/time/calendar.{type Date} 5 + import lustre/attribute 6 + import lustre/element.{type Element} 7 + import lustre/element/html 8 + import lustre/ssg/djot 9 + import page 10 + import simplifile 11 + import tom 12 + 13 + pub type Post { 14 + Post(title: String, date: Date, slug: String, content: List(Element(Nil))) 15 + } 16 + 17 + const posts_dir = "./posts" 18 + 19 + pub fn posts() -> List(Post) { 20 + let assert Ok(files) = simplifile.read_directory(posts_dir) 21 + as "Failed to read posts directory" 22 + 23 + files 24 + |> list.map(read_post) 25 + |> list.sort(fn(a, b) { calendar.naive_date_compare(a.date, b.date) }) 26 + } 27 + 28 + pub fn list_all(posts: List(Post), meta: SiteMeta) -> Element(Nil) { 29 + page.render(page.Blog, "blog", meta, [ 30 + html.article([], [ 31 + html.header([], [html.h1([], [html.text("blog")])]), 32 + html.ul([], list.map(posts, post_list_item)), 33 + ]), 34 + ]) 35 + } 36 + 37 + fn post_list_item(post: Post) -> Element(Nil) { 38 + html.a([attribute.href("/blog/" <> post.slug)], [html.text(post.title)]) 39 + } 40 + 41 + pub fn view_post(post: Post, meta: SiteMeta) -> Element(Nil) { 42 + page.render(page.Blog, post.title, meta, post.content) 43 + } 44 + 45 + fn read_post(filename: String) -> Post { 46 + let assert [slug, "djot"] = string.split(filename, ".") 47 + as "Unexpected post file type" 48 + let assert Ok(content) = simplifile.read(posts_dir <> "/" <> filename) 49 + as "Failed to read file" 50 + 51 + let assert Ok(meta) = djot.metadata(content) as "Failed to read post metadata" 52 + let assert Ok(title) = tom.get_string(meta, ["title"]) as "Missing post title" 53 + let assert Ok(date) = tom.get_date(meta, ["date"]) 54 + as "Missing or malformed post date" 55 + 56 + // TODO scaffolding like nav and such 57 + let content = djot.render(content, djot.default_renderer()) 58 + 59 + Post(title:, date:, slug:, content:) 60 + }
+22
src/page/index.gleam
··· 1 + import meta.{type SiteMeta} 2 + import component 3 + import lustre/element.{type Element} 4 + import lustre/element/html 5 + import page 6 + 7 + pub fn view(meta: SiteMeta) -> Element(Nil) { 8 + page.render(page.Index, "index", meta, [ 9 + component.header("wip", html.h1), 10 + html.p([], [ 11 + html.text( 12 + "This site is very work-in-progress but it kind of looks like a terminal and that's pretty neat.", 13 + ), 14 + ]), 15 + component.header("Nested headers work too!", html.h2), 16 + html.p([], [ 17 + html.text( 18 + "This is a purely static site without javascript but with mutli-page view transitions instead. Firefox can't do them yet. boooo!", 19 + ), 20 + ]), 21 + ]) 22 + }
+29
src/webbed_site.gleam
··· 1 + import gleam/dict 2 + import gleam/list 3 + import lustre/ssg 4 + import meta 5 + import page/about 6 + import page/blog 7 + import page/index 8 + 9 + pub fn main() { 10 + let assert Ok(meta) = meta.fetch() as "Failed to fetch site meta" 11 + let posts = blog.posts() 12 + 13 + let build = 14 + ssg.new("./dist") 15 + |> ssg.add_static_route("/", index.view(meta)) 16 + |> ssg.add_static_route("/about", about.view(meta)) 17 + |> ssg.add_static_route("/blog/index", blog.list_all(posts, meta)) 18 + |> ssg.add_dynamic_route( 19 + "/blog/", 20 + posts 21 + |> list.map(fn(post) { #(post.slug, post) }) 22 + |> dict.from_list(), 23 + blog.view_post(_, meta), 24 + ) 25 + |> ssg.add_static_dir("./assets") 26 + |> ssg.build 27 + 28 + let assert Ok(_) = build as "Build failed" 29 + }