+89
Dockerfile
+89
Dockerfile
···
1
+
# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian
2
+
# instead of Alpine to avoid DNS resolution issues in production.
3
+
#
4
+
# https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu
5
+
# https://hub.docker.com/_/ubuntu?tab=tags
6
+
#
7
+
# This file is based on these images:
8
+
#
9
+
# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
10
+
# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20230612-slim - for the release image
11
+
# - https://pkgs.org/ - resource for finding needed packages
12
+
# - Ex: hexpm/elixir:1.15.7-erlang-26.0.2-debian-bullseye-20230612-slim
13
+
#
14
+
ARG ELIXIR_VERSION=1.15.7
15
+
ARG OTP_VERSION=26.0.2
16
+
ARG DEBIAN_VERSION=bullseye-20230612-slim
17
+
18
+
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
19
+
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
20
+
21
+
FROM ${BUILDER_IMAGE} as builder
22
+
23
+
# Install build dependencies
24
+
RUN apt-get update -y && apt-get install -y build-essential git \
25
+
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
26
+
27
+
# Prepare build dir
28
+
WORKDIR /app
29
+
30
+
# Install hex + rebar
31
+
RUN mix local.hex --force && \
32
+
mix local.rebar --force
33
+
34
+
# Set build ENV
35
+
ENV MIX_ENV="prod"
36
+
37
+
# Install mix dependencies
38
+
COPY mix.exs mix.lock ./
39
+
RUN mix deps.get --only $MIX_ENV
40
+
RUN mkdir config
41
+
42
+
# Copy config/runtime.exs from your project
43
+
COPY config/config.exs config/${MIX_ENV}.exs config/
44
+
RUN mix deps.compile
45
+
46
+
COPY priv priv
47
+
48
+
COPY lib lib
49
+
50
+
COPY assets assets
51
+
52
+
# Compile assets
53
+
RUN mix assets.deploy
54
+
55
+
# Compile the release
56
+
RUN mix compile
57
+
58
+
# Changes to config/runtime.exs don't require recompiling the code
59
+
COPY config/runtime.exs config/
60
+
61
+
COPY rel rel
62
+
RUN mix release
63
+
64
+
# Start a new build stage so that the final image will only contain
65
+
# the compiled release and other runtime necessities
66
+
FROM ${RUNNER_IMAGE}
67
+
68
+
RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \
69
+
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
70
+
71
+
# Set the locale
72
+
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
73
+
74
+
ENV LANG en_US.UTF-8
75
+
ENV LANGUAGE en_US:en
76
+
ENV LC_ALL en_US.UTF-8
77
+
78
+
WORKDIR "/app"
79
+
RUN chown nobody /app
80
+
81
+
# Set runner ENV
82
+
ENV MIX_ENV="prod"
83
+
84
+
# Only copy the final release from the build stage
85
+
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/blog ./
86
+
87
+
USER nobody
88
+
89
+
CMD ["/app/bin/server"]
+32
fly.toml
+32
fly.toml
···
1
+
# fly.toml app configuration file generated for thoughts-and-tidbits on 2025-02-01T10:53:39-05:00
2
+
#
3
+
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
4
+
#
5
+
6
+
app = "thoughts-and-tidbits"
7
+
primary_region = "ord"
8
+
kill_signal = "SIGTERM"
9
+
10
+
[build]
11
+
12
+
[env]
13
+
PHX_HOST = "thoughts-and-tidbits.fly.dev"
14
+
PORT = "8080"
15
+
16
+
[http_service]
17
+
internal_port = 8080
18
+
force_https = true
19
+
auto_stop_machines = true
20
+
auto_start_machines = true
21
+
min_machines_running = 0
22
+
processes = ["app"]
23
+
24
+
[http_service.concurrency]
25
+
type = "connections"
26
+
hard_limit = 1000
27
+
soft_limit = 1000
28
+
29
+
[[vm]]
30
+
cpu_kind = "shared"
31
+
cpus = 1
32
+
memory_mb = 1024
+14
-5
lib/blog/content/post.ex
+14
-5
lib/blog/content/post.ex
···
2
2
defstruct [:body, :title, :written_on, :tags, :slug]
3
3
4
4
def all do
5
-
"priv/posts/*.md"
5
+
"priv/static/posts/*.md"
6
6
|> Path.wildcard()
7
7
|> Enum.map(&parse_post_file/1)
8
8
|> Enum.sort_by(& &1.written_on, {:desc, NaiveDateTime})
9
9
end
10
10
11
11
def get_by_slug(slug) do
12
-
all()
13
-
|> Enum.find(&(&1.slug == slug))
12
+
require Logger
13
+
all_posts = all()
14
+
Logger.debug("Looking for slug: #{slug}")
15
+
Logger.debug("Available slugs: #{inspect(Enum.map(all_posts, & &1.slug))}")
16
+
Logger.debug("First post for debugging: #{inspect(List.first(all_posts), pretty: true)}")
17
+
18
+
found = Enum.find(all_posts, &(&1.slug == slug))
19
+
Logger.debug("Found post?: #{inspect(found != nil)}")
20
+
found
14
21
end
15
22
16
23
defp parse_post_file(file) do
24
+
require Logger
17
25
filename = Path.basename(file, ".md")
26
+
Logger.debug("Parsing file: #{filename}")
18
27
[year, month, day, hour, minute, second | title_words] = String.split(filename, "-")
19
-
_title = Enum.join(title_words, " ")
20
28
datetime = parse_datetime(year, month, day, hour, minute, second)
21
29
slug = Enum.join(title_words, "-")
30
+
Logger.debug("Generated slug: #{slug}")
22
31
23
32
%__MODULE__{
24
33
body: File.read!(file),
···
38
47
String.to_integer(minute),
39
48
String.to_integer(second)
40
49
)
41
-
IO.inspect(datetime, label: "parsed datetime for post")
50
+
datetime # Make sure we're returning the datetime
42
51
end
43
52
44
53
defp humanize_title(slug) do
+28
lib/blog/release.ex
+28
lib/blog/release.ex
···
1
+
defmodule Blog.Release do
2
+
@moduledoc """
3
+
Used for executing DB release tasks when run in production without Mix
4
+
installed.
5
+
"""
6
+
@app :blog
7
+
8
+
def migrate do
9
+
load_app()
10
+
11
+
for repo <- repos() do
12
+
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
13
+
end
14
+
end
15
+
16
+
def rollback(repo, version) do
17
+
load_app()
18
+
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
19
+
end
20
+
21
+
defp repos do
22
+
Application.fetch_env!(@app, :ecto_repos)
23
+
end
24
+
25
+
defp load_app do
26
+
Application.load(@app)
27
+
end
28
+
end
+3
-4
lib/blog_web/components/layouts.ex
+3
-4
lib/blog_web/components/layouts.ex
···
22
22
def posts_by_tag do
23
23
Blog.Content.Post.all()
24
24
|> Enum.flat_map(fn post ->
25
-
Enum.map(post.tags, fn tag -> {tag.name, {post.title, format_date(post.written_on), post.slug}} end)
25
+
Enum.map(post.tags, fn tag -> {tag.name, {post.title, post.slug}} end)
26
26
end)
27
27
|> Enum.group_by(
28
-
fn {tag_name, _post} -> tag_name end,
29
-
fn {_tag_name, post} -> post end
28
+
fn {tag_name, _} -> tag_name end,
29
+
fn {_, post_info} -> post_info end
30
30
)
31
31
|> Enum.map(fn {tag_name, posts} -> {tag_name, Enum.take(posts, 2)} end)
32
32
end
33
-
34
33
35
34
defp format_date(datetime) do
36
35
Calendar.strftime(datetime, "%B %d, %Y")
-10
lib/blog_web/components/layouts/root.html.heex
-10
lib/blog_web/components/layouts/root.html.heex
···
53
53
Posts by Tag
54
54
</h2>
55
55
<%= for {tag, posts} <- posts_by_tag() do %>
56
-
57
56
<div class="space-y-6">
58
57
<div class="group">
59
58
<a href={~p"/post/#{tag}"} class="block">
60
59
<h3 class="text-sm font-medium text-gray-900 group-hover:text-blue-600 transition-colors">
61
60
<%= tag %>
62
61
</h3>
63
-
<p class="text-xs text-gray-500 mt-1 font-normal">
64
-
<%= for {title, slug} <- posts do %>
65
-
<a href={~p"/post/#{slug}"} class="block">
66
-
<h6 class="text-sm font-medium text-gray-900 group-hover:text-blue-600 transition-colors">
67
-
<%= title %>
68
-
</h6>
69
-
</a>
70
-
<% end %>
71
-
</p>
72
62
</a>
73
63
</div>
74
64
</div>
+54
lib/blog_web/live/index.ex
+54
lib/blog_web/live/index.ex
···
1
+
defmodule BlogWeb.PostLive.Index do
2
+
use BlogWeb, :live_view
3
+
4
+
def mount(_params, _session, socket) do
5
+
posts = Blog.Content.Post.all()
6
+
{:ok, assign(socket, posts: posts)}
7
+
end
8
+
9
+
def render(assigns) do
10
+
~H"""
11
+
<div class="px-8 py-12">
12
+
<div class="max-w-7xl mx-auto">
13
+
<h1 class="text-4xl font-bold text-gray-900 mb-8">All Posts</h1>
14
+
15
+
<div class="space-y-10">
16
+
<%= for post <- @posts do %>
17
+
<article class="p-6 bg-white rounded-lg border-2 border-gray-200">
18
+
<h2 class="text-2xl font-bold text-gray-900 mb-2">
19
+
<.link href={~p"/post/#{post.slug}"} class="hover:text-blue-600 transition-colors">
20
+
<%= post.title %>
21
+
</.link>
22
+
</h2>
23
+
24
+
<div class="flex items-center space-x-4 text-sm text-gray-500 mb-4">
25
+
<time datetime={post.written_on}>
26
+
<%= Calendar.strftime(post.written_on, "%B %d, %Y") %>
27
+
</time>
28
+
<div class="flex items-center space-x-2">
29
+
<%= for tag <- post.tags do %>
30
+
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100">
31
+
<%= tag.name %>
32
+
</span>
33
+
<% end %>
34
+
</div>
35
+
</div>
36
+
37
+
<div class="prose prose-lg max-w-none">
38
+
<%= # Show first paragraph or excerpt %>
39
+
<%= String.split(post.body, "\n\n") |> List.first() %>
40
+
</div>
41
+
42
+
<div class="mt-4">
43
+
<.link href={~p"/post/#{post.slug}"} class="text-blue-600 hover:text-blue-800 transition-colors">
44
+
Read more →
45
+
</.link>
46
+
</div>
47
+
</article>
48
+
<% end %>
49
+
</div>
50
+
</div>
51
+
</div>
52
+
"""
53
+
end
54
+
end
+15
-5
lib/blog_web/live/post_live.ex
+15
-5
lib/blog_web/live/post_live.ex
···
2
2
use BlogWeb, :live_view
3
3
4
4
def mount(%{"slug" => slug}, _session, socket) do
5
+
require Logger
6
+
Logger.debug("Mounting PostLive with slug: #{slug}")
7
+
5
8
case Blog.Content.Post.get_by_slug(slug) do
6
9
nil ->
10
+
Logger.debug("No post found for slug: #{slug}")
7
11
{:ok, push_navigate(socket, to: "/")}
8
12
9
13
post ->
14
+
Logger.debug("Found post: #{inspect(post, pretty: true)}")
10
15
case Earmark.as_html(post.body) do
11
16
{:ok, html, _} ->
12
17
headers = extract_headers(post.body)
···
17
22
)
18
23
{:ok, socket}
19
24
20
-
{:error, _html, errors} ->
21
-
# Log the error and show a friendly message
22
-
require Logger
23
-
Logger.error("Failed to parse markdown: #{inspect(errors)}")
24
-
{:ok, push_navigate(socket, to: "/")}
25
+
{:error, html, errors} ->
26
+
# Still show the content even if there are markdown errors
27
+
Logger.error("Markdown parsing warnings: #{inspect(errors)}")
28
+
headers = extract_headers(post.body)
29
+
socket = assign(socket,
30
+
html: html,
31
+
headers: headers,
32
+
post: post
33
+
)
34
+
{:ok, socket}
25
35
end
26
36
end
27
37
end
+1
-1
priv/static/posts/2024-03-10-14-45-00-pattern-matching-in-elixir.md
+1
-1
priv/static/posts/2024-03-10-14-45-00-pattern-matching-in-elixir.md
···
79
79
80
80
Posts are routed by slug, which is implicit by title in filename.
81
81
82
-
For example, `priv/static/posts/2024-03-10-14-45-00-pattern-matching-in-elixir.md` would be `pattern
82
+
For example, `priv/static/posts/2024-03-10-14-45-00-pattern-matching-in-elixir.md` would be routed as `/post/pattern-matching-in-elixir`.
+13
rel/env.sh.eex
+13
rel/env.sh.eex
···
1
+
#!/bin/sh
2
+
3
+
# configure node for distributed erlang with IPV6 support
4
+
export ERL_AFLAGS="-proto_dist inet6_tcp"
5
+
export ECTO_IPV6="true"
6
+
export DNS_CLUSTER_QUERY="${FLY_APP_NAME}.internal"
7
+
export RELEASE_DISTRIBUTION="name"
8
+
export RELEASE_NODE="${FLY_APP_NAME}-${FLY_IMAGE_REF##*-}@${FLY_PRIVATE_IP}"
9
+
10
+
# Uncomment to send crash dumps to stderr
11
+
# This can be useful for debugging, but may log sensitive information
12
+
# export ERL_CRASH_DUMP=/dev/stderr
13
+
# export ERL_CRASH_DUMP_BYTES=4096
+5
rel/overlays/bin/migrate
+5
rel/overlays/bin/migrate
+1
rel/overlays/bin/migrate.bat
+1
rel/overlays/bin/migrate.bat
···
1
+
call "%~dp0\blog" eval Blog.Release.migrate
+5
rel/overlays/bin/server
+5
rel/overlays/bin/server