Why Org-mode?
I have been writing in Org-mode for years. My notes, my tasks, my journals — all of it lives in plain .org files on disk. When I decided to start a blog built on Phoenix, I did not want to switch to Markdown just because it is the default.
The good news: with Pandoc and NimblePublisher, you do not have to.
The Pipeline
The conversion happens at compile time, not at runtime. This is the key insight that makes the whole thing practical.
defmodule OnemargaroBlog.Post do
def build(filename, attrs, body) do
html_body =
case Path.extname(filename) do
".org" -> OnemargaroBlog.OrgConverter.to_html!(body)
_ -> body
end
%__MODULE__{
slug: extract_slug(filename),
body: html_body,
title: attrs["title"],
date: parse_date(filename)
}
end
end
When you run mix phx.server, NimblePublisher walks priv/posts/, calls Post.build/3 for every file it finds, and stores the results in a module attribute. Zero database queries at runtime.
Setting Up Pandoc
Pandoc is a system binary, not an Elixir library. You call it with System.cmd/3.
defmodule OnemargaroBlog.OrgConverter do
@flags ~w[-f org -t html5 --no-highlight --wrap=none]
def to_html!(content) do
case System.cmd("pandoc", @flags, input: content, stderr_to_stdout: true) do
{html, 0} -> String.trim(html)
{err, _} -> raise "Pandoc failed: #{err}"
end
end
end
Install it once per environment:
# macOS
brew install pandoc
# Ubuntu / Debian
sudo apt install pandoc
# Dockerfile (build stage only — not needed at runtime)
RUN apt-get install -y pandoc
Org Syntax You Can Use
Headings
Org headings use asterisks. The number of asterisks sets the level.
* H1
** H2
*** H3
Pandoc maps these to <h1>, <h2>, <h3> in the HTML output.
Inline markup
| Org syntax | Result |
|---|---|
*bold* |
bold |
/italic/ |
italic |
~code~ |
code |
+strikethrough+ |
|
[[url][label]] |
hyperlink |
Code blocks
Use #+BEGIN_SRC with the language name for syntax-highlighted blocks:
defmodule Hello do
def world, do: IO.puts("Hello, world!")
end
mix phx.server
Blockquotes
The purpose of a programming language is to let you express ideas clearly. If you cannot say something clearly, you probably do not understand it yet.
Lists
Unordered:
- NimblePublisher handles the file pipeline
- Earmark parses Markdown
- Pandoc converts Org-mode
- Phoenix serves everything
Ordered:
- Create a
.orgfile inpriv/posts/ - Add the Elixir frontmatter block at the top
- Write the body in Org syntax
- Restart the server — the post appears
Tables
Org tables use | for columns and - for separators. Pandoc renders them as proper HTML <table> elements.
| Feature | Markdown | Org-mode |
|---|---|---|
| Tables | Limited | Native |
| Code blocks | Fenced | BEGINSRC |
| Task lists | GitHub only | Native |
| Links | [text](url) |
[[url][text]] |
| Export targets | HTML | HTML, PDF, LaTeX, ODT |
Frontmatter Format
NimblePublisher reads the block between the first %{ and --- as Elixir map syntax, not YAML. This is important — it means the values are native Elixir terms.
%{
title: "My Post Title",
description: "A short summary shown in post cards.",
tags: ["elixir", "phoenix"]
}
---
The filename drives the date and slug automatically:
2024-06-15-building-with-phoenix.org
│ │
date slug → /blog/building-with-phoenix
Limitations
Pandoc does not produce Phoenix-aware HTML. A few things to keep in mind:
- Verified routes (
\~p"/blog/slug") do not work inside Org files. Use plainhttps://URLs when linking to other posts. - Syntax highlighting — pass
--no-highlightto Pandoc and letmakeup_elixirhandle it via NimblePublisher's highlighter pipeline. - Images — reference them as
/images/filename.pngand place the files inpriv/static/images/.
Summary
The full pipeline is three steps:
- Write
.orgfiles inpriv/posts/ OrgConverter.to_html!/1calls Pandoc at compile time- Phoenix serves the resulting HTML — no Pandoc needed in production
The same .post-card and .prose CSS classes from blog.css style everything, whether the source was Markdown or Org.