Compile-time Markdown in Phoenix
Parsing every post at build time removes a whole category of problem: no database, no cache, no cold render.
There is a version of this problem that everyone solves badly at least once: you want a blog, you have Markdown files, and you reach for a database. Then you have a cache. Then you have a cache invalidation bug at eleven at night.
The alternative is older and much duller. Read the files while compiling, turn them into structs, and let the compiler hold them. A request becomes a lookup in a module attribute.
Publishing a post is a commit. The build is the CMS.
The converter
NimblePublisher handles the file-to-struct half. MDEx handles Markdown-to-HTML, and hands us an AST we can rewrite before rendering.
defmodule Tb.Blog.Converter do
def convert(_path, body, _attrs, _opts) do
body
|> MDEx.parse_document!()
|> promote_images_to_figures()
|> MDEx.to_html!(syntax_highlight: [engine: :lumis])
end
end
Because everything happens at build time, the shell session that proves it is the entire deployment story:
$ vim priv/content/posts/2026-09-03-compile-time-markdown-in-phoenix.md
$ mix compile
Compiling 3 files (.ex)
$ git commit -am "post: compile-time markdown" && git push
What you give up
Publishing now requires a deploy. For a personal site that is a feature: the writing lives in git, every revision is recoverable, and there is no admin panel to keep patched.