Publish Markdown Documents As Static Web Pages with Pandoc and Phoenix
Introduction
A short while ago, we published our latest version of a pandoc
installer Hex
package, modeled after the existing tailwind
and esbuild
packages. In this post, we will show how we used our pandoc
package to add our current markdown publishing solution to the Phoenix
Framework.
1.
Generate a new Phoenix project and add the pandoc
dependency
Let’s start with a new project. We will use the
--no-ecto
option because we don’t need a database for this
project.
$ mix phx.new hello --no-ecto
Next we add pandoc
as a dependency to our
mix.exs
file.
mix.exs
{:pandoc, "~> 0.3", only: :dev}
Then we fetch our dependencies.
$ mix deps.get
2. Configure pandoc
Because the goal is to have multiple documents, the name of the
output file will depend on the name of the input file. For this reason,
we cannot simply use a static value for the --output
option. To deal with this problem, pandoc
accepts a
function for the args
config key that allows us to set the
output filename dynamically for each document.
config/config.exs
if config_env() != :prod do
# Configure pandoc (the version is required)
:pandoc,
config version: "3.6.1",
hello: [
args: fn extra_args ->
{_, [input_file], _} = OptionParser.parse(extra_args, switches: [])
~w(--output=../priv/static/posts/#{Path.rootname(input_file)}.html)
end,
cd: Path.expand("../documents", __DIR__)
]
end
Because anonymous
functions are not supported in releases, we can wrap our config in a
if config_env() != :prod
conditional, since we’ll only
convert markdown to HTML at build time.
Next, we create the directory where our converted HTML documents will live.
$ mkdir priv/static/posts
And we add our new directory to .gitignore
so that Git
doesn’t save the HTML output of our documents.
.gitignore
# Ignore documents that are produced by pandoc.
/priv/static/posts/
Lastly, we add our pandoc
watcher to the endpoint so
that, in development, any changes to our documents’ markdown will
reflect in the browser in real-time.
config/dev.exs
:hello, HelloWeb.Endpoint,
config # ...
watchers: [
# ...
pandoc: {Pandoc, :watch, [:hello]}
]
To make sure everything is working, we can give it a quick test:
$ mkdir documents
$ echo "# hello" > documents/hello.md
$ mix pandoc hello hello.md
... [debug] Downloading pandoc from ...
$ cat priv/static/posts/hello.html
<h1 id="hello">hello</h1>
3. Add new document aliases
to mix.exs
Now that we have Pandoc installed, and a Mix task to convert a
document from markdown to HTML, we need a way to call it on all the
documents in the documents
directory. We can do this by
adding a new alias in mix.exs
that will scan the directory
for files and call the Pandoc Mix task on each.
mix.exs
defp aliases do
[
setup: [
"deps.get",
"assets.setup",
"assets.build",
"documents.setup",
"documents.build"
],
# ...
"documents.setup": ["pandoc.install --if-missing"],
"documents.build": &pandoc/1,
"statics.deploy": ["assets.deploy", "documents.build"]
]
end
defp pandoc(_) do
= Application.get_env(:pandoc, :hello)
config = config[:cd] || File.cwd!()
cd
cd|> File.cd!(fn ->
Enum.filter(File.ls!(), &(File.stat!(&1).type != :directory))
end)
|> Enum.each(&Mix.Task.rerun("pandoc", ["hello", &1]))
end
Now when we run mix setup
, Pandoc will convert all the
files in our documents directory to markup and place the output in
priv/static/posts
.
We also added a statics.deploy
alias so we’ll only have
to run a single task before we build a release.
4. Add context, controller, templates, and routes
Now that we have our documents in priv/static/posts
in
HTML format, we need a way to render them.
We start with a struct that will hold the values for each post.
lib/hello/documents/post.ex
defmodule Hello.Documents.Post do
defstruct [:id, :path, :body]
end
Next, we add our Documents context that will be responsible for fetching a list of all posts as well as each individual post.
We have chosen to name our documents using hyphens (-
)
to separate words. Our post ids, then, will be the document filename
minus the file extension suffix. So, a file
documents/this-is-the-first-post.md
will have an id of
this-is-the-first-post
and a URI that looks like
https://example.org/posts/this-is-the-first-post
.
lib/hello/documents.ex
defmodule Hello.Documents do
@moduledoc """
The Documents context.
"""
alias Hello.Documents.Post
def list_posts do
"documents/*"
|> Path.wildcard()
|> Enum.map(fn path ->
Post{
%id: path |> Path.rootname() |> Path.basename(),
path: path
}
end)
end
def get_post!(id) do
= Enum.find(list_posts(), fn post -> post.id == id end)
post
=
body :hello
|> :code.priv_dir()
|> Path.join("static/posts/#{id}.html")
|> File.read!()
{post | body: body}
%end
end
Our posts controller and view are pretty standard.
lib/hello_web/controllers/post_controller.ex
defmodule HelloWeb.PostController do
use HelloWeb, :controller
alias Hello.Documents
def index(conn, _params) do
= Documents.list_posts()
posts (conn, :index, posts: posts)
renderend
def show(conn, %{"id" => id}) do
= Documents.get_post!(id)
post (conn, :show, post: post)
renderend
end
lib/hello_web/controllers/post_html.ex
defmodule HelloWeb.PostHTML do
use HelloWeb, :html
"post_html/*"
embed_templates end
Our index
and show
templates are pretty
similar to what the Phoenix phx.gen.html
HTML generator
spits out. We take full advantage of the UI core components that Phoenix
provides.
lib/hello_web/controllers/post_html/index.html.heex
<.header>
Listing Posts
</.header>
<.table id="posts" rows={@posts} row_click={&JS.navigate(~p"/posts/#{&1}")}>
<:col :let={post} label="id"><%= post.id %></:col>
<:action :let={post}>
<div class="sr-only">
<.link navigate={~p"/posts/#{post}"}>Show</.link>
</div>
</:action>
</.table>
lib/hello_web/controllers/post_html/show.html.heex
<.header>
<%= @post.id %>
<:subtitle>This is a post from your markdown documents.</:subtitle>
</.header>
<%= raw(@post.body) %>
<.back navigate={~p"/posts"}>Back to posts</.back>
Finally, we add the routes we will need, with only the
index
and show
actions being necessary.
lib/hello_web/router.ex
"/", HelloWeb do
scope :browser
pipe_through
"/posts", PostController, only: [:index, :show]
resources
"/", PageController, :home
get end
Let’s create a couple of documents to see how our app renders them.
$ touch documents/{hello-there.md,welcome-to-our-demo-app.md}
And now we visit localhost:4000/posts
.

Let’s add some markdown content to
documents/hello-there.md
so we can see how the
show
template looks.
documents/hello-there.md
# hello
This is a paragraph.
This is a code block.
> This is a block quote.
## this is a heading
- this is
- a list
- of items
When we visit localhost:4000/posts/hello-there
, it looks
like this:

Our document’s content is visible, but it’s missing any styling. We
will fix this by adding the typography
plugin to Tailwind’s
config file.
5. Style the markdown content
Let’s add @tailwindcss/typography
to the plugins list in
tailwind.config.js
.
assets/tailwind.config.js
: [
plugins// ...
require("@tailwindcss/typography")
]
Then we’ll add Tailwind’s prose
class to our post’s HTML
content.
lib/hello_web/controllers/post_html/show.html.heex
<div class="prose">
<%= raw(@post.body) %>
</div>
After restarting our app, we can visit the post show
page again, and this time our content is styled appropriately.

6. Auto-reload the browser when markdown content changes
Since our pandoc
file-watcher converts documents to HTML
whenever changes are detected, all we need to do to have the changes
update in our browser in real-time is add the static files path to the
live_reload
config in config/dev.exs
.
Caveat: Long Documents
If the documents we are editing become long, there are two issues that may arise.
If the page reloads, but our document content is missing, that means the live-reloader reloaded the page before the document was finished being converted. To address this, we can use the
interval
option to set a lengh of time greater than the100
ms default value.If our terminal is being flooded with too many
[debug] Live reload...
messages, we can use thedebounce
option to set a delay before a reload event is sent to the browser.
config/dev.exs
:hello, HelloWeb.Endpoint,
config live_reload: [
interval: 1000,
debounce: 200,
patterns: [
# ...
~r"priv/static/posts/.*(html)$"
]
]
Now we can edit the markdown in our documents and, as soon as we save our changes, the new content should appear in our browser.
7. Handle draft documents
It would be very helpful if we had a place to keep draft documents
that are visible to us during development but absent in a production
release. With a few changes to lib/hello/documents.ex
we
can do just that.
First, let’s make a directory for draft documents.
$ mkdir documents/_drafts
So our published documents will live in the top-level
documents
directory, and our draft documents will live in
the subdirectory documents/_drafts
.
The first change to the list_posts/1
function in
documents.ex
adds a conditional depending on
Mix.env()
for what directories to scan. In production it
will only scan documents/*
for files, while in development
it will scan all subdirectories with documents/**/*
.
The second change required to list_posts/1
is to filter
out directories, since Path.wildcard/1
will include the
directory _drafts
in with the list with files.
lib/hello/documents.ex
def list_posts do
"documents"
|> Path.join(if(Mix.env() != :prod, do: "**/*", else: "*"))
|> Path.wildcard()
|> Enum.filter(&(File.stat!(&1).type != :directory))
|> Enum.map(fn path ->
# ...
Now we just need to update the get_post!/1
function. In
order to make sure that we never accidentally publish draft documents,
we will convert them on the fly as necessary and will store their
content in memory rather that writing to disk. Since the
pandoc
profile default
outputs conversion
results to stdout
, we can use that profile instead of
hello
when running the conversion and simply capture the
results with ExUnit.CaptureIO.capture_io/1
.
lib/hello/documents.ex
def get_post!(id) do
# ...
=
body if "_drafts" in Path.split(post.path) do
ExUnit.CaptureIO.capture_io(fn ->
Mix.Task.rerun("pandoc", ["default", post.path])
end)
else
:hello
|> :code.priv_dir()
|> Path.join("static/posts/#{id}.html")
|> File.read!()
end
# ...
end
Lastly, since we are not writing draft documents to disk, we need to add another pattern for Phoenix’s LiveReloader to reload the browser when draft content changes.
config/dev.exs
:hello, HelloWeb.Endpoint,
config live_reload: [
patterns: [
# ...
~r"priv/static/posts/.*(html)$",
~r"documents/_drafts/.*(md)$"
]
]
8. Release
When we next release our application, we simply need to run
mix statics.deploy
to build assets and documents first, and
then run the release command.
$ mix statics.deploy
$ MIX_ENV=prod mix release
Conclusion
That’s it! This solution allows us to write our posts in simple markdown and see them converted to HTML automatically with Phoenix and Pandoc. Furthermore, we are able to use Phoenix’s powerful template language HEEx to make writing HTML faster and easier, so we can focus more on content and less on development.
We hope this post was as useful to others as it has been to us. We are always appreciative of any feed back readers would like to share with us. Thanks for reading and happy coding!