Crafting a feature-rich static site generator in Elixir
Welcome to my little website! This is the my first ever post that accompanies this site. Making a personal website in 2023 might not seem like the most complex task, and you would be right. There are many (probably fantastic) tools out there that can help you with easily creating static website.
Instead of choosing the easy path, I decided to create my own static site generator (SSG). Being an avid Elixir fan, I decided to use it as the language of choice for this project. A good question might be why? There are a few reasons.
Firstly, I really love writing Elixir code. It allows me to write
expressive code that "Just Works", has powerful features like macros, and
a good community and ecosystem of useful libraries. Secondly, I was inspired by
some good resources, most notably the NimblePublisher library developed
by Dashbit and the accompanying article
Welcome to our blog: how it was made!.
This post builds on top of the article titled
Crafting your own Static Site Generator using Phoenix
by Jason Stiebs. I recommend reading it for a
more in-depth explanation of how to create a really simple SSG using Elixir and
Phoenix. The basic idea is to use NimblePublisher to generate posts in Elixir
using markdown files as input. The generated posts are then rendered using
HEExfunction components, and the resulting HTML is written to files in
an output folder. The generated HTML files can then be served using a static file
server or deployed to a CDN. My website is based on the same basic idea, but has
the following nice additions:
- It has a development mode that hosts the site locally using
BanditandPlug. - The development mode watches for changes in the source files, automatically recompiles them and reloads the browser.
- It allows for using
HEExcomponents inside markdown files, for more expressive content. - It has a can optimize images at compile-time and generate different image
sizes using the
Imagelibrary. - It has SEO tags for posts that makes then render nicely when shared on social media. This includes automatically generating a nice image for each post.
Why roll your own SSG?
There are many SSGs out there, including ones written in Elixir. One intriguing
option is Tableau, which looks like
a nice library, but seems to be mainly focused using it together with the Temple
templating engine. As i really enjoy working with HEEx in Phoenix and Phoenix
LiveView, so I wanted to use HHEx components to create whole website.
I am considering making this project into a library, as I see it as being useful for creating static websites with Elixir. However, I think there is still some merit in just using it as a starting point for your own website. Most people will have different requirements for their website, and not using a opinionated library will make your site endlessly more hackable. For now, I have decided to keep it as an open source standalone example project.
I will briefly describe some of the more interesting and hopefully useful parts of the project, that builds on top of the original article by Jason Stiebs.
Development mode
I am so used to having a nice development mode that automatically reloads the
browser when I make changes to the code, that I just had to add it to my SSG. My
implementation is based on the way Phoenix works. The gist of it is that I have
a Bandit server that serves the built site locally using the Plug.Static
module. On each request, the server rebuilds the site and serves the new
version. In the development mode, the web browser establishes a websocket
connection to the server. There is also a FileSystem process that watches for
changes in source files. When it detects a change, it sends a message to the
client to reload the page, and also initiates a recompile of changed modules.
HEEx components in markdown
I really like the idea of using HEEx components in markdown files. It allows
me to create reusable components with custom styling inside of my markdown posts.
This is an example of such a component:
This is content
inside
of a
HEEx
component.
The component is defined in the following way:
def callout(assigns) do
~H"""
<div class="rounded-lg border border-teal-200 bg-teal-50 px-8 py-4 shadow-sm lg:-mx-8">
<div class="flex flex-row items-center">
<.icon name={:information_circle} class="mr-2 h-6 w-6" />
<div class="prose-p:m-0">
<%= render_slot(@inner_block) %>
</div>
</div>
</div>
"""
end
And called from markdown like this:
<.callout>
This is content **inside** of a `HEEx` component.
</.callout>
My implementation of this is kind of hacky, but it works 🤷♂️. I had to submit
a PR to
NimblePublisherto make it work, and use the
Md library. This library enables the use of
custom parsers in markdown files. I created a custom parser for HEEx, that is
loosely based on
this
forum post. Since function components in HEEx are just plain old functions,
whenever we encounter a tag that starts with <., we can just call the function
component as a function. The result of this function is a %LiveView.Rendered{} struct,
which we can then convert into a string using Phoenix.HTML.Safe and add it
to the rendered HTML, like this:
defmodule Website.Build.HEEXParser do
alias Md.Parser.State
@behaviour Md.Parser
@impl true
def parse(input, state \\ %State{})
def parse(input, state) do
{tag, attrs, content, rest} = split_input(input)
assigns = get_assigns(attrs, content)
# The tag is a component, which is just a function, we call it
rendered_html =
apply(Website.Components, String.to_existing_atom(tag), [assigns])
|> Phoenix.HTML.Safe.to_iodata()
|> IO.chardata_to_string()
{rest, %State{state | ast: [rendered_html | state.ast]}}
end
...
end
We need to add the HEEx parser as a custom parser in Md in our
config/config.exsfile:
config :md,
syntax: %{
custom: [{"<.", {Website.Build.HEEXParser, %{}}}]
}
And finally we need to create a custom markdown converter (this is what my PR added
☺️) that replaces the default Earmark markdown parser with Md in NimblePublisher:
defmodule Website.Build.HTMLConverter do
def convert(extname, content, _attrs, opts) when extname in [".md"] do
highlighters = Keyword.get(opts, :highlighters, [])
Md.generate(content, format: :none) |> NimblePublisher.highlight(highlighters)
end
end
And add html_converter: Website.Build.HTMLConverter when using the NimblePublisher
module.
Some care needs to be taken to make sure that we can inject rendered markdown
content into the inner_block slot of the component. This is done by
recursively rendering the inner block of the component using Md, then
converting the result into EEx using the LiveView engine, and finally
assigning the result to the inner_block slot of the component. Check out
the full source code
for more details.
Image optimization
Sending large full-size images over the network is both slow and wasteful. I
wanted to have a good way of automatically optimizing images for my website.
HTML image tags have a srcset attribute that allows the browser to choose the
best image size for the current screen size. In order to use this, we need to
generate multiple versions of each image in different sizes when building the
site. While slightly abusing the notion of a function (this is a non-pure
function with side effects), this sounds like a good use of a HEEx function
component. Since I can use HEEx components in markdown files, I can have
optimized images in my posts without having to worry about it! It is implemented
in the following way:
attr :src, :string, required: true
attr :sizes, :string, default: ""
attr :rest, :global
def image(assigns) do
if String.starts_with?(assigns.src, "/"),
do: static_image(assigns),
else: external_image(assigns)
end
defp static_image(%{src: source} = assigns) do
alias Website.Images
with true <- Images.file_exists?(source),
paths <- Images.generate_variants(source) do
srcset =
Enum.map(paths, fn path -> "#{path.path} #{path.w}w" end)
|> Enum.join(", ")
assigns =
assigns
|> assign(:largest, List.last(paths).path)
|> assign(:srcset, srcset)
~H"""
<img src={@largest} srcset={@srcset} sizes={@sizes} {@rest} loading="lazy" />
"""
else
_ -> external_image(assigns)
end
end
The bulk of the work is done in the Images.generate_variants/1 function, which
idempotently generates different image sizes using the Image library. Since image
variants are only generated once, this does not slow down the build time when
developing the site. The function is implemented in the following way:
@content_dir Application.compile_env(:website, :content_dir, "priv/")
@output_dir Application.compile_env(:website, :output_dir, "output")
@output_path "#{@output_dir}/assets/static"
@breakpoints [16, 32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920]
def generate_variants(path, options \\ []) do
breakpoints = Keyword.get(options, :breakpoints, @breakpoints)
format = Keyword.get(options, :format, "webp")
path = Path.join(@content_dir, path)
image = Image.open!(path)
for breakpoint <- breakpoints do
file_name = Path.split(path) |> List.last() |> String.split(".") |> hd()
out_path = Path.join(@output_path, "#{file_name}-#{breakpoint}.#{format}")
if !File.exists?(out_path) do
File.mkdir_p!(Path.dirname(out_path))
Image.thumbnail!(image, breakpoint)
|> Image.write!(out_path)
end
%{path: String.trim_leading(out_path, @output_dir), w: breakpoint}
end
end
This function reads the image from the source directory, and for each breakpoint generates a new image with the given width. The generated images are saved to the output directory, and their paths and widths are returned.
SEO tags for posts
I wanted to have nice SEO tags for my posts, so that they render nicely when shared on social media. When sharing a link on a social media site such as Twitter, the site will try to fetch some specific tags from the page that describes the content of the page. These tags include the title, description, author and a preview image. The image is used as a preview when sharing the link on social media.
For generating the tags, i used the phoenix_seo library. Despite Phoenix being in the name, it works just fine without Phoenix. I followed the instructions in the README file, and just and added the following when generating the HTML for each post:
for post <- posts do
conn = SEO.assign(%Plug.Conn{}, post)
render_file(post.path, post(%{post: post, conn: conn}))
end
And added the SEO.Juice component to my post HEEx component:
<.layout>
<:head>
<SEO.juice conn={@conn} config={Website.SEO.config()} page_title={@post.title} />
</:head>
...
</.layout>
Generating preview images
While rapidly falling into the rabbit hole of SEO, I wanted to automatically
generate some nice-looking preview images for my posts. My approach is inspired by the
blog post Dynamic image generation with Elixir,
which uses the Image library.
I created a background image in Photoshop, and an svg file with the text that
should be rendered on top of the background image. I can then use the Image
library to render the svg file on top of the background image, and save the
result as a new image. The code for this is quite simple. I have an svg file
with placeholders for each line in the title eg. LINE_1, LINE_2, ... (svg
does not support wrapping text). We split the title of the post into chunks of
words that fit on a single line, and replace the placeholders in the svg file
with the chunks. We then render the svg file on top of the background image,
and save the result as a new image. The code for this is shown below:
@max_length 28
@tags ~w(LINE_1 LINE_2 LINE_3 LINE_4)
def generate_og_image(post) do
output_path = Path.join(@output_dir, ["assets/static/og-images/", post.id, ".jpg"])
File.mkdir_p!(Path.dirname(output_path))
text_overlay =
String.split(post.title, " ")
|> Enum.reduce([], &wrap_words/2)
|> Enum.reverse()
|> replace_headings()
|> Image.from_svg!()
base_image = Image.open!(Path.join(@content_dir, "assets/static/og-background.png"))
avatar_image =
Image.open!(Path.join(@content_dir, "assets/static/profile.jpeg"))
|> Image.avatar!(size: 65)
Image.compose!(base_image, avatar_image, x: 58, y: 516)
|> Image.compose!(text_overlay)
|> Image.write!(output_path)
String.trim_leading(output_path, @output_dir)
end
defp wrap_words(word, [curr | rest] = acc) do
concatinated = curr <> " " <> word
if String.length(concatinated) > @max_length do
[word | acc]
else
[concatinated | rest]
end
end
defp wrap_words(word, []), do: [word]
defp replace_headings(headings) do
{svg, _} =
Enum.reduce(@tags, {@svg, headings}, fn tag, {svg, headings} ->
case headings do
[] -> {String.replace(svg, tag, ""), []}
[head | rest] -> {String.replace(svg, tag, head), rest}
end
end)
svg
end
The path of the generated image is returned from the generate_og_image/1
function and is used in the SEO.OpenGraph.Build implementation for posts:
defimpl SEO.OpenGraph.Build, for: Website.Build.Posts.Post do
def build(post, _conn) do
SEO.OpenGraph.build(
...,
image: image(post)
)
end
defp image(post) do
path = Website.Images.generate_og_image(post)
SEO.OpenGraph.Image.build(
url: path,
width: 1200,
height: 630,
alt: post.title
)
end
end
This results in the following nice-looking preview image when sharing a link:

Conclusion
In the end, was it worth it to create my own SSG? I think so. I have learnt a lot
about how EEx and HEEx works, and I have created a website that I am happy
with. It is fast, easy to work with, and has all the features that I want it to
have. The best part is that if there is anything that I want to change, or a
feature that I want to add, I can just do it. The full source code for my website
is available on GitHub.
I hope that some of the ideas in this post and the source code for my website is useful or interesting. If you have any questions, comments or tips for improvement, feel free to reach out to me on Twitter.