+44
.tangled/workflows/publish.yaml
+44
.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
+
21
+
22
+
environment:
23
+
SITE_PATH: 'dist'
24
+
SITE_NAME: 'webbed-site'
25
+
WISP_HANDLE: 'fruno.win'
26
+
27
+
steps:
28
+
- name: build site
29
+
command: |
30
+
export PATH="$HOME/.nix-profile/bin:$PATH"
31
+
32
+
gleam run
33
+
- name: deploy to wisp
34
+
command: |
35
+
# Download Wisp CLI
36
+
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
37
+
chmod +x wisp-cli
38
+
39
+
# Deploy to Wisp
40
+
./wisp-cli \
41
+
"$WISP_HANDLE" \
42
+
--path "$SITE_PATH" \
43
+
--site "$SITE_NAME" \
44
+
--password "$WISP_APP_PASSWORD"
+22
README.md
+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
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.
+266
assets/style.css
+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
+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
+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
+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
+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
+
}
+92
src/page.gleam
+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
+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