Static Markdown blog posts with Elixir Phoenix
I recently decided to code a new simple website for myself (you’re looking at it). My old website was a simple static generated website, which is awesome for a lot of purposes. But I wanted my website to be more of a real application that I can use for more than simple blog posts. So I decided to implement it in Elixir using the Phoenix framework.
I still wanted to be able to write my blog posts using simple Markdown documents that I version control in Git. I prefer using static Markdown documents over content loaded from a database since I like using my normal code editor to write in, I get version control for free and I don’t have to think about an admin panel.
In this post I’m gonna share how I implemented a very simple static blog engine in Elixir with Markdown support.
What we want
Let’s start by thinking about what goals we have for our blog engine.
I want each post to be a single Markdown file with Frontmatter in YAML. Example:
---
title: Markdown for blog posts is nice, says Sebastian
date: 2016-05-01
intro: Read about why Markdown is healthy for you.
---
All the **Markdown** here...
We can put the Markdown files in the priv/posts
folder. The name of each file will also be the slug on the website. Example: priv/posts/my-blog-post.md
results in a blog post at http://example.com/my-blog-post
.
If a post needs to refer to static assets, such as images, we can put them in web/static/assets/images
and let Phoenix serve them.
We want every request to be as fast as possible, so we only want to render the Markdown files once. In other words we need to keep the compiled posts in memory somehow.
Alright, now let’s code some Elixir!
Create a new Phoenix application
We’ll start by firing up a new Phoenix app. We won’t use Ecto for simplicity.
mix phoenix.new static_blog --no-ecto
We’re going to need a few dependencies:
- yamerl: An Erlang YAML parser.
- Earmark: An Elixir Markdown parser.
- Timex: An Elixir date/time library.
Add the following lines to your mix.exs
’s deps
function:
defp deps do
[
# ...
{:earmark, "~> 0.2.0"},
{:timex, "~> 2.1.4"},
{:yamerl, github: "yakaz/yamerl"}]
end
Now run mix deps.get
from your Terminal.
Also add :timex
and :yamerl
to your applications
in mix.exs
:
def application do
[mod: {StaticBlog, []},
applications: [:phoenix, :phoenix_html, :cowboy, :logger, :gettext,
:timex, :yamerl]]
end
We should also add at least two posts so we have some content to work on. See the two posts I made for example.
Write a module that compiles a single post
We’re going to define a module named StaticBlog.Post
, which:
- Defines a struct that represents a blog post.
- Defines a
compile/1
function that takes the name of a Markdown file and returns aPost
struct.
Let’s start by defining the struct:
defmodule StaticBlog.Post do
defstruct slug: "", title: "", date: "", intro: "", content: ""
end
Next we’ll define the compile/1
function:
defmodule StaticBlog.Post do
# ...
def compile(file) do
post = %StaticBlog.Post{
slug: file_to_slug(file)
}
Path.join(["priv/posts", file])
|> File.read!
|> split
|> extract(post)
end
end
It takes a file
argument, which could be fx my-blog-post.md
. It then creates a new Post
struct and sets the slug
key using a file_to_slug/1
function, which we’ll define next.
Finally it builds the full path to the file using Path.join/1
(results in fx priv/posts/my-blog-post.md
), which is piped to File.read!/1
. The content of the file is then piped to split/1
, whose result is again piped to extract/2
. We’ll define split/1
and extract/2
momentarily.
extract/2
takes the post
as its second argument and will also return a Post
struct, which means our compile/1
function will return the final Post
value. Remember that Elixir has implicit returns, so the result of the last executed statement is automatically returned.
The file_to_slug/1
function is super simple:
defmodule StaticBlog.Post do
# ...
defp file_to_slug(file) do
String.replace(file, ~r/\.md$/, "")
end
end
Could we have avoided creating this function and put it directly in place of file_to_slug(file)
inside compile/1
? Yes we could, but it’s much nicer to extract it into its own function. It makes the intention crystal clear vs. an odd String.replace
call.
split/1
takes file data and returns a tuple of parsed Frontmatter and compiled Markdown. It looks like this:
defmodule StaticBlog.Post do
# ...
defp split(data) do
[frontmatter, markdown] = String.split(data, ~r/\n-{3,}\n/, parts: 2)
{parse_yaml(frontmatter), Earmark.to_html(markdown)}
end
end
It uses String.split/3
to split the file data string in two parts (note the parts: 2
, which limits to only splitting once = two parts). The regex ~r/\n-{3,}\n/
looks for the first line that consists of min. 3 dashes and has a newline before and after it. We then pattern match to get the frontmatter
and the markdown
into two separate variables. We parse both using parse_yaml/1
and Earmark.to_html/1
respectively and return a tuple with the two elements.
parse_yaml/1
is a little helper function. Yamerl’s :yamerl_constr.string/1
returns a list, where we’re only interested in the first element:
defmodule StaticBlog.Post do
# ...
defp parse_yaml(yaml) do
[parsed] = :yamerl_constr.string(yaml)
parsed
end
end
Now we can define extract/2
. It takes a tuple with parsed Frontmatter properties and the parsed markdown (now HTML) and the Post
that it should populate with the remaining values.
defmodule StaticBlog.Post do
# ...
defp extract({props, content}, post) do
%{post |
title: get_prop(props, "title"),
date: Timex.parse!(get_prop(props, "date"), "{ISOdate}"),
intro: get_prop(props, "intro"),
content: content}
end
end
We simply return a new Post
struct that inherits the given post
and adds the title
, date
, intro
and content
attributes.
Note that the result from :yamerl_constr.string/1
we got earlier, props
, is an Erlang property list, which are a little awkward to work with in Elixir. So we define the function get_prop/2
that helps us:
defmodule StaticBlog.Post do
# ...
defp get_prop(props, key) do
case :proplists.get_value(String.to_char_list(key), props) do
:undefined -> nil
x -> to_string(x)
end
end
end
It uses :proplists.get_value
, which expects the key to be a char list. It will return the :undefined
atom if the given key is not defined - we prefer a nil
. And if the key does exist it returns a char list, which we convert to a string.
See complete StaticBlog.Post code.
We’re now ready to try out our post compiler. Fire up iex
and run:
$ iex -S mix
iex(1)> StaticBlog.Post.compile("first-post.md")
%StaticBlog.Post{content: "<p>This post is written in <strong>Markdown</strong>.</p>\n<h2>A header2 is good</h2>\n<p>Lists are nice, too:</p>\n<ul>\n<li>Apples\n</li>\n<li>Bananas\n</li>\n<li>Pears\n</li>\n</ul>\n",
date: #<DateTime(2016-04-24T00:00:00Z)>,
intro: "The first post is about one topic.", slug: "first-post",
title: "First post"}
Great success! Next step is to write a crawler that will discover all our posts and compile them all.
Crawling posts
We will define another module, StaticBlog.Crawler
, which defines a crawl/0
function that finds all our blog posts and returns a list of Post
structs. It’s remarkably simple:
defmodule StaticBlog.Crawler do
def crawl do
File.ls!("priv/posts")
|> Enum.map(&StaticBlog.Post.compile/1)
|> Enum.sort(&sort/2)
end
def sort(a, b) do
Timex.compare(a.date, b.date) > 0
end
end
It lists all files in our priv/posts
folder, compiles each file using StaticBlog.Post.compile/1
and sorts them chronologically (newest first).
Let’s try it out. Back to iex
(remember to restart iex
between editing code in lib
):
$ iex -S mix
iex(1)> StaticBlog.Crawler.crawl
[%StaticBlog.Post{content: "<p>This post is also written in <strong>Markdown</strong>.</p>\n<p>A link: <a href=\"http://www.sebastianseilund.com\">Sebastian Seilund</a></p>\n<p>![phoenix][/images/phoenix.png]</p>\n",
date: #<DateTime(2016-05-02T00:00:00Z)>,
intro: "The second post is about another topic.", slug: "second-post",
title: "Second post"},
%StaticBlog.Post{content: "<p>This post is written in <strong>Markdown</strong>.</p>\n<h2>A header2 is good</h2>\n<p>Lists are nice, too:</p>\n<ul>\n<li>Apples\n</li>\n<li>Bananas\n</li>\n<li>Pears\n</li>\n</ul>\n",
date: #<DateTime(2016-04-24T00:00:00Z)>,
intro: "The first post is about one topic.", slug: "first-post",
title: "First post"}]
Sweet! A list with two blog posts. Who would have known?
Building a repository using GenServer
Okay, so we now have a way to get a list of all our posts. But one of our initial goals was to only compile the posts once. We need something in our application that can run StaticBlog.Crawler.crawl/0
once, hold on to the returned list and respond to fx get_by_slug/1
and list/0
calls.
This sounds like a good fit for OTP’s GenServer. I’m not gonna go into depth with GenServer here since there are many good resources available for that - see this Elixir Getting Started guide fx. Basically our GenServer will be a part of our application’s supervision tree and it will hold the list of posts and respond to get/list calls.
Let’s call our module StaticBlog.Repo
. We start by use
ing GenServer and implementing the start_link/0
function so our application knows how to start it:
defmodule StaticBlog.Repo do
use GenServer
def start_link do
GenServer.start_link(__MODULE__, :ok, [name: __MODULE__])
end
def init(:ok) do
posts = StaticBlog.Crawler.crawl
{:ok, posts}
end
end
We use name: __MODULE__
since there will ever only be one StaticBlog.Repo
process running in our system. We don’t care about redundancy at this point, plus if the process were to crash, the OTP supervisor would bring up a new one right away.
We also implemented the init/1
function that OTP calls to initialize the state of our repo. init/1
accepts an :ok
atom, since that’s what supplied as the second argument to GenServer.start_link/3
. It doesn’t really matter in our example what we use here. In the function body we call StaticBlog.Crawler.crawl
and return with a tuple of :ok
and the list of posts. This is how we signal to OTP that our GenServer started properly and to tell it what the current state is. Our state is always our list of posts - it never changes while the application is running (we don’t have any live-reload functionality yet).
Other parts of our application should be able to query our blog posts. We will settle on supporting a get_by_slug/1
method to get a single post by it’s URL slug, and list/0
which returns all posts. We add the following functions to our module:
defmodule StaticBlog.Repo do
# ...
def get_by_slug(slug) do
GenServer.call(__MODULE__, {:get_by_slug, slug})
end
def list() do
GenServer.call(__MODULE__, {:list})
end
def handle_call({:get_by_slug, slug}, _from, posts) do
case Enum.find(posts, fn(x) -> x.slug == slug end) do
nil -> {:reply, :not_found, posts}
post -> {:reply, {:ok, post}, posts}
end
end
def handle_call({:list}, _from, posts) do
{:reply, {:ok, posts}, posts}
end
end
The two first functions, get_by_slug/1
and list/0
, simply call
s our repo process with the given request. The first argument, __MODULE__
is what we named our process in start_link/0
. The second argument represents what the caller asked for, i.e. either {:list}
or {:get_by_slug, slug}
.
We then implement handle_call
for each of the two types of messages. The first one replies {:ok, post}
if a post with the given slug was found, and :not_found
otherwise. The second one always replies with all the posts. Both of them returns the posts
list as the third return tuple element, which means that we keep using the same list of posts.
See complete StaticBlog.Repo code.
Now we just have to add our StaticBlog.Repo
to our application’s supervision tree.
Open lib/static_blog.ex
and add a worker to the children
list. Like this:
def start(_type, _args) do
import Supervisor.Spec, warn: false
children = [
supervisor(StaticBlog.Endpoint, []),
worker(StaticBlog.Repo, []), # <--- THIS IS THE LINE WE ADDED
]
opts = [strategy: :one_for_one, name: StaticBlog.Supervisor]
Supervisor.start_link(children, opts)
end
Let’s try it out. Back to iex
:
$ iex -S mix
iex(1)> StaticBlog.Repo.get_by_slug("first-post")
{:ok,
%StaticBlog.Post{...content omitted...}
iex(2)> StaticBlog.Repo.list()
{:ok, ...content omitted...}
Yay, it worked. Just for the fun of it, let’s try to manually kill the process to make sure that we configured our supervision tree correctly:
$ iex -S mix
iex(1)> Process.whereis(StaticBlog.Repo) |> Process.exit(:kill)
true
iex(2)> StaticBlog.Repo.list()
{:ok, ...content omitted...}
It quickly recovered automatically. Isn’t Elixir/Erlang just amazing?
Okay, all we have left now is to actually serve some blog posts.
Show post on website
First we add a route handler to StaticBlog.Router
(web/router.ex
):
get "/:slug", PostController, :show
Make sure to put this line after any other routes you may already have as it will catch all requests at the first directory level.
We add our controller to web/controllers/post_controller.ex
:
defmodule StaticBlog.PostController do
use StaticBlog.Web, :controller
def show(conn, %{"slug" => slug}) do<
case StaticBlog.Repo.get_by_slug(slug) do
{:ok, post} -> render conn, "show.html", post: post
:not_found -> not_found(conn)
end
end
def not_found(conn) do
conn
|> put_status(:not_found)
|> render(StaticBlog.ErrorView, "404.html")
end
end
It takes the slug from the params, queries it from the repo and renders the show.html
template if found, or responds with a 404 page if it was not found.
We also need to implement a view, which does nothing. Add web/views/post_view.ex
:
defmodule StaticBlog.PostView do
use StaticBlog.Web, :view
end
Finally we add our HTML template to web/templates/post/show.html.eex
:
<p><a href="<%= page_path(@conn, :index) %>">« Back to index</a></p>
<article>
<h1><%= @post.title %></h1>
<p><%= Timex.format!(@post.date, "{Mshort} {D} {YYYY}") %></p>
<%= raw(@post.content) %>
</article>
It links back to the index page and then renders the title, date and HTML content of the post. Notice how we use the raw/1
function. This is because @post.content
is already rendered HTML, and we don’t want Phoenix to escape it.
Now start Phoenix with:
mix phoenix.server
Go to http://localhost:4000/first-post or http://localhost:4000/second-post. The posts should appear. And if you go to http://localhost:4000/third-post you should see the standard 404 page.
Listing posts on the frontpage (last step)
Since we already have a frontpage (index), all we have to do is supply the list of posts to the index template and render some HTML.
Change the index/2
function in web/controllers/page_controller.ex
to this:
def index(conn, _params) do
{:ok, posts} = StaticBlog.Repo.list()
render conn, "index.html", posts: posts
end
And then replace web/templates/page/index.html.eex
with this:
<%= for post <- @posts do %>
<article class="row">
<h2><%= post.title %></h2>
<p><%= Timex.format!(post.date, "{Mshort} {D} {YYYY}") %></p>
<p><%= post.intro %></p>
<p><a href="<%= post_path(@conn, :show, post.slug) %>">Read post</a></p>
</article>
<% end %>
We loop over each post and render its title, date, intro and a link to the post itself. Phoenix automatically makes a post_path/3
helper function for us, which makes linking easy.
Start Phoenix again with mix phoenix.server
and go to http://localhost:4000/ to see a list of all your blog posts.
Conclusion
We now have a simple way for our Markdown blog posts to live in our existing Phoenix application. Adding a new blog post is as simple as adding another Markdown file. The posts are only rendered once, and are served at sub-millisecond speed by Elixir. Exactly what we wanted.
You could extend the system further, such as add pagination, a search feature, tags etc. All you would need to do is adjust the StaticBlog.Post
struct, add support for new query methods in StaticBlog.Repo
and adjust your routes/templates.
You may think this solution is a little overkill for a simple blog. But then again. It’s not that much code. And now you have complete control over it and don’t have to deal with a beast such as Wordpress. I sure like it. Plus now I can use my website for other fun things because of Phoenix.
The complete source code is available on GitHub. If you want to deploy your code in a very easy way, the Phoenix Heroku guide is very helpful.
If you have any comments, I’d love to hear from you.