Creating a Blog with Phoenix
I've gone through the motions of making a static site website. My first attempt was a Gatsbyjs site on Netlify. I love both of those but I quickly got bored with all the javascript :)
After that, I made a static site that looks much like the one you're on right now with Hugo. Hugo was fast, and Go is fun but to be honest I liked the Gatsby experience way more. Ultimately, I wanted to do something different.
I've been enamored by elixir and phoenix for a while now and so when José Valim wrote up a post on Dashbit's new blog I knew I had to try something similar.
Fair warning, this was very much inspired by both José and Saša Jurić's site, you'll most likely learn a lot more by reading their posts and source code than reading this.
Nonetheless, I wanted to put this together as an exercise and to show that Phoenix and Elixir, without a database, is a viable choice for a blog.
Getting Started
First, if you haven't installed Elixir yet head to the installation instructions and do that first.
Now, open up a terminal and follow along :)
We're going to start by making sure we have Phoenix installed: (1.5.1 is the newest version as of this, but go with the latest and greatest)
$ mix archive.install hex phx_new
Now, let's create a new Phoenix project without ecto (since we won't be needing a database)
$ mix phx.new example_site --no-ecto
This command uses
mix
to invoke the phx_new
application we just installed, creating a new phoenix
application named example_site.
When prompted to Fetch and install dependencies?
press Y
Let's go into the directory and get to coding.
$ cd example_site
Blog Posts
The first thing we're going to need is a way to represent our Blog posts as an Elixir struct. Structs are one of the most important types of data structure in elixir and one you'll use a lot. In our case, we need a way to go from a markdown file to a struct that elixir can understand.
Before we get there, let's talk a little bit about structure. Elixir
applications tend to follow the same type of structure, with a top level
directory that holds some files about the program itself (like mix.exs
and
README.md
). The most important folder here tends to be the lib
folder
where our application lives.
If you go into our lib folder you'll see two folders:
$ cd lib
$ ls
example_site/ example_site.ex example_site_web/ example_site_web.ex
The most important thing to note here is the division between example_site/
and
example_site_web/
. Elixir programmers like to use this concept of contexts to manage
their code and Phoenix is no different. You typically put your contexts into
your app/
folder, in our case example_site/
and leave example_site_web/
for things that are specifically related to the web application portion.
To summarize:
-
example_site/
<- Your elixir application -
example_site_web/
<- Your phoenix application
Let's go into example_site/
and add the modules that will be responsible for our
blog and blog posts.
$ cd examlpe_site/
$ touch blog.ex
$ mkdir blog
$ touch blog/post.ex
In this case, lib/example_site/blog.ex
will be our context and
lib/example_site/blog/post.ex
our Post module. Let's start by creating our
Post
model. Open up post.ex
in your favorite editor and let's build this
up step by step.
If you want to see the completed file, you can search for post.ex
and scroll
past all the gifs to see the final version. I have way too much fun making
these gifs so I'm going to explain it bit by bit :)
The first thing we're going to do is to define our struct using the defstruct
macro. We're also going to use @enforce_keys to ensure that all keys are present
when we try to create a new struct. As a shorthand we're just passing the
enforced keys straight into defstruct
since we want all of our keys present.
Now, we're going to build up the rest of the file by starting from the end of
our processing pipeline. We're going to eventually have a function called
parse!/1
that takes a filename, parses the contents and returns our %Post{}
struct.
That flow will look like this, in semi-pseudo code (hopefully this makes sense):
File -> parse() -> parse_contents(File.read) -> parse_content_part()
In Elixir, as with many functional languages it makes sense to start at the end
of that flow. If we wrote our parse!/1
function now, our compiler would get
angry since we'll try to call a non-existent parse_contents/1
function etc...
So, let's parse our content blocks. Our markdown files will look something like this:
==title==
Awesome Blogpost
==description==
The best blog post there ever was
==tags==
old_news, science
==date==
2020-05-11
==body==
## Here comes the markdown!
Look at me, I'm a [link](https://exercism.io).
The first thing we need to do is take those sections ==title==
etc, and their
content Awesome Blogpost
and transform them to our elixir struct:
%{
title: "Awesome Blogpost"
...
}
For each section
, we'll have a function that parses that attr that takes
an atom (the attr), and it's value. Like so:
That takes care of most of the sections, but notice that we left out body
.
The body
is a little special since we need to parse the markdown syntax within
and output html. Time to add some depenencies. And while we're at it, we might
as well add another dependency we're going to use later to generate Slugs.
Open up mix.exs
at the root of your project and add
earmarkand
slugify to your dependencies:
...
defp deps do
[
{:phoenix, "~> 1.5.1"},
{:phoenix_html, "~> 2.11"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_dashboard, "~> 0.2.0"},
{:telemetry_metrics, "~> 0.4"},
{:telemetry_poller, "~> 0.4"},
{:gettext, "~> 0.11"},
{:jason, "~> 1.0"},
{:plug_cowboy, "~> 2.0"},
# Markdown Support
{:earmark, "~> 1.3"},
# To Generate Slugs
{:slugify, "~> 1.3.0"}
]
end
...
Now, back in our terminal let's download our dependencies and compile the application:
$ mix do deps.get, deps.compile
$ mix phx.server
Ok, let's open up lib/example_site/blog/post.ex
again and add the parsing
function for our body
. Note that we add it above the function we just added
so Elixir can do it's pattern matching magic.
We're getting closer! Time to put these functions into use:
That's a good amount of code all at once, let's break it down a little. We take the entire contents of a file, split it up into it's distinct parts with that regex, and then using a for comprehension we parse that specific attribute according to it's rules (using the functions we wrote just before this).
Now, let's write the final function in this file, the one that actually parses a file given it's filename and puts it all together.
That's it! Our final lib/example_site/blog/post.ex
:
defmodule ExampleSite.Blog.Post do
@enforce_keys [:title, :slug, :body, :description, :tags, :date]
defstruct @enforce_keys
def parse!(filename) do
contents = parse_contents(File.read!(filename))
slug =
Keyword.get(contents, :title)
|> Slug.slugify()
struct!(__MODULE__, contents ++ [slug: slug])
end
defp parse_contents(contents) do
parts =
Regex.split(~r/^==(\w+)==\n/m, contents,
include_captures: true,
trim: true
)
for [attr_with_equals, value] <- Enum.chunk_every(parts, 2) do
[_, attr, _] = String.split(attr_with_equals, "==")
attr = String.to_atom(attr)
{attr, parse_attr(attr, value)}
end
end
defp parse_attr(:body, value),
do:
value
|> Earmark.as_html!(%Earmark.Options{
smartypants: false,
code_class_prefix: "language-"
})
defp parse_attr(attr, value)
when attr in [:title, :description, :tags, :date],
do: String.trim(value)
end
Our Blog Context
It's worth taking a breath here and taking stock of what we have. While we're
pretty far away from our finished blog, we have a working model of what our
Post
s will look like, and that's a great start. Philosophically, we're
taking the approach of building up the application first here, meaning the
functionality of it all, and then adding form (styling etc later).
A Context
The job of our Blog
context is going to be to create the main API our web
application interfaces with when grabbing our posts to display them to our
visitors. It's going to have a couple of important functions, the most prominent
being all_posts
and get_post_by_slug/1
which we'll use to grab a specific
blog post when a visitor navigates to /posts/:slug
.
Oh, one more thing. Our actual posts are going to live in a folder called blog
at the root of our project. Let's make that now.
$ ls
_build/ config/ lib/ mix.lock README.md
assets/ deps/ mix.exs priv/ test/
$ mkdir blog
$ touch blog/our_first_post.md
$ touch blog/example_site/blog.ex
Open up blog/our_first_post.md
and add the following content:
==title==
Our first post!
==description==
Our very first blog post. Look at us!!
==tags==
learning, fun, awesomeness, cheesy_tags
==date==
2020-05-11
==body==
## A Post
There once was a post that knew a tree, the tree said to the post, what's
cracking post? And the post said: nothing, I'm made of steel.
Ok now that we have that, let's venture into our Blog
context. First of all,
let's make sure our dependent application is started and then locate our posts.
Open up lib/example_site/blog.ex
:
We have something here! I like to get something tangible as quickly as possible when I write code, it helps me verify that what I have is working and build up a better mental model of the code in my head. For that reason, let's take this for a spin in elixir's interactive repl:
That's our post! Shit like this gets me so excited everytime. Ok let's try to
give that post to our parse!/1
function!
It's alive!!
Now let's build up our context api, all_posts
, and get_post_by_slug
:
Once again, let's test it out in iex!
Here's our finished lib/example_site/blog.ex
:
defmodule ExampleSite.Blog do
alias ExampleSite.Blog.Post
Application.ensure_all_started(:earmark)
posts_paths = "blog/**/*.md" |> Path.wildcard() |> Enum.sort()
posts =
for post_path <- posts_paths do
@external_resource Path.relative_to_cwd(post_path)
Post.parse!(post_path)
end
@posts posts
def all_posts do
@posts
end
def get_post_by_slug(slug) do
all_posts()
|> Enum.find(&(&1.slug == slug))
end
end
Ok that's basically it for the creating a blog part, but it would be no fun if we stopped here so let's make this an actual blog with links and a frontpage :)
... wip