+38
.tangled/workflows/publish.yaml
+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
+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>
+332
assets/style.css
+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
+
+22
gleam.toml
+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
+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
+8
posts/2026-01-03-initial-commit.djot
+30
serve💅.py
+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
+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
+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
+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
+11
src/page/about.gleam
+60
src/page/blog.gleam
+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
+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
+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
+
}