My personal website

Initial commit

+5
.gitignore
··· 1 + *.beam 2 + *.ez 3 + /build 4 + erl_crash.dump 5 + /dist
+43
.tangled/workflows/publish.yaml
··· 1 + when: 2 + - event: ['push'] 3 + branch: ['main'] 4 + - event: ['manual'] 5 + 6 + engine: 'nixery' 7 + 8 + clone: 9 + skip: false 10 + depth: 1 11 + submodules: false 12 + 13 + dependencies: 14 + nixpkgs: 15 + - coreutils 16 + - curl 17 + github:NixOS/nixpkgs/nixpkgs-unstable: 18 + - gleam 19 + - erlang 20 + 21 + environment: 22 + SITE_PATH: 'dist' 23 + SITE_NAME: 'webbed-site' 24 + WISP_HANDLE: 'fruno.win' 25 + 26 + steps: 27 + - name: build site 28 + command: | 29 + export PATH="$HOME/.nix-profile/bin:$PATH" 30 + 31 + gleam run -m build 32 + - name: deploy to wisp 33 + command: | 34 + # Download Wisp CLI 35 + curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 36 + chmod +x wisp-cli 37 + 38 + # Deploy to Wisp 39 + ./wisp-cli \ 40 + "$WISP_HANDLE" \ 41 + --path "$SITE_PATH" \ 42 + --site "$SITE_NAME" \ 43 + --password "$WISP_APP_PASSWORD"
+22
README.md
··· 1 + # webbed_site 2 + 3 + ```sh 4 + gleam run -m build 5 + gleam run -m serve 6 + ``` 7 + ```gleam 8 + import webbed_site 9 + 10 + pub fn main() -> Nil { 11 + // TODO: An example of the project in use 12 + } 13 + ``` 14 + 15 + Further documentation can be found at <https://hexdocs.pm/webbed_site>. 16 + 17 + ## Development 18 + 19 + ```sh 20 + gleam run # Run the project 21 + gleam test # Run the tests 22 + ```
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.

+266
assets/style.css
··· 1 + /* Variables */ 2 + 3 + :root { 4 + color-scheme: light dark; 5 + 6 + --color-fg: light-dark(#1f1f28, #dcd7ba); 7 + --color-bg: light-dark(#dcd7ba, #1f1f28); 8 + 9 + --color-fg-muted: light-dark(#363646, #bab28a); 10 + /* lighter in dark mode, darker in light mode */ 11 + --color-bg-variant: light-dark(#363646, #bab28a); 12 + 13 + --color-primary: light-dark(#957fb8, #938aa9); 14 + --color-secondary: light-dark(#6a9589, #7aa89f); 15 + --color-select: light-dark(#7fb4ca, #7e9cd8); 16 + --color-select-variant: light-dark(#7e9cd8, #7fb4ca); 17 + 18 + --font: "Inclusive Sans", sans-serif; 19 + --font-mono: "Myna", monospace; 20 + 21 + color: var(--color-fg); 22 + background: var(--color-bg); 23 + 24 + font-family: var(--font); 25 + font-size: calc(.333vw + 1em); 26 + } 27 + 28 + /* Fonts */ 29 + 30 + @font-face { 31 + font-family: "Inclusive Sans"; 32 + font-style: normal; 33 + src: url("/fonts/InclusiveSans.woff2") 34 + format("woff2-variations"); 35 + font-weight: 125 950; 36 + } 37 + 38 + @font-face { 39 + font-family: "Inclusive Sans"; 40 + font-style: italic; 41 + src: url("/fonts/InclusiveSans-Italic.woff2") 42 + format("woff2-variations"); 43 + font-weight: 125 950; 44 + } 45 + 46 + @font-face { 47 + font-family: "Myna"; 48 + font-style: normal; 49 + src: url("/fonts/Myna.woff2"); 50 + font-weight: 400; 51 + } 52 + 53 + /* Style */ 54 + 55 + ::selection { 56 + background: var(--color-select); 57 + color: var(--color-bg); 58 + } 59 + 60 + h1, h2, h3 { 61 + color: var(--color-primary); 62 + font-family: var(--font-mono); 63 + font-weight: inherit; 64 + 65 + a { 66 + text-decoration: none; 67 + color: inherit; 68 + 69 + &:hover { 70 + text-decoration: underline; 71 + } 72 + } 73 + 74 + &:hover:before { 75 + color: var(--color-fg); 76 + } 77 + } 78 + 79 + h1::before { 80 + content: "# "; 81 + } 82 + 83 + h2::before { 84 + content: "## "; 85 + } 86 + 87 + h3::before { 88 + content: "### "; 89 + } 90 + 91 + 92 + #navbar { 93 + view-transition-name: nav-active-navbar; 94 + position: fixed; 95 + font-family: var(--font-mono); 96 + bottom: 0.2rem; 97 + font-size: 1.4em; 98 + 99 + /* With a mouse, leave space for the little link hover thing 100 + * in the bottom left 101 + */ 102 + @media (pointer: fine) { 103 + bottom: 2ch; 104 + left: 2ch; 105 + } 106 + 107 + #prompt { 108 + display: flex; 109 + color: var(--color-bg); 110 + 111 + #prompt-left { 112 + background: var(--color-primary); 113 + border-radius: 999em 0 0 999em; 114 + &:before { 115 + content: "\00a0"; 116 + } 117 + } 118 + 119 + #prompt-user { 120 + display: flex; 121 + background: var(--color-primary); 122 + 123 + .prompt-triangle { 124 + background: linear-gradient( 125 + 90deg, 126 + var(--color-primary) 25%, 127 + var(--color-secondary) 0 100% 128 + ); 129 + div { 130 + background: var(--color-primary); 131 + } 132 + } 133 + } 134 + 135 + .prompt-triangle { 136 + width: 3ch; 137 + height: 100%; 138 + div { 139 + width: 2ch; 140 + height: 100%; 141 + clip-path: polygon(0 0, 50% 0, 100% 50%, 50% 100%, 0 100%); 142 + } 143 + } 144 + 145 + #prompt-dir { 146 + display: flex; 147 + background: var(--color-secondary); 148 + 149 + .prompt-triangle { 150 + background: linear-gradient( 151 + 90deg, 152 + var(--color-secondary) 25%, 153 + var(--color-bg) 0 100% 154 + ); 155 + div { 156 + background: var(--color-secondary); 157 + } 158 + } 159 + } 160 + } 161 + 162 + ul { 163 + view-transition-name: navbar; 164 + list-style-type: none; 165 + margin: 0.5ch 0 0.5ch 0; 166 + gap: 1ch; 167 + padding: 0; 168 + display: flex; 169 + /* Firefox mobile puts the nav links a couple pixels higher otherwise */ 170 + align-items: end; 171 + } 172 + 173 + a { 174 + padding: 0 0.5ch 0 0.5ch; 175 + border-radius: 0.2em; 176 + color: var(--color-fg); 177 + z-index: 100; 178 + 179 + &:visited { 180 + color: inherit; 181 + } 182 + 183 + /* 184 + * Pseudo background element we can animate during page transitions 185 + */ 186 + &.active::before { 187 + view-transition-name: nav-active-bg; 188 + border-radius: 0.2em; 189 + content: ""; 190 + position: absolute; 191 + top: 0; 192 + left: 0; 193 + height: 100%; 194 + width: 100%; 195 + z-index: -100; 196 + background-color: var(--color-fg); 197 + } 198 + 199 + &.active { 200 + position: relative; 201 + color: var(--color-bg); 202 + background-color: var(--color-fg); 203 + } 204 + 205 + &:hover { 206 + color: var(--color-bg); 207 + background: var(--color-fg); 208 + } 209 + } 210 + 211 + &:hover #nav-cursor { 212 + animation: blink 1s steps(1, start) infinite; 213 + } 214 + } 215 + 216 + article a { 217 + text-decoration: none; 218 + color: var(--color-select); 219 + 220 + &:hover { 221 + text-decoration: underline; 222 + color: var(--color-select-variant); 223 + } 224 + } 225 + 226 + @view-transition { 227 + navigation: auto; 228 + } 229 + 230 + @keyframes blink { 231 + 50% { visibility: hidden } 232 + } 233 + 234 + @keyframes slide-out-up { 235 + from { transform: translateY(0) } 236 + to { transform: translateY(-100vh) } 237 + } 238 + 239 + @keyframes slide-in-up { 240 + from { transform: translateY(100vh) } 241 + to { transform: translateY(0) } 242 + } 243 + 244 + main > * { 245 + max-width: 35rem; 246 + margin-left: auto; 247 + margin-right: auto; 248 + } 249 + 250 + main { 251 + view-transition-name: main-content; 252 + } 253 + 254 + ::view-transition-old(main-content) { 255 + animation: 250ms ease-in both slide-out-up; 256 + } 257 + 258 + ::view-transition-new(main-content) { 259 + animation: 250ms ease-in both slide-in-up; 260 + } 261 + 262 + 263 + ::view-transition-group(navbar) { 264 + z-index: 100; 265 + } 266 +
+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 + }
+27
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 + 23 + [dev-dependencies] 24 + gleeunit = ">= 1.0.0 and < 2.0.0" 25 + mist = ">= 5.0.3 and < 6.0.0" 26 + gleam_http = ">= 4.3.0 and < 5.0.0" 27 + gleam_erlang = ">= 1.3.0 and < 2.0.0"
+44
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_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, 11 + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, 12 + { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, 13 + { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, 14 + { name = "gleam_stdlib", version = "0.67.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "6CE3E4189A8B8EC2F73AB61A2FBDE49F159D6C9C61C49E3B3082E439F260D3D0" }, 15 + { name = "gleam_time", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "0DF3834D20193F0A38D0EB21F0A78D48F2EC276C285969131B86DF8D4EF9E762" }, 16 + { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, 17 + { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, 18 + { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, 19 + { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, 20 + { name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" }, 21 + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, 22 + { name = "jot", version = "8.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "houdini", "splitter"], otp_app = "jot", source = "hex", outer_checksum = "CCE11C8904B129CC9DA3A293B645884B91C96D252183F6DBCAEFA8F2587CAEFD" }, 23 + { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, 24 + { 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" }, 25 + { 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" }, 26 + { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, 27 + { name = "simplifile", version = "2.3.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "E049B4DACD4D206D87843BCF4C775A50AE0F50A52031A2FFB40C9ED07D6EC70A" }, 28 + { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, 29 + { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, 30 + { 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" }, 31 + { name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" }, 32 + ] 33 + 34 + [requirements] 35 + gleam_erlang = { version = ">= 1.3.0 and < 2.0.0" } 36 + gleam_http = { version = ">= 4.3.0 and < 5.0.0" } 37 + gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 38 + gleam_time = { version = ">= 1.6.0 and < 2.0.0" } 39 + gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 40 + lustre = { version = ">= 5.4.0 and < 6.0.0" } 41 + lustre_ssg = { git = "https://github.com/fruno-bulax/lustre_ssg", ref = "6c132bd34ab75a1144d31c0f896cab0e3cbf80fc" } 42 + mist = { version = ">= 5.0.3 and < 6.0.0" } 43 + simplifile = { version = ">= 2.3.2 and < 3.0.0" } 44 + 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!
+27
src/build.gleam
··· 1 + import gleam/dict 2 + import gleam/list 3 + import lustre/ssg 4 + import page/about 5 + import page/index 6 + import page/posts 7 + 8 + pub fn main() { 9 + let posts = posts.all() 10 + 11 + let build = 12 + ssg.new("./dist") 13 + |> ssg.add_static_route("/", index.view()) 14 + |> ssg.add_static_route("/about", about.view()) 15 + |> ssg.add_static_route("/blog", posts.view_all(posts)) 16 + |> ssg.add_dynamic_route( 17 + "/blog", 18 + posts 19 + |> list.map(fn(post) { #(post.slug, post) }) 20 + |> dict.from_list(), 21 + posts.view, 22 + ) 23 + |> ssg.add_static_dir("./assets") 24 + |> ssg.build 25 + 26 + let assert Ok(_) = build as "Build failed" 27 + }
+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 + }
+92
src/page.gleam
··· 1 + import lustre/attribute 2 + import lustre/element.{type Element} 3 + import lustre/element/html 4 + 5 + pub type Nav { 6 + Index 7 + Blog 8 + About 9 + } 10 + 11 + pub fn render( 12 + active_nav: Nav, 13 + title: String, 14 + content: List(Element(msg)), 15 + ) -> Element(msg) { 16 + html.html([attribute.lang("en")], [ 17 + html.head([], [ 18 + html.meta([attribute.charset("utf-8")]), 19 + html.meta([ 20 + attribute.name("viewport"), 21 + attribute.content("width-device-width, initial-scale=1.0"), 22 + ]), 23 + html.title([], title), 24 + html.link([ 25 + attribute.rel("stylesheet"), 26 + attribute.href("/style.css"), 27 + ]), 28 + ]), 29 + // TODO nav and such 30 + html.body([], [navbar(active_nav), html.main([], content)]), 31 + ]) 32 + } 33 + 34 + fn navbar(active: Nav) -> Element(msg) { 35 + html.nav([attribute.id("navbar")], [ 36 + prompt(active), 37 + html.ul([], [ 38 + html.span([], [html.text("❯")]), 39 + html.span([attribute.id("nav-cursor")], [html.text("█")]), 40 + html.li([], [ 41 + html.a( 42 + [ 43 + attribute.href("/"), 44 + attribute.id("home"), 45 + attribute.classes([#("active", active == Index)]), 46 + ], 47 + [html.text("/home")], 48 + ), 49 + ]), 50 + html.li([], [ 51 + html.a( 52 + [ 53 + attribute.href("/blog"), 54 + attribute.id("blog"), 55 + attribute.classes([#("active", active == Blog)]), 56 + ], 57 + [html.text("/blog/")], 58 + ), 59 + ]), 60 + html.li([], [ 61 + html.a( 62 + [ 63 + attribute.href("/about"), 64 + attribute.id("about"), 65 + attribute.classes([#("active", active == About)]), 66 + ], 67 + [html.text("/about")], 68 + ), 69 + ]), 70 + ]), 71 + ]) 72 + } 73 + 74 + fn prompt(active: Nav) -> Element(msg) { 75 + let dir = case active { 76 + Index -> "/home" 77 + Blog -> "/blog" 78 + About -> "/about" 79 + } 80 + 81 + html.div([attribute.id("prompt")], [ 82 + html.div([attribute.id("prompt-left")], []), 83 + html.div([attribute.id("prompt-user")], [ 84 + html.text("fruno"), 85 + html.div([attribute.class("prompt-triangle")], [html.div([], [])]), 86 + ]), 87 + html.div([attribute.id("prompt-dir")], [ 88 + html.text(dir), 89 + html.div([attribute.class("prompt-triangle")], [html.div([], [])]), 90 + ]), 91 + ]) 92 + }
+10
src/page/about.gleam
··· 1 + import lustre/element.{type Element} 2 + import lustre/element/html 3 + import page 4 + import component 5 + 6 + pub fn view() -> Element(Nil) { 7 + page.render(page.About, "about", [ 8 + component.header("about", html.h1) 9 + ]) 10 + }
+21
src/page/index.gleam
··· 1 + import component 2 + import lustre/element.{type Element} 3 + import lustre/element/html 4 + import page 5 + 6 + pub fn view() -> Element(Nil) { 7 + page.render(page.Index, "index", [ 8 + component.header("wip", html.h1), 9 + html.p([], [ 10 + html.text( 11 + "This site is very work-in-progress but it kind of looks like a terminal and that's pretty neat.", 12 + ), 13 + ]), 14 + component.header("Nested headers work too!", html.h2), 15 + html.p([], [ 16 + html.text( 17 + "This is a purely static site without javascript but with mutli-page view transitions instead. Firefox can't do them yet. boooo!", 18 + ), 19 + ]), 20 + ]) 21 + }
+59
src/page/posts.gleam
··· 1 + import gleam/list 2 + import gleam/string 3 + import gleam/time/calendar.{type Date} 4 + import lustre/attribute 5 + import lustre/element.{type Element} 6 + import lustre/element/html 7 + import lustre/ssg/djot 8 + import page 9 + import simplifile 10 + import tom 11 + 12 + pub type Post { 13 + Post(title: String, date: Date, slug: String, content: List(Element(Nil))) 14 + } 15 + 16 + const posts_dir = "./posts" 17 + 18 + pub fn all() -> List(Post) { 19 + let assert Ok(files) = simplifile.read_directory(posts_dir) 20 + as "Failed to read posts directory" 21 + 22 + files 23 + |> list.map(read_post) 24 + |> list.sort(fn(a, b) { calendar.naive_date_compare(a.date, b.date) }) 25 + } 26 + 27 + pub fn view_all(posts: List(Post)) -> Element(Nil) { 28 + page.render(page.Blog, "blog", [ 29 + html.article([], [ 30 + html.header([], [html.h1([], [html.text("blog")])]), 31 + html.ul([], list.map(posts, post_list_item)), 32 + ]), 33 + ]) 34 + } 35 + 36 + fn post_list_item(post: Post) -> Element(Nil) { 37 + html.a([attribute.href("/blog/" <> post.slug)], [html.text(post.title)]) 38 + } 39 + 40 + pub fn view(post: Post) -> Element(Nil) { 41 + page.render(page.Blog, post.title, post.content) 42 + } 43 + 44 + fn read_post(filename: String) -> Post { 45 + let assert [slug, "djot"] = string.split(filename, ".") 46 + as "Unexpected post file type" 47 + let assert Ok(content) = simplifile.read(posts_dir <> "/" <> filename) 48 + as "Failed to read file" 49 + 50 + let assert Ok(meta) = djot.metadata(content) as "Failed to read post metadata" 51 + let assert Ok(title) = tom.get_string(meta, ["title"]) as "Missing post title" 52 + let assert Ok(date) = tom.get_date(meta, ["date"]) 53 + as "Missing or malformed post date" 54 + 55 + // TODO scaffolding like nav and such 56 + let content = djot.render(content, djot.default_renderer()) 57 + 58 + Post(title:, date:, slug:, content:) 59 + }
+13
test/webbed_site_test.gleam
··· 1 + import gleeunit 2 + 3 + pub fn main() -> Nil { 4 + gleeunit.main() 5 + } 6 + 7 + // gleeunit test functions end in `_test` 8 + pub fn hello_world_test() { 9 + let name = "Joe" 10 + let greeting = "Hello, " <> name <> "!" 11 + 12 + assert greeting == "Hello, Joe!" 13 + }