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 + ./serve💅.py 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>
+354
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: #7e9cd8; 15 + --color-select-variant: #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); 33 + font-weight: 300 700; 34 + font-display: swap; 35 + } 36 + 37 + @font-face { 38 + font-family: "Inclusive Sans"; 39 + font-style: italic; 40 + src: url(/fonts/InclusiveSans-Italic.woff2) format(woff2); 41 + font-weight: 300 700; 42 + font-display: swap; 43 + } 44 + 45 + @font-face { 46 + font-family: "Myna"; 47 + font-style: normal; 48 + src: url(/fonts/Myna.woff2); 49 + font-weight: 400; 50 + } 51 + 52 + /* Style */ 53 + 54 + main { 55 + view-transition-name: main-content; 56 + padding-bottom: 2em; 57 + } 58 + 59 + main > * { 60 + max-width: 35rem; 61 + margin-left: auto; 62 + margin-right: auto; 63 + } 64 + 65 + ::selection { 66 + background: var(--color-select); 67 + color: var(--color-bg); 68 + } 69 + 70 + .heading { 71 + color: var(--color-primary); 72 + font-family: var(--font-mono); 73 + 74 + h1, h2, h3, h4, h5, h6 { font-weight: inherit } 75 + 76 + &:hover .header-anchor { 77 + color: var(--color-fg); 78 + } 79 + 80 + .header-anchor { 81 + margin-right: 1ch; 82 + text-decoration: none; 83 + color: inherit; 84 + 85 + &:hover + * { 86 + color: var(--color-select-variant); 87 + text-decoration: underline; 88 + } 89 + } 90 + } 91 + 92 + a { 93 + color: var(--color-select); 94 + text-decoration: none; 95 + 96 + &:hover { 97 + text-decoration: underline; 98 + color: var(--color-select-variant); 99 + } 100 + } 101 + 102 + /* Only works in flex containers because CSS works in mysterious ways */ 103 + .icon { 104 + height: 1em; 105 + width: 1em; 106 + margin: auto 0.2em; 107 + } 108 + 109 + .cursor::before { 110 + content: "█"; 111 + } 112 + 113 + #navbar { 114 + view-transition-name: navbar; 115 + position: fixed; 116 + z-index: 10; 117 + display: flex; 118 + flex-wrap: wrap; 119 + font-family: var(--font-mono); 120 + row-gap: 0.25em; 121 + bottom: 0; 122 + right: 1ch; 123 + left: 1ch; 124 + padding-bottom: 0.5em; 125 + background: var(--color-bg); 126 + 127 + .prompt-pointed-right { 128 + width: 3ch; 129 + height: 100%; 130 + div { 131 + width: 2ch; 132 + height: 100%; 133 + clip-path: polygon(0 0, 50% 0, 100% 50%, 50% 100%, 0 100%); 134 + } 135 + } 136 + 137 + #prompt-left .prompt-rounded-left { 138 + background: var(--color-primary); 139 + } 140 + 141 + #prompt-left { 142 + display: flex; 143 + color: var(--color-bg); 144 + 145 + #prompt-user { 146 + display: flex; 147 + background: var(--color-primary); 148 + padding-left: 1ch; 149 + border-radius: 999em 0 0 999em; 150 + 151 + & + .prompt-pointed-right { 152 + background: linear-gradient(90deg, var(--color-primary) 25%, var(--color-secondary) 0 100%); 153 + div { 154 + background: var(--color-primary); 155 + } 156 + } 157 + } 158 + 159 + #prompt-dir { 160 + display: flex; 161 + background: var(--color-secondary); 162 + 163 + & + .prompt-pointed-right { 164 + background: linear-gradient(90deg, var(--color-secondary) 25%, var(--color-bg) 0 100%); 165 + div { 166 + background: var(--color-secondary); 167 + } 168 + } 169 + } 170 + } 171 + 172 + #prompt-right { 173 + .icon { margin-left: 0 } 174 + float: right; 175 + display: flex; 176 + 177 + --commit-bg: color-mix(in srgb, var(--color-bg) 90%, var(--color-fg) 10%); 178 + --gleam-bg: color-mix(in srgb, var(--color-bg) 80%, var(--color-fg) 20%); 179 + 180 + .prompt-pointed-right:first-child { 181 + z-index: 1; 182 + background: linear-gradient(90deg, var(--color-bg) 25%, var(--commit-bg) 0 100%); 183 + div { 184 + background: var(--color-bg); 185 + } 186 + } 187 + 188 + #prompt-commit { 189 + display: flex; 190 + background: var(--commit-bg); 191 + color: var(--color-fg-muted); 192 + 193 + a { color: inherit } 194 + 195 + & + .prompt-pointed-right { 196 + background: linear-gradient(90deg, var(--commit-bg) 25%, var(--gleam-bg) 0 100%); 197 + div { 198 + background: var(--commit-bg); 199 + } 200 + } 201 + } 202 + 203 + #prompt-gleam { 204 + display: flex; 205 + background: var(--gleam-bg); 206 + 207 + & + .prompt-pointed-right { 208 + width: 2ch; 209 + background: linear-gradient(90deg, var(--gleam-bg) 25%, var(--color-bg) 0 100%); 210 + div { 211 + background: var(--gleam-bg); 212 + } 213 + } 214 + } 215 + } 216 + 217 + #nav { 218 + view-transition-name: nav; 219 + flex-grow: 100; 220 + display: flex; 221 + 222 + ul { 223 + display: flex; 224 + list-style-type: none; 225 + margin: 0; 226 + padding: 0; 227 + gap: 1ch; 228 + 229 + a { 230 + padding: 0 1ch; 231 + border-radius: 0.25em; 232 + color: var(--color-fg); 233 + 234 + &:visited { 235 + color: inherit; 236 + } 237 + 238 + /* 239 + * Pseudo background element we can animate during page transitions 240 + */ 241 + &.active::before { 242 + view-transition-name: nav-active-bg; 243 + border-radius: 0.25em; 244 + content: ""; 245 + position: absolute; 246 + top: 0; 247 + left: 0; 248 + height: 100%; 249 + width: 100%; 250 + z-index: 50; 251 + background: var(--color-fg); 252 + } 253 + 254 + &.active { 255 + position: relative; 256 + color: var(--color-bg); 257 + background: var(--color-fg); 258 + } 259 + 260 + span { 261 + position: relative; 262 + z-index: 100; 263 + } 264 + 265 + &:hover { 266 + color: var(--color-bg); 267 + background: var(--color-fg); 268 + } 269 + } 270 + }; 271 + } 272 + 273 + .cursor { 274 + margin-right: 2ch; 275 + } 276 + 277 + &:hover .cursor { 278 + animation: blink 1.5s steps(1, start) infinite; 279 + } 280 + 281 + #nav-chevron { 282 + display: none; 283 + width: 1ch; 284 + margin-right: 1ch; 285 + } 286 + } 287 + 288 + /* I wanted to avoid breakpoints, but I can't figure out a way around this one */ 289 + @media (max-width: 700px) { 290 + main { padding-bottom: 4em } 291 + 292 + #navbar { 293 + #nav-chevron { display: inherit } 294 + 295 + #prompt-left { 296 + margin-right: -3ch; 297 + } 298 + 299 + #nav { 300 + order: 3; 301 + width: 100vw; 302 + } 303 + 304 + #prompt-right .prompt-pointed-right:first-child { 305 + background: linear-gradient(90deg, var(--color-secondary) 25%, var(--commit-bg) 0 100%); 306 + div { 307 + background: var(--color-secondary); 308 + } 309 + } 310 + } 311 + } 312 + 313 + @media (max-width: 380px) { 314 + #prompt-right { display: none !important } 315 + } 316 + 317 + @view-transition { 318 + navigation: auto; 319 + } 320 + 321 + @keyframes blink { 322 + 50% { visibility: hidden } 323 + } 324 + 325 + @keyframes slide-out-up { 326 + from { transform: translateY(0) } 327 + to { transform: translateY(min(-100vh, -100%)) } 328 + } 329 + 330 + @keyframes slide-in-up { 331 + from { transform: translateY(100vh) } 332 + to { transform: translateY(0) } 333 + } 334 + 335 + ::view-transition-old(main-content) { 336 + animation: 161ms ease-in both slide-out-up; 337 + } 338 + 339 + ::view-transition-new(main-content) { 340 + animation: 161ms ease-in both slide-in-up; 341 + animation-delay: 161ms; 342 + } 343 + 344 + ::view-transition-group(navbar) { 345 + z-index: 10; 346 + } 347 + 348 + ::view-transition-group(nav-active-bg) { 349 + z-index: 50; 350 + } 351 + 352 + ::view-transition-group(nav) { 353 + z-index: 100; 354 + }
+24
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 has a lot of outdated deps :/ 18 + lustre_ssg = { git = "https://github.com/fruno-bulax/lustre_ssg", ref = "6c132bd34ab75a1144d31c0f896cab0e3cbf80fc" } 19 + lustre = ">= 5.4.0 and < 6.0.0" 20 + gleam_time = ">= 1.6.0 and < 2.0.0" 21 + simplifile = ">= 2.3.2 and < 3.0.0" 22 + shellout = ">= 1.7.0 and < 2.0.0" 23 + jot = ">= 8.0.0 and < 9.0.0" 24 + tom = ">= 2.0.0 and < 3.0.0"
+34
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 + jot = { version = ">= 8.0.0 and < 9.0.0" } 30 + lustre = { version = ">= 5.4.0 and < 6.0.0" } 31 + lustre_ssg = { git = "https://github.com/fruno-bulax/lustre_ssg", ref = "6c132bd34ab75a1144d31c0f896cab0e3cbf80fc" } 32 + shellout = { version = ">= 1.7.0 and < 2.0.0" } 33 + simplifile = { version = ">= 2.3.2 and < 3.0.0" } 34 + tom = { version = ">= 2.0.0 and < 3.0.0" }
+84
pages/dots.djot
··· 1 + # what i use 2 + 3 + We all use software. Good software, bad software, great software, shit software. 4 + Here's a wall of text about the software I use. 5 + Oh, also some hardware too, I guess. 6 + 7 + ## operating system 8 + 9 + Arch (btw). I tried to like NixOS. I really did. I tried twice! I gave up twice… 10 + 11 + The idea of having a single configuration for your whole system is incredibly 12 + appealing, but I never got over Nix-the-language and the sheer complexity 13 + of it all. So back to Arch it is. 14 + 15 + ## desktop environment 16 + 17 + I am currently quite enjoying [Niri](https://github.com/YaLTeR/niri) in combination 18 + with the [Noctalia](https://noctalia.dev/) shell. Tiling window managers are a way 19 + of life, and Noctalia comes with basic shell things I need. If I never have to 20 + configure a Waybar again, it'll be too soon. 21 + 22 + I tend to go _all in_ on color schemes. After yeeeaars on Gruvbox, I switched to 23 + [Kanagawa](https://github.com/rebelot/kanagawa.nvim) a while ago. 24 + My shell, terminal, editor, GTK/QT theme, _even this website_ are styled accordingly. 25 + 26 + ## terminal stuff 27 + 28 + Yeah, I thought it was hype too, but [Ghostty](https://ghostty.org/) 29 + is actually really nice. No complaints so far! 30 + 31 + After a long time of using zsh at work, I decided to pick up 32 + [Elvish](https://elv.sh/) for a few days. While it's definitely worth 33 + checking out, I ultimately went back to [fish](https://fishshell.com/). 34 + Dunno why I left it, it's really, really nice! Bonus points for being 35 + one of the blessed shells that usually gets shell completions out of the box. 36 + 37 + Like everyone else I use [Starship](https://starship.rs/) for my prompt. 38 + I will say that configuring that did _not_ spark joy. Until native 39 + support comes along, I'm also using the 40 + [starship-jj](https://gitlab.com/lanastara_foss/starship-jj) module. 41 + 42 + ## browser 43 + 44 + I finally did it. I stopped using Firefox. It was a long time coming, 45 + but evolving into a "Modern AI Browser" finally tipped me over the edge. 46 + I'm currently using [Helium](https://helium.computer/), a chromium-based browser. 47 + I definitely do miss some Firefox-isms, but overall I'm pretty happy. 48 + 49 + By the way, if you're using Firefox, you're not seeing the multi-page 50 + view transitions on this website! 51 + 52 + ## coding 53 + 54 + I'll probably leave this a stub because otherwise it will escalate. 55 + 56 + After years I finally wrote a decent config from actual scratch. 57 + No LazyVim. Not even kickstart. Just nvim nightly and the new native 58 + package manager. I mostly use plugins from the `mini` family, as well as 59 + `blink` for completions and `flash` for navigation. 60 + 61 + Of course I also use LSP. LSP is great! I just wish it was good… 62 + 63 + ## multimedia 64 + 65 + When I abandoned the last remnants of Windows I jumped from VLC to mpv 66 + because it didn't quite work right on Wayland somehow. 67 + The default UI is butt-ugly (and this is coming from a VLC user!), 68 + but my nvim config skills have prepared me for also configuring mpv. 69 + Of course I've changed the colors over to Kanagawa. 70 + 71 + I also have a local music library (sourced from Bandcamp), but I haven't 72 + settled on a music player yet. 73 + 74 + ## keeeeeebs 75 + 76 + i love dem ortho split keebs. looove em! 77 + I use a [3w6](https://github.com/weteor/3W6) at work and a 78 + [corne](https://github.com/foostan/crkbd) at home. The extra column is really 79 + a must for the gaming-layer. 80 + Both layouts are heavily based on [miryoku](https://github.com/manna-harbour/miryoku), 81 + but I'm still stuck on QWERTY. 82 + 83 + I'd really like to try something fancier with tenting or a curved surface or something, 84 + but for now I'm happy with what I've got.
+13
pages/index.djot
··· 1 + # hello, world 2 + 3 + hi, I'm fruno, a professional software developer and amateur 4 + [crytpid](https://en.wikipedia.org/wiki/List_of_cryptids) based in Vienna. 5 + I also like opossums. 6 + 7 + At my day job I use Kotlin, but my free time is mostly spent with 8 + [Gleam](https://gleam.run/) or Rust (usually on the Gleam compiler). 9 + You can find me over on 10 + [GitHub](https://github.com/fruno-bulax) or [tangled](https://tangled.org/fruno.win), 11 + which also hosts the [source code](https://tangled.org/fruno.win/webbed-site/) 12 + of this very site! I'm not really on any socials but you can message me on 13 + [Bluesky](https://bsky.app/profile/fruno.win).
+7
posts/2026-01-03-initial-commit.djot
··· 1 + --- 2 + title = "Initial Commit" 3 + --- 4 + 5 + # initial commit 6 + 7 + This post is gonna detail how I made this site, but for now it's just a placeholder.
+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)
+50
src/blog.gleam
··· 1 + import component 2 + import gleam/list 3 + import gleam/string 4 + import lustre/attribute 5 + import lustre/element.{type Element} 6 + import lustre/element/html 7 + import lustre/ssg/djot 8 + import simplifile 9 + import tom 10 + 11 + pub type Post { 12 + Post(slug: String, title: String, content: String) 13 + } 14 + 15 + const posts_dir = "./posts" 16 + 17 + pub fn posts() -> List(Post) { 18 + let assert Ok(files) = simplifile.read_directory(posts_dir) 19 + as "Failed to read posts directory" 20 + 21 + files 22 + |> list.sort(string.compare) 23 + |> list.map(read_post) 24 + } 25 + 26 + fn read_post(filename: String) { 27 + let assert [slug, "djot"] = string.split(filename, ".") 28 + as "Unexpected post file type" 29 + 30 + let assert Ok(content) = simplifile.read(posts_dir <> "/" <> filename) 31 + as "Failed to read file" 32 + let assert Ok(meta) = djot.metadata(content) as "Failed to read post metadata" 33 + let assert Ok(title) = tom.get_string(meta, ["title"]) as "Missing post title" 34 + 35 + Post(slug, title, content) 36 + } 37 + 38 + pub fn list_posts(posts: List(Post)) -> List(Element(msg)) { 39 + [ 40 + component.header(1, "blog", [], [html.text("blog")]), 41 + html.ul([], list.map(posts, post_list_item)), 42 + ] 43 + } 44 + 45 + fn post_list_item(post: Post) -> Element(msg) { 46 + html.a([attribute.href("/blog/" <> post.slug)], [ 47 + html.text(post.slug), 48 + html.text(".djot"), 49 + ]) 50 + }
+31
src/component.gleam
··· 1 + import gleam/int 2 + import gleam/string 3 + import lustre/attribute.{type Attribute} 4 + import lustre/element.{type Element} 5 + import lustre/element/html 6 + 7 + pub fn header( 8 + level: Int, 9 + id: String, 10 + attrs: List(Attribute(msg)), 11 + content: List(Element(msg)), 12 + ) -> Element(msg) { 13 + let h = case level { 14 + 1 -> html.h1 15 + 2 -> html.h2 16 + 3 -> html.h3 17 + 4 -> html.h4 18 + 5 -> html.h5 19 + 6 -> html.h6 20 + _ -> html.p 21 + } 22 + 23 + let anchor = 24 + html.a([attribute.href("#" <> id), attribute.class("header-anchor")], [ 25 + html.text(string.repeat("#", int.min(6, level))), 26 + ]) 27 + 28 + html.div([attribute.class("heading")], [ 29 + h([attribute.id(id), ..attrs], [anchor, html.span([], content)]), 30 + ]) 31 + }
+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 + }
+244
src/page.gleam
··· 1 + import blog 2 + import component 3 + import gleam/dict 4 + import gleam/list 5 + import gleam/option.{None, Some} 6 + import gleam/string 7 + import jot 8 + import lustre/attribute.{attribute} 9 + import lustre/element.{type Element} 10 + import lustre/element/html 11 + import lustre/ssg/djot 12 + import meta.{type SiteMeta} 13 + import simplifile 14 + 15 + pub type Page { 16 + Index 17 + Blog 18 + Dots 19 + 20 + BlogPost(blog.Post) 21 + } 22 + 23 + pub fn route(page: Page) { 24 + case page { 25 + Index -> "/" 26 + Blog -> "/blog/" 27 + Dots -> "/dots" 28 + BlogPost(post) -> "/blog/" <> post.slug 29 + } 30 + } 31 + 32 + pub fn title(page: Page) { 33 + case page { 34 + Index -> "/index" 35 + Blog -> "/blog/" 36 + Dots -> "/.dots" 37 + BlogPost(post) -> post.title 38 + } 39 + } 40 + 41 + pub type SiteInfo { 42 + SiteInfo(posts: List(blog.Post), meta: SiteMeta) 43 + } 44 + 45 + pub fn render(page: Page, info: SiteInfo) -> Element(msg) { 46 + html.html([attribute.lang("en")], [ 47 + html.head([], [ 48 + html.meta([attribute.charset("utf-8")]), 49 + html.meta([ 50 + attribute.name("viewport"), 51 + attribute.content("width=device-width, initial-scale=1.0"), 52 + ]), 53 + html.title([], "fruno | " <> title(page)), 54 + preload_font("InclusiveSans"), 55 + preload_font("InclusiveSans-Italic"), 56 + preload_font("Myna"), 57 + html.link([ 58 + attribute.rel("stylesheet"), 59 + attribute.href("/style.css"), 60 + ]), 61 + ]), 62 + html.body([], [navbar(page, info.meta), html.main([], content(page, info))]), 63 + ]) 64 + } 65 + 66 + const fonts_dir = "/fonts/" 67 + 68 + fn preload_font(font: String) { 69 + html.link([ 70 + attribute.rel("preload"), 71 + attribute.href(fonts_dir <> font <> ".woff2"), 72 + attribute.as_("font"), 73 + attribute.type_("font/woff2"), 74 + attribute.crossorigin("anonymous"), 75 + ]) 76 + } 77 + 78 + const pages_dir = "pages/" 79 + 80 + fn content(page: Page, info: SiteInfo) -> List(Element(msg)) { 81 + let read_page = fn(file) { 82 + let assert Ok(content) = simplifile.read(pages_dir <> file) 83 + as "Failed to read djot file" 84 + content 85 + } 86 + 87 + case page { 88 + Index -> "index.djot" |> read_page |> djot.render(renderer()) 89 + Dots -> "dots.djot" |> read_page |> djot.render(renderer()) 90 + 91 + Blog -> blog.list_posts(info.posts) 92 + BlogPost(post) -> djot.render(post.content, renderer()) 93 + } 94 + } 95 + 96 + fn navbar(page: Page, meta: SiteMeta) -> Element(msg) { 97 + html.nav([attribute.id("navbar")], [ 98 + prompt_left(), 99 + nav(page), 100 + prompt_right(meta), 101 + ]) 102 + } 103 + 104 + fn prompt_left() -> Element(msg) { 105 + html.div([attribute.id("prompt-left")], [ 106 + html.div([attribute.id("prompt-user")], [ 107 + html.text("fruno"), 108 + ]), 109 + html.div([attribute.class("prompt-pointed-right")], [html.div([], [])]), 110 + html.div([attribute.id("prompt-dir")], [ 111 + html.text("~/webbed_site"), 112 + ]), 113 + html.div([attribute.class("prompt-pointed-right")], [html.div([], [])]), 114 + ]) 115 + } 116 + 117 + fn nav(page: Page) -> Element(msg) { 118 + html.div([attribute.id("nav")], [ 119 + html.span([attribute.id("nav-chevron")], [html.text(">")]), 120 + html.span([], [html.text("/")]), 121 + html.span([attribute.class("cursor")], []), 122 + html.ul([], [ 123 + nav_link(Index, page), 124 + nav_link(Blog, page), 125 + nav_link(Dots, page), 126 + ]), 127 + ]) 128 + } 129 + 130 + fn nav_link(to: Page, active: Page) { 131 + let is_active = case to, active { 132 + BlogPost(_), BlogPost(_) | BlogPost(_), Blog -> True 133 + _, _ if to == active -> True 134 + _, _ -> False 135 + } 136 + html.li([], [ 137 + html.a( 138 + [ 139 + attribute.href(route(to)), 140 + attribute.classes([#("active", is_active)]), 141 + ], 142 + [html.span([], [html.text(title(to))])], 143 + ), 144 + ]) 145 + } 146 + 147 + fn prompt_right(meta: SiteMeta) -> Element(msg) { 148 + html.div([attribute.id("prompt-right")], [ 149 + html.div([attribute.class("prompt-pointed-right")], [html.div([], [])]), 150 + html.div([attribute.id("prompt-commit")], [ 151 + html.a( 152 + [ 153 + attribute.href( 154 + "https://tangled.org/fruno.win/webbed-site/commit/" 155 + <> meta.commit_hash, 156 + ), 157 + ], 158 + [html.text(string.slice(meta.commit_hash, 0, 4))], 159 + ), 160 + ]), 161 + html.div([attribute.class("prompt-pointed-right")], [html.div([], [])]), 162 + html.div([attribute.id("prompt-gleam")], [ 163 + html.img([ 164 + attribute.class("icon"), 165 + attribute.alt("Lucy, the mascot of the gleam programming language"), 166 + attribute.src("/img/gleam.svg"), 167 + ]), 168 + html.text(meta.gleam_version), 169 + ]), 170 + html.div([attribute.class("prompt-pointed-right")], [html.div([], [])]), 171 + ]) 172 + } 173 + 174 + pub fn renderer() -> djot.Renderer(Element(msg)) { 175 + let to_attributes = fn(attrs) { 176 + use attrs, key, val <- dict.fold(attrs, []) 177 + [attribute(key, val), ..attrs] 178 + } 179 + 180 + djot.Renderer( 181 + codeblock: fn(attrs, lang, code) { 182 + let lang = option.unwrap(lang, "text") 183 + html.pre(to_attributes(attrs), [ 184 + html.code([attribute("data-lang", lang)], [html.text(code)]), 185 + ]) 186 + }, 187 + emphasis: fn(content) { html.em([], content) }, 188 + heading: fn(attrs, level, content) { 189 + let assert Ok(id) = attrs |> dict.get("id") as "Missing id in header" 190 + let attrs = to_attributes(attrs) 191 + component.header(level, id, attrs, content) 192 + }, 193 + link: fn(destination, attributes, content) { 194 + let attributes = to_attributes(attributes) 195 + 196 + case destination { 197 + None -> html.span(attributes, content) 198 + Some(url) -> html.a([attribute.href(url), ..attributes], content) 199 + } 200 + }, 201 + paragraph: fn(attrs, content) { html.p(to_attributes(attrs), content) }, 202 + bullet_list: fn(layout, _style, items) { 203 + html.ul([], { 204 + list.map(items, fn(item) { 205 + case layout { 206 + jot.Tight -> html.li([], item) 207 + jot.Loose -> html.li([], [html.p([], item)]) 208 + } 209 + }) 210 + }) 211 + }, 212 + raw_html: fn(content) { element.unsafe_raw_html("", "div", [], content) }, 213 + strong: fn(content) { html.strong([], content) }, 214 + text: fn(text) { html.text(text) }, 215 + code: fn(content) { html.code([], [html.text(content)]) }, 216 + image: fn(destination, attributes, alt) { 217 + let attributes = to_attributes(attributes) 218 + case destination { 219 + None -> html.span(attributes, [html.text(alt)]) 220 + Some(url) -> 221 + html.img([attribute.href(url), attribute.alt(alt), ..attributes]) 222 + } 223 + }, 224 + linebreak: html.br([]), 225 + thematicbreak: html.hr([]), 226 + inline_math: fn(math) { 227 + html.span([attribute.class("math inline")], [ 228 + html.text("\\(" <> math <> "\\)"), 229 + ]) 230 + }, 231 + display_math: fn(math) { 232 + html.span([attribute.class("math display")], [ 233 + html.text("\\[" <> math <> "\\]"), 234 + ]) 235 + }, 236 + blockquote: fn(attrs, content) { 237 + html.blockquote(to_attributes(attrs), content) 238 + }, 239 + span: fn(attrs, content) { 240 + html.span(to_attributes(attrs), [html.text(content)]) 241 + }, 242 + div: fn(attrs, content) { html.div(to_attributes(attrs), content) }, 243 + ) 244 + }
+34
src/webbed_site.gleam
··· 1 + import blog 2 + import gleam/dict 3 + import gleam/list 4 + import lustre/ssg 5 + import meta 6 + import page 7 + 8 + pub fn main() { 9 + let assert Ok(meta) = meta.fetch() as "Failed to fetch site meta" 10 + let posts = blog.posts() 11 + let info = page.SiteInfo(posts:, meta:) 12 + 13 + let site = 14 + ssg.new("./dist") 15 + |> ssg.add_static_route( 16 + page.route(page.Index), 17 + page.render(page.Index, info), 18 + ) 19 + // Special path here because wisp (the AT-Proto hosting platform) 20 + // likes them that way 21 + |> ssg.add_static_route("/blog/index", page.render(page.Blog, info)) 22 + |> ssg.add_static_route(page.route(page.Dots), page.render(page.Dots, info)) 23 + |> ssg.add_dynamic_route( 24 + "/blog/", 25 + posts 26 + |> list.map(fn(post) { #(post.slug, page.BlogPost(post)) }) 27 + |> dict.from_list(), 28 + page.render(_, info), 29 + ) 30 + |> ssg.add_static_dir("./assets") 31 + |> ssg.build 32 + 33 + let assert Ok(_) = site as "Build failed" 34 + }