tangled
alpha
login
or
join now
kacaii.dev
/
blog
0
fork
atom
💻 My personal website
blog.kacaii.dev/
blog
gleam
lustre
0
fork
atom
overview
issues
pulls
pipelines
:boom: no need for a http server
kacaii.dev
2 months ago
08505f4b
d8e49c75
+106
-256
22 changed files
expand all
collapse all
unified
split
.dockerignore
.gitignore
Dockerfile
fly.toml
healthcheck.sh
justfile
priv
dist
style
blog.css
font
MapleMono-NF-Bold.ttf
MapleMono-NF-BoldItalic.ttf
MapleMono-NF-Italic.ttf
MapleMono-NF-Regular.ttf
font.css
src
blog
page
home.gleam
navbar.gleam
posts.gleam
recent_posts.gleam
root.gleam
blog.gleam
build.gleam
supervision_tree.gleam
web
http_router.gleam
web.gleam
-19
.dockerignore
···
1
1
-
*.beam
2
2
-
*.ez
3
3
-
*.just
4
4
-
/build
5
5
-
erl_crash.dump
6
6
-
7
7
-
/dev
8
8
-
/test
9
9
-
justfile
10
10
-
README.md
11
11
-
12
12
-
/.justfiles
13
13
-
.prettierignore
14
14
-
.envrc
15
15
-
.gitignore
16
16
-
17
17
-
/.git
18
18
-
/.jj
19
19
-
/.tangled
+1
-2
.gitignore
···
14
14
/.lustre
15
15
/dist
16
16
17
17
-
priv/static/*.html
18
18
-
priv/static/posts/*.html
17
17
+
priv/dist/*.html
-33
Dockerfile
···
1
1
-
ARG ERLANG_VERSION=28.0.2.0
2
2
-
ARG GLEAM_VERSION=v1.14.0
3
3
-
4
4
-
# Gleam stage
5
5
-
FROM ghcr.io/gleam-lang/gleam:${GLEAM_VERSION}-scratch AS gleam
6
6
-
7
7
-
# Build stage
8
8
-
FROM erlang:${ERLANG_VERSION}-alpine AS build
9
9
-
COPY --from=gleam /bin/gleam /bin/gleam
10
10
-
COPY . /blog/
11
11
-
RUN apk add --no-cache build-base
12
12
-
WORKDIR /blog
13
13
-
RUN gleam export erlang-shipment
14
14
-
15
15
-
# Final stage
16
16
-
FROM erlang:${ERLANG_VERSION}-alpine
17
17
-
18
18
-
RUN \
19
19
-
addgroup --system webapp && \
20
20
-
adduser --system webapp -g webapp
21
21
-
22
22
-
COPY --from=build /blog/build/erlang-shipment /blog
23
23
-
COPY healthcheck.sh /app/healthcheck.sh
24
24
-
25
25
-
RUN chmod +x /app/healthcheck.sh
26
26
-
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 CMD [ "/blog/healthcheck.sh" ]
27
27
-
28
28
-
WORKDIR /blog
29
29
-
30
30
-
EXPOSE 8080
31
31
-
32
32
-
ENTRYPOINT ["/blog/entrypoint.sh"]
33
33
-
CMD ["run"]
-30
fly.toml
···
1
1
-
# fly.toml app configuration file generated for kacaii-blog on 2025-12-31T11:44:59-03:00
2
2
-
#
3
3
-
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
4
4
-
#
5
5
-
6
6
-
app = 'kacaii-blog'
7
7
-
primary_region = 'gru'
8
8
-
9
9
-
[build]
10
10
-
dockerfile = 'Dockerfile'
11
11
-
12
12
-
[http_service]
13
13
-
internal_port = 8080
14
14
-
force_https = true
15
15
-
auto_stop_machines = 'stop'
16
16
-
auto_start_machines = true
17
17
-
min_machines_running = 0
18
18
-
processes = ['app']
19
19
-
20
20
-
[[http_service.checks]]
21
21
-
grace_period = "10s"
22
22
-
interval = "30s"
23
23
-
method = "GET"
24
24
-
timeout = "5s"
25
25
-
path = "/healthcheck"
26
26
-
27
27
-
[[vm]]
28
28
-
memory = '1gb'
29
29
-
cpus = 1
30
30
-
memory_mb = 1024
-3
healthcheck.sh
···
1
1
-
#!/bin/sh
2
2
-
3
3
-
exec wget --spider --quiet 'http://127.0.0.1:8000'
+1
-6
justfile
···
12
12
13
13
# Build static HTML files
14
14
build:
15
15
-
gleam run -m build
16
16
-
17
17
-
# Deploy app
18
18
-
[confirm(" Deploy app?")]
19
19
-
deploy:
20
20
-
fly deploy
15
15
+
gleam run
priv/static/style/blog.css
priv/dist/style/blog.css
priv/static/style/font.css
priv/dist/style/font.css
priv/static/style/font/MapleMono-NF-Bold.ttf
priv/dist/style/font/MapleMono-NF-Bold.ttf
priv/static/style/font/MapleMono-NF-BoldItalic.ttf
priv/dist/style/font/MapleMono-NF-BoldItalic.ttf
priv/static/style/font/MapleMono-NF-Italic.ttf
priv/dist/style/font/MapleMono-NF-Italic.ttf
priv/static/style/font/MapleMono-NF-Regular.ttf
priv/dist/style/font/MapleMono-NF-Regular.ttf
+87
-14
src/blog.gleam
···
1
1
+
import blog/page/footer
2
2
+
import blog/page/home
3
3
+
import blog/page/navbar
4
4
+
import blog/page/posts
1
5
import blog/post
6
6
+
import blog/root
2
7
import filepath as path
3
3
-
import gleam/erlang/process
4
8
import gleam/list
5
9
import gleam/result
10
10
+
import lustre/attribute.{class} as attr
11
11
+
import lustre/element/html
6
12
import simplifile
7
7
-
import supervision_tree
8
13
import web
9
9
-
import web/http_router
10
14
import wisp
11
15
12
12
-
pub fn main() -> Nil {
13
13
-
wisp.configure_logger()
16
16
+
fn with_context(next: fn(web.Context) -> Nil) -> Nil {
14
17
let assert Ok(priv) = priv_directory()
15
18
let assert Ok(posts) = read_posts(priv)
19
19
+
let ctx = web.Context(priv:, posts:)
20
20
+
21
21
+
next(ctx)
22
22
+
}
23
23
+
24
24
+
pub fn main() -> Nil {
25
25
+
use ctx <- with_context()
26
26
+
27
27
+
build_home(ctx)
28
28
+
build_post_contents(ctx.priv)
29
29
+
build_posts_page(ctx)
30
30
+
}
31
31
+
32
32
+
fn build_home(ctx: web.Context) -> Nil {
33
33
+
let out_file =
34
34
+
path.join(ctx.priv, "dist")
35
35
+
|> path.join("index.html")
36
36
+
37
37
+
let html = home.view(ctx)
38
38
+
let assert Ok(_) = simplifile.write(out_file, html)
39
39
+
40
40
+
Nil
41
41
+
}
42
42
+
43
43
+
fn build_posts_page(ctx: web.Context) -> Nil {
44
44
+
let out_file =
45
45
+
path.join(ctx.priv, "dist")
46
46
+
|> path.join("articles.html")
47
47
+
48
48
+
let html = posts.view(ctx)
49
49
+
let assert Ok(_) = simplifile.write(out_file, html)
50
50
+
51
51
+
Nil
52
52
+
}
53
53
+
54
54
+
fn build_post_contents(priv: String) -> Nil {
55
55
+
let posts_path = path.join(priv, "posts")
56
56
+
let out_path = path.join(priv, "dist")
16
57
17
17
-
let ctx = web.Context(priv:, posts:)
18
18
-
let handler = http_router.handle_request(_, ctx)
19
19
-
let assert Ok(_) = supervision_tree.start(handler)
58
58
+
let assert Ok(entries) = simplifile.read_directory(posts_path)
59
59
+
as "Read posts directory"
60
60
+
61
61
+
use file_name <- list.each(entries)
62
62
+
let file_path = path.join(posts_path, file_name)
63
63
+
let assert Ok(post) = post.from_string(path: file_path) as "Parse post"
64
64
+
65
65
+
let title = post.meta.title
66
66
+
67
67
+
let post_header =
68
68
+
html.header([class("hidden md:block")], [
69
69
+
html.h1([class("mb-1 text-4xl font-bold")], [html.text(title)]),
70
70
+
html.p([class("my-2")], [html.text(post.meta.description)]),
71
71
+
html.hr([class("border text-ctp-surface0")]),
72
72
+
])
20
73
21
21
-
process.sleep_forever()
74
74
+
let back_button_style =
75
75
+
class(
76
76
+
"mx-auto w-full text-right underline underline-offset-2 text-ctp-lavender",
77
77
+
)
78
78
+
79
79
+
let back_button =
80
80
+
html.a([attr.href("articles.html"), back_button_style], [
81
81
+
html.text("Back"),
82
82
+
])
83
83
+
84
84
+
let content_html =
85
85
+
root.view(title:, content: [
86
86
+
navbar.view(),
87
87
+
post_header,
88
88
+
post.view(post),
89
89
+
back_button,
90
90
+
footer.view(),
91
91
+
])
92
92
+
93
93
+
let out_file = path.join(out_path, post.meta.slug <> ".html")
94
94
+
let assert Ok(_) = simplifile.write(out_file, content_html)
22
95
}
23
96
24
24
-
pub fn read_posts(priv: String) -> Result(List(post.Post), post.PostError) {
97
97
+
pub fn priv_directory() -> Result(String, Nil) {
98
98
+
wisp.priv_directory("blog")
99
99
+
}
100
100
+
101
101
+
fn read_posts(priv: String) -> Result(List(post.Post), post.PostError) {
25
102
let posts_path = path.join(priv, "posts")
26
103
let assert Ok(entries) = simplifile.read_directory(posts_path)
27
104
as "Read posts directory"
···
37
114
list.sort(posts, post.compare)
38
115
|> list.reverse
39
116
}
40
40
-
41
41
-
pub fn priv_directory() -> Result(String, Nil) {
42
42
-
wisp.priv_directory("blog")
43
43
-
}
+1
-1
src/blog/page/home.gleam
···
12
12
let elements = [recent_posts.view(ctx.posts)]
13
13
14
14
let content = [
15
15
-
navbar.view([]),
15
15
+
navbar.view(),
16
16
html.main([class(main_style)], elements),
17
17
footer.view(),
18
18
]
+9
-5
src/blog/page/navbar.gleam
···
2
2
import lustre/attribute.{class} as attr
3
3
import lustre/element/html
4
4
5
5
-
pub fn view(attributes: List(attr.Attribute(_))) {
5
5
+
pub fn view() {
6
6
let style = class("grid grid-cols-3 place-items-center")
7
7
8
8
-
html.nav([style, ..attributes], [
9
9
-
route(icon: "nf-fa-home", href: "/index.html", label: "Home"),
10
10
-
route(icon: "nf-md-file_document_edit", href: "/posts", label: "Articles"),
11
11
-
route(icon: "nf-md-file_star", href: "/posts/uses.html", label: "Uses"),
8
8
+
html.nav([style, attr.style("view-transition-name", "navbar")], [
9
9
+
route(icon: "nf-fa-home", href: "index.html", label: "Home"),
10
10
+
route(
11
11
+
icon: "nf-md-file_document_edit",
12
12
+
href: "articles.html",
13
13
+
label: "Articles",
14
14
+
),
15
15
+
route(icon: "nf-md-file_star", href: "uses.html", label: "Uses"),
12
16
])
13
17
}
14
18
+4
-6
src/blog/page/posts.gleam
···
10
10
import lustre/element
11
11
import lustre/element/html
12
12
import web
13
13
-
import wisp
14
13
15
15
-
pub fn handle_request(ctx: web.Context) -> wisp.Response {
14
14
+
pub fn view(ctx: web.Context) -> String {
16
15
let ul_styles = class("grid grid-cols-1 gap-4 mx-auto w-full")
17
16
let posts = list.filter(ctx.posts, fn(post) { post.meta.slug != "uses" })
18
17
19
18
let content = [
20
20
-
navbar.view([]),
19
19
+
navbar.view(),
21
20
html.div([], [html.ul([ul_styles], list.map(posts, post_preview))]),
22
21
footer.view(),
23
22
]
24
23
25
24
// RENDER
26
25
root.view(content:, title: "Posts")
27
27
-
|> wisp.html_response(200)
28
26
}
29
27
30
30
-
pub fn post_preview(post: post.Post) -> element.Element(_) {
28
28
+
fn post_preview(post: post.Post) -> element.Element(_) {
31
29
let meta = post.meta
32
30
33
31
let str_date = {
···
44
42
class("flex-col p-4 mx-auto w-full rounded-lg shadow-sm bg-ctp-mantle")
45
43
46
44
html.li([li_styles], [
47
47
-
html.a([attr.href("/posts/" <> post.meta.slug <> ".html")], [
45
45
+
html.a([attr.href(post.meta.slug <> ".html")], [
48
46
html.h2([class("text-2xl font-bold text-pretty")], [
49
47
html.text(meta.title),
50
48
]),
+2
-2
src/blog/page/recent_posts.gleam
···
15
15
let articles_section = html.ul([class("grid grid-cols-1 gap-2")], previews)
16
16
let section_title = html.h3([class("text-lg")], [html.text("Recent posts")])
17
17
let more_style = class("max-w-min underline text-md underline-offset-2")
18
18
-
let more = html.a([attr.href("/posts"), more_style], [html.text("More")])
18
18
+
let more = html.a([attr.href("articles.html"), more_style], [html.text("More")])
19
19
20
20
html.section([class("grid grid-cols-1 gap-2")], [
21
21
section_title,
···
37
37
string.join([day, month, year], with: " ")
38
38
}
39
39
40
40
-
let href = attr.href("/posts/" <> post.meta.slug <> ".html")
40
40
+
let href = attr.href(post.meta.slug <> ".html")
41
41
let style = class("flex flex-col p-4 mx-auto w-full rounded-lg bg-ctp-mantle")
42
42
43
43
html.li([style], [
+1
-1
src/blog/root.gleam
···
14
14
html.meta([attr.charset("utf-8")]),
15
15
html.title([], title),
16
16
viewport_meta,
17
17
-
html.link([attr.rel("stylesheet"), attr.href("/style/blog.css")]),
17
17
+
html.link([attr.rel("stylesheet"), attr.href("style/blog.css")]),
18
18
])
19
19
20
20
let style =
-83
src/build.gleam
···
1
1
-
import blog
2
2
-
import blog/page/footer
3
3
-
import blog/page/home
4
4
-
import blog/page/navbar
5
5
-
import blog/post
6
6
-
import blog/root
7
7
-
import filepath as path
8
8
-
import gleam/list
9
9
-
import lustre/attribute.{class} as attr
10
10
-
import lustre/element/html
11
11
-
import simplifile
12
12
-
import web
13
13
-
14
14
-
fn with_context(next: fn(web.Context) -> Nil) -> Nil {
15
15
-
let assert Ok(priv) = blog.priv_directory()
16
16
-
let assert Ok(posts) = blog.read_posts(priv)
17
17
-
let ctx = web.Context(priv:, posts:)
18
18
-
19
19
-
next(ctx)
20
20
-
}
21
21
-
22
22
-
pub fn main() -> Nil {
23
23
-
use ctx <- with_context()
24
24
-
25
25
-
build_home(ctx)
26
26
-
build_posts(ctx.priv)
27
27
-
}
28
28
-
29
29
-
fn build_home(ctx: web.Context) -> Nil {
30
30
-
let out_file =
31
31
-
path.join(ctx.priv, "static")
32
32
-
|> path.join("index.html")
33
33
-
34
34
-
let html = home.view(ctx)
35
35
-
let assert Ok(_) = simplifile.write(out_file, html)
36
36
-
37
37
-
Nil
38
38
-
}
39
39
-
40
40
-
fn build_posts(priv: String) -> Nil {
41
41
-
let posts_path = path.join(priv, "posts")
42
42
-
let out_path =
43
43
-
path.join(priv, "static")
44
44
-
|> path.join("posts")
45
45
-
46
46
-
let assert Ok(entries) = simplifile.read_directory(posts_path)
47
47
-
as "Read posts directory"
48
48
-
49
49
-
use file_name <- list.each(entries)
50
50
-
let file_path = path.join(posts_path, file_name)
51
51
-
let assert Ok(post) = post.from_string(path: file_path) as "Parse post"
52
52
-
53
53
-
let title = post.meta.title
54
54
-
55
55
-
let post_header =
56
56
-
html.header([class("hidden md:block")], [
57
57
-
html.h1([class("mb-1 text-4xl font-bold")], [html.text(title)]),
58
58
-
html.p([class("my-2")], [html.text(post.meta.description)]),
59
59
-
html.hr([class("border text-ctp-surface0")]),
60
60
-
])
61
61
-
62
62
-
let back_button_style =
63
63
-
class(
64
64
-
"mx-auto w-full text-right underline underline-offset-2 text-ctp-lavender",
65
65
-
)
66
66
-
67
67
-
let back_button =
68
68
-
html.a([attr.href("/posts"), back_button_style], [
69
69
-
html.text("Back"),
70
70
-
])
71
71
-
72
72
-
let content_html =
73
73
-
root.view(title:, content: [
74
74
-
navbar.view([]),
75
75
-
post_header,
76
76
-
post.view(post),
77
77
-
back_button,
78
78
-
footer.view(),
79
79
-
])
80
80
-
81
81
-
let out_file = path.join(out_path, post.meta.slug <> ".html")
82
82
-
let assert Ok(_) = simplifile.write(out_file, content_html)
83
83
-
}
-20
src/supervision_tree.gleam
···
1
1
-
import gleam/otp/actor
2
2
-
import gleam/otp/static_supervisor as supervisor
3
3
-
import mist
4
4
-
import wisp
5
5
-
import wisp/wisp_mist
6
6
-
7
7
-
pub fn start(
8
8
-
handler: fn(wisp.Request) -> wisp.Response,
9
9
-
) -> Result(actor.Started(supervisor.Supervisor), actor.StartError) {
10
10
-
let mist_child =
11
11
-
wisp_mist.handler(handler, wisp.random_string(54))
12
12
-
|> mist.new
13
13
-
|> mist.bind("0.0.0.0")
14
14
-
|> mist.port(8080)
15
15
-
|> mist.supervised()
16
16
-
17
17
-
supervisor.new(supervisor.OneForOne)
18
18
-
|> supervisor.add(mist_child)
19
19
-
|> supervisor.start
20
20
-
}
-16
src/web.gleam
···
1
1
import blog/post
2
2
-
import wisp
3
2
4
3
pub type Context {
5
4
Context(priv: String, posts: List(post.Post))
6
5
}
7
7
-
8
8
-
pub fn middleware(
9
9
-
req: wisp.Request,
10
10
-
ctx: Context,
11
11
-
next: fn(wisp.Request) -> wisp.Response,
12
12
-
) -> wisp.Response {
13
13
-
let req = wisp.method_override(req)
14
14
-
use <- wisp.log_request(req)
15
15
-
use <- wisp.rescue_crashes()
16
16
-
use req <- wisp.handle_head(req)
17
17
-
use req <- wisp.csrf_known_header_protection(req)
18
18
-
use <- wisp.serve_static(req, under: "", from: ctx.priv <> "/static")
19
19
-
20
20
-
next(req)
21
21
-
}
-15
src/web/http_router.gleam
···
1
1
-
import blog/page/posts
2
2
-
import web
3
3
-
import wisp
4
4
-
5
5
-
pub fn handle_request(req: wisp.Request, ctx: web.Context) -> wisp.Response {
6
6
-
use req <- web.middleware(req, ctx)
7
7
-
8
8
-
case wisp.path_segments(req) {
9
9
-
[] -> wisp.redirect("/index.html")
10
10
-
["posts"] -> posts.handle_request(ctx)
11
11
-
12
12
-
["healthcheck"] -> wisp.ok()
13
13
-
_ -> wisp.not_found()
14
14
-
}
15
15
-
}