💻 My personal website blog.kacaii.dev/
blog gleam lustre

:boom: no need for a http server

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