+47
.tangled/workflows/publish.yaml
+47
.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
+
- beamMinimal28Packages.erlang
20
+
# Needed for dev-dependencies :/
21
+
- beamMinimal28Packages.rebar3
22
+
- git
23
+
24
+
25
+
environment:
26
+
SITE_PATH: 'dist'
27
+
SITE_NAME: 'webbed-site'
28
+
WISP_HANDLE: 'fruno.win'
29
+
30
+
steps:
31
+
- name: build site
32
+
command: |
33
+
export PATH="$HOME/.nix-profile/bin:$PATH"
34
+
35
+
gleam run
36
+
- name: deploy to wisp
37
+
command: |
38
+
# Download Wisp CLI
39
+
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
40
+
chmod +x wisp-cli
41
+
42
+
# Deploy to Wisp
43
+
./wisp-cli \
44
+
"$WISP_HANDLE" \
45
+
--path "$SITE_PATH" \
46
+
--site "$SITE_NAME" \
47
+
--password "$WISP_APP_PASSWORD"
+11
README.md
+11
README.md
assets/fonts/InclusiveSans-Italic.woff2
assets/fonts/InclusiveSans-Italic.woff2
This is a binary file and will not be displayed.
assets/fonts/InclusiveSans.woff2
assets/fonts/InclusiveSans.woff2
This is a binary file and will not be displayed.
assets/fonts/Myna.woff2
assets/fonts/Myna.woff2
This is a binary file and will not be displayed.
+1
assets/img/gleam.svg
+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>
+333
assets/style.css
+333
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
+
view-transition-name: nav;
210
+
flex-grow: 100;
211
+
list-style-type: none;
212
+
margin: 0;
213
+
padding: 0;
214
+
display: flex;
215
+
gap: 1ch;
216
+
217
+
a {
218
+
padding: 0 1ch;
219
+
border-radius: 0.25em;
220
+
color: var(--color-fg);
221
+
z-index: 100;
222
+
223
+
&:visited {
224
+
color: inherit;
225
+
}
226
+
227
+
/*
228
+
* Pseudo background element we can animate during page transitions
229
+
*/
230
+
&.active::before {
231
+
view-transition-name: nav-active-bg;
232
+
border-radius: 0.25em;
233
+
content: "";
234
+
position: absolute;
235
+
top: 0;
236
+
left: 0;
237
+
height: 100%;
238
+
width: 100%;
239
+
z-index: -100;
240
+
background: var(--color-fg);
241
+
}
242
+
243
+
&.active {
244
+
position: relative;
245
+
color: var(--color-bg);
246
+
background: var(--color-fg);
247
+
}
248
+
249
+
&:hover {
250
+
color: var(--color-bg);
251
+
background: var(--color-fg);
252
+
}
253
+
}
254
+
}
255
+
256
+
#nav-cursor {
257
+
margin-right: 1ch;
258
+
}
259
+
260
+
&:hover #nav-cursor {
261
+
animation: blink 1.5s steps(1, start) infinite;
262
+
}
263
+
264
+
#nav-chevron {
265
+
display: none;
266
+
width: 1ch;
267
+
margin-right: 1ch;
268
+
}
269
+
270
+
/* I wanted to avoid breakpoints, but I can't figure out a way around this one */
271
+
@media (max-width: 768px) {
272
+
#nav-chevron { display: inherit }
273
+
#prompt-right {
274
+
position: relative;
275
+
left: -2ch;
276
+
}
277
+
#nav {
278
+
order: 3;
279
+
width: 100vw;
280
+
}
281
+
282
+
#prompt-right .prompt-pointed-right:first-child {
283
+
background: linear-gradient(90deg, var(--color-secondary) 25%, var(--jj-bg) 0 100%);
284
+
div {
285
+
background: var(--color-secondary);
286
+
}
287
+
}
288
+
}
289
+
}
290
+
291
+
a {
292
+
color: var(--color-select);
293
+
text-decoration: none;
294
+
295
+
&:hover {
296
+
text-decoration: underline;
297
+
color: var(--color-select-variant);
298
+
}
299
+
}
300
+
301
+
@view-transition {
302
+
navigation: auto;
303
+
}
304
+
305
+
@keyframes blink {
306
+
50% { visibility: hidden }
307
+
}
308
+
309
+
@keyframes slide-out-up {
310
+
from { transform: translateY(0) }
311
+
to { transform: translateY(-100vh) }
312
+
}
313
+
314
+
@keyframes slide-in-up {
315
+
from { transform: translateY(100vh) }
316
+
to { transform: translateY(0) }
317
+
}
318
+
319
+
main {
320
+
view-transition-name: main-content;
321
+
}
322
+
323
+
::view-transition-old(main-content) {
324
+
animation: 250ms ease-in both slide-out-up;
325
+
}
326
+
327
+
::view-transition-new(main-content) {
328
+
animation: 250ms ease-in both slide-in-up;
329
+
}
330
+
331
+
::view-transition-group(navbar) {
332
+
z-index: 100;
333
+
}
+66
dev/serve.gleam
+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
+
}
+28
gleam.toml
+28
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"
23
+
24
+
[dev-dependencies]
25
+
gleeunit = ">= 1.0.0 and < 2.0.0"
26
+
mist = ">= 5.0.3 and < 6.0.0"
27
+
gleam_http = ">= 4.3.0 and < 5.0.0"
28
+
gleam_erlang = ">= 1.3.0 and < 2.0.0"
+46
manifest.toml
+46
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 = "shellout", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "1BDC03438FEB97A6AF3E396F4ABEB32BECF20DF2452EC9A8C0ACEB7BDDF70B14" },
28
+
{ name = "simplifile", version = "2.3.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "E049B4DACD4D206D87843BCF4C775A50AE0F50A52031A2FFB40C9ED07D6EC70A" },
29
+
{ name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" },
30
+
{ name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" },
31
+
{ 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" },
32
+
{ name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" },
33
+
]
34
+
35
+
[requirements]
36
+
gleam_erlang = { version = ">= 1.3.0 and < 2.0.0" }
37
+
gleam_http = { version = ">= 4.3.0 and < 5.0.0" }
38
+
gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" }
39
+
gleam_time = { version = ">= 1.6.0 and < 2.0.0" }
40
+
gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
41
+
lustre = { version = ">= 5.4.0 and < 6.0.0" }
42
+
lustre_ssg = { git = "https://github.com/fruno-bulax/lustre_ssg", ref = "6c132bd34ab75a1144d31c0f896cab0e3cbf80fc" }
43
+
mist = { version = ">= 5.0.3 and < 6.0.0" }
44
+
shellout = { version = ">= 1.7.0 and < 2.0.0" }
45
+
simplifile = { version = ">= 2.3.2 and < 3.0.0" }
46
+
tom = { version = ">= 2.0.0 and < 3.0.0" }
+8
posts/2026-01-03-initial-commit.djot
+8
posts/2026-01-03-initial-commit.djot
+18
src/component.gleam
+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
+
}
+135
src/page.gleam
+135
src/page.gleam
···
1
+
import gleam/string
2
+
import lustre/attribute
3
+
import lustre/element.{type Element}
4
+
import lustre/element/html
5
+
import shellout
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
+
content: List(Element(msg)),
17
+
) -> Element(msg) {
18
+
html.html([attribute.lang("en")], [
19
+
html.head([], [
20
+
html.meta([attribute.charset("utf-8")]),
21
+
html.meta([
22
+
attribute.name("viewport"),
23
+
attribute.content("width=device-width, initial-scale=1.0"),
24
+
]),
25
+
html.title([], title),
26
+
html.link([
27
+
attribute.rel("stylesheet"),
28
+
attribute.href("/style.css"),
29
+
]),
30
+
]),
31
+
html.body([], [navbar(active_nav), html.main([], content)]),
32
+
])
33
+
}
34
+
35
+
fn navbar(active: Nav) -> Element(msg) {
36
+
html.nav([attribute.id("navbar")], [
37
+
prompt_left(),
38
+
nav(active),
39
+
prompt_right(),
40
+
])
41
+
}
42
+
43
+
fn prompt_left() -> Element(msg) {
44
+
html.div([attribute.id("prompt-left")], [
45
+
html.div([attribute.id("prompt-user")], [
46
+
html.text("fruno"),
47
+
]),
48
+
html.div([attribute.class("prompt-pointed-right")], [html.div([], [])]),
49
+
html.div([attribute.id("prompt-dir")], [
50
+
html.text("~/webbed_site"),
51
+
]),
52
+
html.div([attribute.class("prompt-pointed-right")], [html.div([], [])]),
53
+
])
54
+
}
55
+
56
+
fn nav(active: Nav) -> Element(msg) {
57
+
html.ul([attribute.id("nav")], [
58
+
html.span([attribute.id("nav-chevron")], [html.text(">")]),
59
+
html.span([attribute.id("nav-cursor")], [html.text("█")]),
60
+
html.li([], [
61
+
html.a(
62
+
[
63
+
attribute.href("/"),
64
+
attribute.id("home"),
65
+
attribute.classes([#("active", active == Index)]),
66
+
],
67
+
[html.text("/home")],
68
+
),
69
+
]),
70
+
html.li([], [
71
+
html.a(
72
+
[
73
+
attribute.href("/blog"),
74
+
attribute.id("blog"),
75
+
attribute.classes([#("active", active == Blog)]),
76
+
],
77
+
[html.text("/blog/")],
78
+
),
79
+
]),
80
+
html.li([], [
81
+
html.a(
82
+
[
83
+
attribute.href("/about"),
84
+
attribute.id("about"),
85
+
attribute.classes([#("active", active == About)]),
86
+
],
87
+
[html.text("/about")],
88
+
),
89
+
]),
90
+
])
91
+
}
92
+
93
+
fn prompt_right() -> Element(msg) {
94
+
let commit = commit_hash()
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/" <> commit,
102
+
),
103
+
],
104
+
[html.text(string.slice(commit, 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(gleam_version()),
115
+
]),
116
+
html.div([attribute.class("prompt-pointed-right")], [html.div([], [])]),
117
+
])
118
+
}
119
+
120
+
// TODO: don't fetch these once per page!
121
+
fn commit_hash() -> String {
122
+
let assert Ok(hash) =
123
+
shellout.command(run: "git", with: ["rev-parse", "HEAD"], in: ".", opt: [])
124
+
as "Failed to fetch commit hash"
125
+
hash
126
+
}
127
+
128
+
fn gleam_version() -> String {
129
+
let result = shellout.command("gleam", ["--version"], ".", [])
130
+
case result {
131
+
Ok("gleam " <> version) -> version
132
+
Ok(_) -> panic as "gleam --version returned unexpected result"
133
+
Error(_) -> panic as "Failed to get gleam version"
134
+
}
135
+
}
+10
src/page/about.gleam
+10
src/page/about.gleam
+21
src/page/index.gleam
+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
+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
+
}
+27
src/webbed_site.gleam
+27
src/webbed_site.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
+
}
+13
test/webbed_site_test.gleam
+13
test/webbed_site_test.gleam