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
:zap: build static files before deploying
kacaii.dev
1 month ago
4b932968
d76c95a5
+106
-82
12 changed files
expand all
collapse all
unified
split
.gitignore
.tangled
workflows
fly.yml
Dockerfile
justfile
src
blog
page
content.gleam
home.gleam
navbar.gleam
posts.gleam
recent_posts.gleam
root.gleam
build.gleam
web
http_router.gleam
+2
.gitignore
···
13
# Added automatically by Lustre Dev Tools
14
/.lustre
15
/dist
0
0
···
13
# Added automatically by Lustre Dev Tools
14
/.lustre
15
/dist
16
+
17
+
priv/static/posts/
+11
-1
.tangled/workflows/fly.yml
···
9
- flyctl
10
- tailwindcss_4
11
0
0
0
0
0
12
steps:
13
-
- name: Generate CSS
14
command: |
15
tailwindcss -i input.css -o priv/static/output.css
0
0
0
0
0
16
17
- name: Deploy app
18
command: |
···
9
- flyctl
10
- tailwindcss_4
11
12
+
github:NixOS/nixpkgs/nixpkgs-unstable:
13
+
- gleam
14
+
- beamMinimal28Packages.erlang
15
+
- beamMinimal28Packages.rebar3
16
+
17
steps:
18
+
- name: tailwind
19
command: |
20
tailwindcss -i input.css -o priv/static/output.css
21
+
22
+
- name: build
23
+
command: |
24
+
export PATH="$HOME/.nix-profile/bin:$PATH"
25
+
gleam dev -- build
26
27
- name: Deploy app
28
command: |
+1
-1
Dockerfile
···
1
ARG ERLANG_VERSION=28.0.2.0
2
-
ARG GLEAM_VERSION=v1.13.0
3
4
# Gleam stage
5
FROM ghcr.io/gleam-lang/gleam:${GLEAM_VERSION}-scratch AS gleam
···
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
+4
justfile
···
10
update-post-titles:
11
gleam dev -- update_titles
12
0
0
0
0
13
# Deploy app
14
[confirm(" Deploy app?")]
15
deploy:
···
10
update-post-titles:
11
gleam dev -- update_titles
12
13
+
# Build static HTML files
14
+
build:
15
+
gleam run -m build
16
+
17
# Deploy app
18
[confirm(" Deploy app?")]
19
deploy:
-72
src/blog/page/content.gleam
···
1
-
import blog/page/footer
2
-
import blog/page/navbar
3
-
import blog/post
4
-
import blog/root
5
-
import contour
6
-
import gleam/list
7
-
import gleam/option
8
-
import jot
9
-
import lustre/attribute.{class} as attr
10
-
import lustre/element
11
-
import lustre/element/html
12
-
import web
13
-
import wisp
14
-
15
-
pub fn handle_request(ctx: web.Context, post_uri: String) -> wisp.Response {
16
-
case list.find(ctx.posts, fn(post) { post.meta.slug == post_uri }) {
17
-
Error(_) -> wisp.not_found()
18
-
Ok(post) -> {
19
-
let title = post.meta.title
20
-
21
-
let post_header =
22
-
html.header([class("hidden md:block")], [
23
-
html.h1([class("mb-1 text-4xl font-bold")], [html.text(title)]),
24
-
html.p([class("my-2")], [html.text(post.meta.description)]),
25
-
html.hr([class("border text-ctp-surface0")]),
26
-
])
27
-
28
-
let back_button_style =
29
-
class(
30
-
"mx-auto w-full text-right underline underline-offset-2 text-ctp-lavender",
31
-
)
32
-
33
-
let back_button =
34
-
html.a([attr.href("/posts"), back_button_style], [
35
-
html.text("Back"),
36
-
])
37
-
38
-
root.view(title:, content: [
39
-
navbar.view([]),
40
-
post_header,
41
-
view(post),
42
-
back_button,
43
-
footer.view(),
44
-
])
45
-
}
46
-
}
47
-
}
48
-
49
-
pub fn view(post: post.Post) -> element.Element(a) {
50
-
let content =
51
-
post.body.content
52
-
|> list.map(highlight_codeblock)
53
-
54
-
let post_body =
55
-
jot.Document(..post.body, content:)
56
-
|> jot.document_to_html
57
-
58
-
let style = class("grid grid-cols-1 gap-4 w-full text-pretty post-content")
59
-
60
-
element.fragment([element.unsafe_raw_html("", "article", [style], post_body)])
61
-
}
62
-
63
-
fn highlight_codeblock(container: jot.Container) -> jot.Container {
64
-
case container {
65
-
jot.Codeblock(_, language: option.Some("gleam"), content:) -> {
66
-
let code = "<pre><code>" <> contour.to_html(content) <> "</code></pre>"
67
-
jot.RawBlock(code)
68
-
}
69
-
70
-
other -> other
71
-
}
72
-
}
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
+1
src/blog/page/home.gleam
···
21
]
22
23
root.view(content:, title: "Home")
0
24
}
···
21
]
22
23
root.view(content:, title: "Home")
24
+
|> wisp.html_response(200)
25
}
+1
-1
src/blog/page/navbar.gleam
···
8
html.nav([style, ..attributes], [
9
route(icon: "nf-fa-home", href: "/", label: "Home"),
10
route(icon: "nf-md-file_document_edit", href: "/posts", label: "Articles"),
11
-
route(icon: "nf-md-file_star", href: "/uses", label: "Uses"),
12
])
13
}
14
···
8
html.nav([style, ..attributes], [
9
route(icon: "nf-fa-home", href: "/", 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"),
12
])
13
}
14
+2
-1
src/blog/page/posts.gleam
···
24
25
// RENDER
26
root.view(content:, title: "Posts")
0
27
}
28
29
pub fn post_preview(post: post.Post) -> element.Element(_) {
···
43
class("flex-col p-4 mx-auto w-full rounded-lg shadow-sm bg-ctp-mantle")
44
45
html.li([li_styles], [
46
-
html.a([attr.href("/posts/" <> post.meta.slug)], [
47
html.h2([class("text-2xl font-bold text-pretty")], [
48
html.text(meta.title),
49
]),
···
24
25
// RENDER
26
root.view(content:, title: "Posts")
27
+
|> wisp.html_response(200)
28
}
29
30
pub fn post_preview(post: post.Post) -> element.Element(_) {
···
44
class("flex-col p-4 mx-auto w-full rounded-lg shadow-sm bg-ctp-mantle")
45
46
html.li([li_styles], [
47
+
html.a([attr.href("/posts/" <> post.meta.slug <> ".html")], [
48
html.h2([class("text-2xl font-bold text-pretty")], [
49
html.text(meta.title),
50
]),
+1
-1
src/blog/page/recent_posts.gleam
···
37
string.join([day, month, year], with: " ")
38
}
39
40
-
let href = attr.href("/posts/" <> post.meta.slug)
41
let style = class("flex flex-col p-4 mx-auto w-full rounded-lg bg-ctp-mantle")
42
43
html.li([style], [
···
37
string.join([day, month, year], with: " ")
38
}
39
40
+
let href = attr.href("/posts/" <> post.meta.slug <> ".html")
41
let style = class("flex flex-col p-4 mx-auto w-full rounded-lg bg-ctp-mantle")
42
43
html.li([style], [
-2
src/blog/root.gleam
···
1
import lustre/attribute.{class} as attr
2
import lustre/element
3
import lustre/element/html
4
-
import wisp
5
6
pub fn view(title title: String, content content: List(element.Element(_))) {
7
let viewport_meta =
···
32
33
html.html([attr.lang("en")], [head, html.body([style], content)])
34
|> element.to_document_string
35
-
|> wisp.html_response(200)
36
}
···
1
import lustre/attribute.{class} as attr
2
import lustre/element
3
import lustre/element/html
0
4
5
pub fn view(title title: String, content content: List(element.Element(_))) {
6
let viewport_meta =
···
31
32
html.html([attr.lang("en")], [head, html.body([style], content)])
33
|> element.to_document_string
0
34
}
+83
src/build.gleam
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import blog
2
+
import blog/page/footer
3
+
import blog/page/navbar
4
+
import blog/post
5
+
import blog/root
6
+
import contour
7
+
import filepath as path
8
+
import gleam/list
9
+
import gleam/option
10
+
import jot
11
+
import lustre/attribute.{class} as attr
12
+
import lustre/element
13
+
import lustre/element/html
14
+
import simplifile
15
+
16
+
pub fn main() {
17
+
let assert Ok(priv) = blog.priv_directory()
18
+
let posts_path = path.join(priv, "posts")
19
+
let out_path = path.join(priv, "static") |> path.join("posts")
20
+
21
+
let assert Ok(entries) = simplifile.read_directory(posts_path)
22
+
as "Read posts directory"
23
+
24
+
use file_name <- list.each(entries)
25
+
let file_path = path.join(posts_path, file_name)
26
+
let assert Ok(post) = post.from_string(path: file_path) as "Parse post"
27
+
28
+
let title = post.meta.title
29
+
30
+
let post_header =
31
+
html.header([class("hidden md:block")], [
32
+
html.h1([class("mb-1 text-4xl font-bold")], [html.text(title)]),
33
+
html.p([class("my-2")], [html.text(post.meta.description)]),
34
+
html.hr([class("border text-ctp-surface0")]),
35
+
])
36
+
37
+
let back_button_style =
38
+
class(
39
+
"mx-auto w-full text-right underline underline-offset-2 text-ctp-lavender",
40
+
)
41
+
42
+
let back_button =
43
+
html.a([attr.href("/posts"), back_button_style], [
44
+
html.text("Back"),
45
+
])
46
+
47
+
let content_html =
48
+
root.view(title:, content: [
49
+
navbar.view([]),
50
+
post_header,
51
+
view(post),
52
+
back_button,
53
+
footer.view(),
54
+
])
55
+
56
+
let out_file = path.join(out_path, post.meta.slug <> ".html")
57
+
let assert Ok(_) = simplifile.write(out_file, content_html)
58
+
}
59
+
60
+
pub fn view(post: post.Post) -> element.Element(a) {
61
+
let content =
62
+
post.body.content
63
+
|> list.map(highlight_codeblock)
64
+
65
+
let post_body =
66
+
jot.Document(..post.body, content:)
67
+
|> jot.document_to_html
68
+
69
+
let style = class("grid grid-cols-1 gap-4 w-full text-pretty post-content")
70
+
71
+
element.fragment([element.unsafe_raw_html("", "article", [style], post_body)])
72
+
}
73
+
74
+
fn highlight_codeblock(container: jot.Container) -> jot.Container {
75
+
case container {
76
+
jot.Codeblock(_, language: option.Some("gleam"), content:) -> {
77
+
let code = "<pre><code>" <> contour.to_html(content) <> "</code></pre>"
78
+
jot.RawBlock(code)
79
+
}
80
+
81
+
other -> other
82
+
}
83
+
}
-3
src/web/http_router.gleam
···
1
-
import blog/page/content
2
import blog/page/home
3
import blog/page/posts
4
import web
···
9
10
case wisp.path_segments(req) {
11
[] -> home.handle_request(ctx)
12
-
["uses"] -> content.handle_request(ctx, "uses")
13
["posts"] -> posts.handle_request(ctx)
14
-
["posts", post] -> content.handle_request(ctx, post)
15
16
["healthcheck"] -> wisp.ok()
17
_ -> wisp.not_found()
···
0
1
import blog/page/home
2
import blog/page/posts
3
import web
···
8
9
case wisp.path_segments(req) {
10
[] -> home.handle_request(ctx)
0
11
["posts"] -> posts.handle_request(ctx)
0
12
13
["healthcheck"] -> wisp.ok()
14
_ -> wisp.not_found()