This is a post about building up your own Static Site Generator from scratch. If you want to deploy your Phoenix LiveView app right now, then check out how to get started. You could be up and running in minutes.
The year is 2023, you have many options for building a Static Website. From the OG Jekyll to literally hundreds of JavaScript based options to people suggesting you should just craft HTML by hand. All of these solutions are correct and good, and you know what? You should use them!
End of post, no need to read on.
That said… a static website is really just HTML, CSS and JS files. In Elixir, we have wonderful tools for doing that. So let’s do it!
The Map
This post is going to assume you are at least a beginner to intermediate in Elixir.
Starting from scratch with an empty Elixir project, we will build a basic personal website and blog. We’ll add each dependency as we need them and integrate them. We’ll be using well known libraries, and I think we’ll be surprised by how far we get by just following our intuition!
So let’s begin with the most basic elixir project:
$ mix new personal_website
* creating README.md
* creating .formatter.exs
* creating .gitignore
* creating mix.exs
* creating lib
* creating lib/personal_website.ex
* creating test
* creating test/test_helper.exs
* creating test/personal_website_test.exs
Your Mix project was created successfully.
You can use "mix" to compile it, test it, and more:
cd personal_website
mix test
Instead of running the tests, I recommend removing the test/personal_website_test.exs
because we’re building a personal website. I also like to do a git init && git commit -am "Make it so"
, just in case I mess up and want to undo, or show diffs in a blog post.
Let’s start with our blog content.
Content
We want to author in Markdown and publish to HTML, luckily there is a handy library NimblePublisher, just for that, adding to our mix.exs
file:
defp deps do
[
{:nimble_publisher, "~> 0.1.3"}
]
end
NimblePublisher is a Dashbit library that will read markdown from a directory, parse the front matter, produce markdown, and build up data structures for creating your own content site. It does not however render it to HTML for you or building any sort of routing.
It essentially acts like a compile time database for interfacing with a directory of Markdown.
Luckily for us their docs walk through building a blog and provide some sensible defaults, we want a /posts/YEAR/MONTH-DAY-ID.md
file name, and we want to parse that with NimblePublisher into a Post struct. Let’s create our first module,lib/blog.ex
defmodule PersonalWebsite.Blog do
alias PersonalWebsite.Post
use NimblePublisher,
build: Post,
from: "./posts/**/*.md",
as: :posts,
highlighters: [:makeup_elixir, :makeup_erlang]
@posts Enum.sort_by(@posts, & &1.date, {:desc, Date})
# And finally export them
def all_posts, do: @posts
end
Here we configure NimblePublisher which will read each markdown file from
the posts directory and call the Post.build/3
function on each. Then finally it will assign to the module attribute @posts
configured with :as
. Then we sort the @posts
by date and define a function that returns all_posts
.
Take note that this is all happening at compile time and is embedded into our compiled module. Meaning accessing it will be lighting quick!
The keen eye’d will be asking, “So what about post? And build/3
?” We define those in lib/post.ex
:
defmodule PersonalWebsite.Post do
@enforce_keys [:id, :author, :title, :body, :description, :tags, :date, :path]
defstruct [:id, :author, :title, :body, :description, :tags, :date, :path]
def build(filename, attrs, body) do
path = Path.rootname(filename)
[year, month_day_id] = path |> Path.split() |> Enum.take(-2)
path = path <> ".html"
[month, day, id] = String.split(month_day_id, "-", parts: 3)
date = Date.from_iso8601!("#{year}-#{month}-#{day}")
struct!(__MODULE__, [id: id, date: date, body: body, path: path] ++ Map.to_list(attrs))
end
end
and before we dive into this, add a test post to posts/2023/04-01-pranks.md
:
%{
title: "Pranks!",
author: "Jason Stiebs",
tags: ~w(april fools),
description: "Let's learn how to do pranks!"
}
---
## Gotcha! Not a real post.
This is very funny.
During compile time, NimblePublisher will grab every file from /posts/**/*.md
and apply the Post.build/3
function to it. The function build/3
is expected to return a data structure representing a post. In this case, we chose a struct with all the same fields as our front matter and a couple extra we parse from the filename.
Note that NimblePublisher expects the markdown to have a front-matter formatted as an Elixir Map, followed by ---
, finally followed by the post Markdown.
The build/3
function pulls apart the path to collect the year
, month
, day
and id
from the file name and builds a Date
struct. It also generates the final path URL, appending .html
.
Let’s test this in iex
and see what we’ve got:
$ iex -S mix
iex(1)> PersonalWebsite.Blog.all_posts()
[
%PersonalWebsite.Post{
id: "pranks",
author: "Jason Stiebs",
title: "Pranks!",
body: "<h2>\nGotcha!</h2>\n<p>\nNot a real post. This is very funny.</p>\n",
description: "Let's learn how to do pranks!",
tags: ["april", "fools"],
date: ~D[2023-04-01],
path: "posts/2023/04-01-pranks.html"
}
]
Beautiful.
From here on out, we have our “context” with all of our posts. If we want a filtered set, or to add paging, we’d do it by adding functions to our Blog
and using the built-in Enum
functions. Adding more files to /posts
will result in this list having one most Post
‘s, it’s that simple!
Don’t worry about scaling this, because if you do hit the point where this takes up too much memory, you will have people who are eager to fix this for you, because they will be tired of generating markdown files. That said, since this is compiled, the cost is paid once at compile time so no big deal!
Rendering HTML
Ever since they were announced, I’ve really loved building HTML as Phoenix Components. And even though we only be using 1/10th of the functionality, let’s pull in PhoenixLiveView so we can use HEEX. Editing mix.exs
:
defp deps do
[
- {:nimble_publisher, "~> 0.1.3"}
+ {:nimble_publisher, "~> 0.1.3"},
+ {:phoenix_live_view, "~> 0.18.2"}
Now to make a new module responsible for rendering our website into HTML, open up lib/personal_site.ex
:
defmodule PersonalWebsite do
use Phoenix.Component
import Phoenix.HTML
def post(assigns) do
~H"""
<.layout>
<%= raw @post.body %>
</.layout>
"""
end
def index(assigns) do
~H"""
<.layout>
<h1>Jason's Personal website!!</h1>
<h2>Posts!</h2>
<ul>
<li :for={post <- @posts}>
<a href={post.path}> <%= post.title %> </a>
</li>
</ul>
</.layout>
"""
end
def layout(assigns) do
~H"""
<html>
<body>
<%= render_slot(@inner_block) %>
</body>
</html>
"""
end
end
If you are familiar with Phoenix Components, then you will know exactly what’s going on here. We have our base layout/1
function, which builds our base HTML and accepts an inner_block
. We have two separate page types, one for index/1
and one for our post/1
. Using only the primitives that Phoenix provides us to build our HTML using functions!
If we wanted a third page like about
we’d simply make a new function! If your layout grows unwieldy, move it to its own file. It’s just functions!
Now it’s a matter of wiring it up to our data! Let’s add a build/0
function to collect all of our data, render it and output it to /output
:
@output_dir "./output"
File.mkdir_p!(@output_dir)
def build() do
posts = Blog.all_posts()
render_file("index.html", index(%{posts: posts}))
for post <- posts do
dir = Path.dirname(post.path)
if dir != "." do
File.mkdir_p!(Path.join([@output_dir, dir]))
end
render_file(post.path, post(%{post: post}))
end
:ok
end
def render_file(path, rendered) do
safe = Phoenix.HTML.Safe.to_iodata(rendered)
output = Path.join([@output_dir, path])
File.write!(output, safe)
end
Stepping through the code we:
- Create the
output_dir
if it doesn’t exist - Grab all of the posts.
- Render the index.html, write it to disk.
- For each post:
- Build the “year” directory if it doesn’t exist
- Render the file
- Write it to disk.
The render_file/2
function does have one interesting line, Phoenix.HTML.Safe.to_iodata/1
will take a Phoenix rendered component and output it to an HTML safe iodata
, which is a weird name for a string in a list, but Erlang knows how to use these to be very efficient. If we were to “dead render” this using a Phoenix Controller, this is the last function Phoenix would call before sending it down the wire.
Load up iex
and see what we get!
$ iex -S mix
iex(1)> PersonalWebsite.build()
:ok
CTRL-C CTRL-C
$ open ./output/index.html
We should be greeted by our wonderful website!
And this for the post
Hey, this is starting to look like a real website! If you check the ./output
all the files are put where they belong. You could deploy this as is, but we’re going to keep going.
Automation!
A mix task would be mighty handy here, first $ mkdir -p lib/mix/tasks
and then edit lib/mix/tasks/build.ex
:
defmodule Mix.Tasks.Build do
use Mix.Task
@impl Mix.Task
def run(_args) do
{micro, :ok} = :timer.tc(fn ->
PersonalWebsite.build()
end)
ms = micro / 1000
IO.puts("BUILT in #{ms}ms")
end
end
Running it:
$ mix build
BUILT in 13.47ms
Now we’re getting somewhere… but you know there is one thing we haven’t solved yet? CSS and JS. So do that!
First add a couple familiar deps:
+ {:esbuild, "~> 0.5"},
+ {:tailwind, "~> 0.1.8"}
Create a config/config.exs
import Config
# Configure esbuild (the version is required)
config :esbuild,
version: "0.14.41",
default: [
args:
~w(app.js --bundle --target=es2017 --outdir=../output/assets --external:/fonts/* --external:/images/*),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]
# Configure tailwind (the version is required)
config :tailwind,
version: "3.2.4",
default: [
args: ~w(
--config=tailwind.config.js
--input=css/app.css
--output=../output/assets/app.css
),
cd: Path.expand("../assets", __DIR__)
]
This is copy/pasted from a fresh mix phx.new
generated website. I did change the output paths to make it work with out setup. Create an asset’s directory $ mkdir assets
create an assets/app.js
console.log("HELLO WORLD")
Create a assets/tailwind.config.js
module.exports = {
content: [
"./**/*.js",
"../lib/personal_website.ex",
],
plugins: [
require("@tailwindcss/typography"),
]
};
Finally, create a mix alias opening up mix.exs
again
+ aliases: aliases(),
deps: deps()
]
+ defp aliases() do
+ [
+ "site.build": ["build", "tailwind default --minify", "esbuild default --minify"]
+ ]
+ end
Now when we run mix site.build
Elixir will download esbuild
and tailwind
and execute them outputting to output/
for us. We’re getting nearly the same development experience as a full Phoenix Application!
Finally, we have to add the CSS and JS we compiled updating our layout/1
in lib/personal_website.ex
+ <link rel="stylesheet" href="/assets/app.css" />
+ <script type="text/javascript" src="/assets/app.js" />
</head>
And now we can write JavaScript in our assets/app.js
or and use Tailwind classes in our site!
One small hitch is that we now need to use a web server for local development, since assets are served relative to the base path. Now we could add another dep, but this post is getting too long, so we are going to use the lean on a built-in to python web serverr.
$ cd output && python3 -m http.server 8000
When we open http://localhost:8000
we should see our website in full glory! Edit the files, rebuild and boom we have a personal website!
The final piece is copying over static assets like Images or Fonts. I will leave that as an exercise to the reader but to give you a hint Path.wildcard/2
and File.cp!/3
will get you a long way!
Deploying it
Anywhere you can send HTML files and static assets will work here! Obviously this is Fly.io, and we have a wonderful guide on deploying Static Websites, we’re going to crib off that.
Let’s create a Dockerfile
.
FROM pierrezemb/gostatic
COPY ./output/ /srv/http/
This Dockerfile assumes we’re building locally to ./output and deploying. We could use the Phoenix Dockerfile to build it as well and change the final output to the above, but I’ll leave that as an exercise to the reader.
Now let’s run fly apps create --machines
and create our own fly.toml
app = "APP_NAME"
kill_signal = "SIGINT"
kill_timeout = 5
processes = []
[http_service]
internal_port = 8043
force_https = true
[http_service.concurrency]
type = "requests"
soft_limit = 200
hard_limit = 250
This sets up the gostatic
file server to serve port 8043 and tells Fly.io to let it rip! So let’s deploy this with fly deploy
. Once complete, lets check it out with fly open
!
We are deployed!
Wrap up
We used NimblePublisher to simplify Markdown and Front Matter. We used Phoenix.Component to render our HTML. We used built in Elixir File and Path functions to write them to our deployment directory. We used the same Tailwind and Esbuild hex packages that Phoenix does to give us a modern front end working environment. Finally, we deployed it to Fly.io.
In ~100 lines of code built our own static site generator and we did it using the same Elixir packages we know and love. When something breaks or if we need to add a feature, we can just do it. But that is also the downside, if we want to do something we have to build it. Other pre-built tools have libraries plugins for most tasks.
That said, there is nothing so complex in the world of the wide web. You can build anything.