We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
So I built this static website
Edit 01.08.2024: I replaces earmark and all the makeup_* libraries with mdex which simplified highlighting code snippets, but I’ll keep this article here for historical reference
So I built this website instead of updating my old one.
The old one was a simple next.js site I set up years ago, but looking into the JS ecosystem after a simple markdown blogging engine stressed me out. So many open issues on everything and half of everything has been abandoned for the next new thing.
The only sensible idea is to rebuild if from scratch in another language, surely!
I’m happy you agree! Anyways. Elixir to the rescue of course. And the ever productive Dashbit to the rescue as well of course of course. They already figured out how to do this and told us all about it years ago and even built a nice library, NimblePublisher for us to use.
Using that blog post and the NimblePublisher docs as a starting point, these are the steps I took to build the impressive website you see before you.
Step 0
Spend a lot of time generating a bunch of low quality images of men battling potatoes to go with your as of yet non-existing blog posts. I used Fooocus
, but you can use any art generator.
Also, while the images are being generated, install Elixir, Erlang and the Phoenix project generator if you haven’t already.
Step 1 - Create new phoenix project.
$ mix phx.new bloggo
Bloggo is born! What a great name for a project. We’re off to a good start!
I don’t actually need a database or Ecto for this so I should have run it with --no-ecto
. I forgot to do this however, so I went into application.ex and commented out Bloggo.Repo
from the supervisor children so the console would stop yelling at me about the missing database. Who knows, might want Ecto involved at some point anyway.
Step 2 - Setup NimblePublisher
Following the setup guide in the readme, with a few tiny tweaks got us about 50% of the way to a new shiny blog.
Add the NimblePublisher deps and some extra packages to turn our markdown code snippets into nicely highlighted html.
defp deps do
[
{:phoenix, "~> 1.7.10"},
...
{:nimble_publisher, "~> 1.0"},
{:makeup_elixir, ">= 0.0.0"},
{:makeup_eex, "~> 0.1.0"},
{:makeup_erlang, ">= 0.0.0"}
{:makeup_js, "~> 0.1.0"},
{:makeup_diff, "~> 0.1.0"},
{:makeup_html, "~> 0.1.1"},
]
end
NimblePublisher suggests putting the date of the blog post in the path of the individual markdown file like so /posts/YEAR/MONTH-DAY-ID.md
. I didn’t like this, so I threw it into the data map in the blog post instead along with some extra fields.
So this blog post lives in ./posts/so-i-built-this-website.md
and starts like this. Images goes into ./priv/static/images/posts
%{
title: "So I built this static website",
author: "Sean B. Powell",
tags: ~w(howto elixir phoenix),
cover_image: "/images/posts/so-i-built-this-website.png",
description: "I build this website, so now I feel like I need to add more content. Here's how I did it.",
date: ~D"2024-01-15"
}
---
# So I built this static website
...
Then, to turn the markdown into html, we continue our NimblePublisher setup mostly like suggested in the readme. I didn’t add the tag filtering for some reason. Bet that won’t come back to bite me in the ass.
defmodule Bloggo.Posts do
alias Bloggo.Post
use NimblePublisher,
build: Post,
from: "./posts/*.md",
as: :posts,
highlighters: [
:makeup_elixir,
:makeup_eex,
:makeup_erlang,
:makeup_js,
:makeup_diff,
:makeup_html
]
@posts Enum.sort_by(@posts, & &1.date, {:desc, Date})
def all_posts, do: @posts
end
Simplified the Post module a bit now that we aren’t using the path to store data.
defmodule Bloggo.Post do
@enforce_keys [:id, :author, :title, :body, :description, :tags, :date]
defstruct [:id, :author, :title, :body, :description, :tags, :date, :cover_image]
def build(filename, attrs, body) do
struct!(
__MODULE__,
Map.to_list(attrs) ++
[
id: Path.rootname(filename),
body: body
]
)
end
end
Step 3 - Hook it into Phoenix
Now, this is the point where I’d normally set up a liveview, but seeing as this is so simple, I’ve gone with a good old fashioned MVC setup.
So we set up a route and point it at a controller.
defmodule BloggoWeb.Router do
...
scope "/", BloggoWeb do
pipe_through :browser
get "/", PageController, :home # <-- already generated for us
get "/blog/", PageController, :index
get "/blog/*id", PageController, :blog_post
end
end
Phoenix generated a default controller for us called PageController
, so lets reuse it for the blog stuff.
# Bloggo_web/controller/page_controller.ex
defmodule BloggoWeb.PageController do
use BloggoWeb, :controller
def home(conn, _params) do
conn |> assign(:page_title, "Home") |> render(:home, layout: false)
end
def blog(conn, _params) do
conn |> assign(:posts, Posts.all_posts()) |> assign(:page_title, "Blog") |> render(:index)
end
def blog_post(conn, params) do
id = params["id"] |> Enum.join("/")
post = Bloggo.Posts.all_posts() |> Enum.find(&(&1.id == id))
case post do
nil ->
raise Phoenix.Router.NoRouteError, conn: conn, router: BloggoWeb.Router
post ->
conn
|> assign(:post, post)
|> assign(:page_title, post.title)
|> render(:blog_post)
end
end
end
And then finally the templates for the index and the blog post.
<!-- bloggo_web/controllers/page_html/index.html.heex -->
<div class="prose prose-xl post mx-2">
<%= raw(@post.body) %>
</div>
Most of the heavy lifting in the index is done by the tailwind prose
class, the rest is handled by the Makeup library.
<!-- bloggo_web/controllers/page_html/blog_post.html.heex -->
<div class="container mx-auto flex flex-wrap py-6">
<article :for={post <- @posts} class="flex flex-col shadow my-4 w-full">
<% url = "/blog/#{post.id}" %>
<a href={url}>
<img class="object-cover rounded-t-lg h-48 w-full" src={post.cover_image} />
</a>
<div class="bg-white flex flex-col justify-start p-6">
<a href={url} class="text-3xl font-bold hover:text-gray-700 pb-2">
<%= post.title %>
</a>
<div class="flex flex-row pb-2">
<a
:for={tag <- post.tags}
href={~p"/blog?tag=#{tag}"}
class="text-blue-700 text-xs font-bold uppercase pr-4"
>
<%= tag %>
</a>
</div>
<p href="#" class="text-sm">
By <span class="font-semibold hover:text-gray-800"><%= post.author %></span>, Published on <%= local_date(
post
) %>
</p>
<p class="py-4">
<%= post.description %>
</p>
<a href={url} class="uppercase text-gray-800 hover:text-black">
Read all about it <.icon name="hero-arrow-right" class="h-6" />
</a>
</div>
</article>
</div>
Generate some styles for the markdown blocks in the iex console like so, Makeup.stylesheet(:monokai_style)
, and throw them into app.css
And there you have it. Every bit of code required to render this tiny blog.
Step 4 - Tweaks
I added some extra styles to app.css
to tweak the defaults a bit.
And here I ran into a problem with the makeup library, there doesn’t seem to be a css highlighter. If the snippet blow isn’t highlighted, I still haven’t fixed this.
/* Set same background as tailwind on markup blocks */
.post .makeup {
background-color: var(--tw-prose-pre-bg);
}
/* Potato colored highlighting of code blocks */
.post :not(a):not(pre)>code {
background-color: rgb(246, 228, 171);
padding-left: 6px;
padding-right: 6px;
}
/* Potato colored highlighting of links */
.post a {
text-decoration: solid underline rgb(193, 150, 89) 3px;
}
I also wasn’t a fan of the quotes that prose throws around a code block, so I disabled them in tailwind.config.js
. Lots of tweaking possible if you don’t quite like how the prose class does things.
module.exports = {
...,
theme: {
extend: {
typography: {
DEFAULT: {
css: {
"code::before": false,
"code::after": false,
}
},
}
},
},
...
}
Step 5 - Deploy it
I hooked this blog into github actions and CapRover, but I would probably suggest you use fly.io instead. It’s cheaper and way cooler!
The only think you need to keep in mind here is that you need to copy over the /posts
directory in the docker file, otherwise it won’t find any blog posts to compile at compile time.
Now comes the hard part. The mandatory blog post about your new blogging setup.
Good luck!