I moved my blog from Hugo to a custom Elixir static site generator.

After running my blog with Hugo hosted on Netlify for nearly a decade, I decided to migrate to a custom solution. I think I resisted the urge to rewrite my personal site for long enough and I deserve to have some fun.
I am not going to find an excuse. I wanted to do this in Elixir. I wanted to build all the details of a static website from scratch once. It feels empowering to understand everything. And everything I learned along the way is generally useful Elixir knowledge. There are no concepts specific to a single static side generator.

With that out of the way, the reason why I am doing this now is that I plan to build more with Elixir in the near future and there will be more static sites too.
I also wanted to learn the details of generating SEO-friendly meta tags, getting a high pagespeed score and everything else involved in the progress.

<meta charset="utf-8" />
<title><%= @title %></title>
<meta name="description" content={@description} />
<meta name="author" content={Content.site_author()} />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="/index.xml" rel="alternate" type="application/rss+xml" title={Content.site_title()} />
<meta name="ROBOTS" content="INDEX, FOLLOW" />
<meta property="og:title" content={@title} />
<meta property="og:description" content={@description} />
<meta property="og:type" content={@og_type} />
<meta property="og:url" content={"#{Content.site_url()}#{@route}"}>
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content={@title} />
<meta name="twitter:description" content={@description} />
<meta itemprop="name" content={@title} />
<meta itemprop="description" content={@description} />
<%= if @og_type == "article" do %>
  <meta itemprop="datePublished" content={format_iso_date(@date)} />
  <meta itemprop="dateModified" content={format_iso_date(@date)} />
  <meta itemprop="wordCount" content={@wordcount} />
  <meta itemprop="keywords" content={Enum.join(@keywords, ",")} />
  <meta property="article:author" content={Content.site_author()} />
  <meta property="article:section" content="Software" />
  <meta :for={keyword <- @keywords} property="article:tag" content={keyword} />
  <meta property="article:published_time" content={format_iso_date(@date)} />
  <meta property="article:modified_time" content={format_iso_date(@date)} />
<% end %>

What the site does

You can find all the code on Github.
Elixir renders static HTML pages from Markdown and YAML content. Most pages are blog posts. There are additional pages such as the about page. And XML files are generated for the Sitemap and RSS feed.

def sitemap(pages) do
     xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
     "xmlns:xhtml": "http://www.w3.org/1999/xhtml"
     {:url, [{:loc, Content.site_url()}, {:lastmod, format_sitemap_date(DateTime.utc_now())}]}
     | for page <- pages do
          [{:loc, Content.site_url() <> page.route}, {:lastmod, format_sitemap_date(page.date)}]}
  |> XmlBuilder.document()
  |> XmlBuilder.generate()

How it works

I have to give a huge shout out Jason Stiebs’ post on the fly.io blog. I followed the tutorial to use NimblePublisher to read markdown files, render them to HTML using Phoenix LiveView HEEx templates and integrate Tailwind into the setup.
Having Tailwind available is something I really wanted. I plan to use it to simplify my hardly maintainable CSS.

I also have to thank Hugo again. Having the original output generated by Hugo helped me to learn about setting proper meta tags, rendering the sitemap and generating the RSS feed.

The setup includes a tiny Plug server to serve files during development and ExSync auto-compiles the site when files change.

Finally, the site is now deployed to Github pages using a Github action. This means one less separate tool to understand and maintain.

def build_pages() do
  pages = Content.all_pages()
  all_posts = Content.all_posts()
  about_page = Content.about_page()
  reads = Content.get_reads()
  render_file("index.html", Render.index(%{posts: Content.active_posts()}))
  render_file("404.html", Render.page(Content.not_found_page()))
  render_file(about_page.html_path, Render.page(about_page))
  render_file("archive/index.html", Render.archive(%{posts: all_posts}))
  render_file("reads/index.html", Render.reads_index(%{pages: reads}))
  write_file("index.xml", Render.rss(all_posts))
  write_file("sitemap.xml", Render.sitemap(pages))

Note that the code can be as simple as it is because it is catered to my use case. It doesn’t have to support thousands of pages or flexible content hierarchies. It only needs to do exactly what I want, nothing more, nothing less.