I wanted to share a method for embedding HEEx components inside of markdown files as templates, in the form of a mini-guide here on the forum. It is heavily based on the implementation of sigil_M by @leandrocp and his excellent MDEx library - huge thank you for developing and maintaining this library!!
The reason I wanted to do this was so that I could write my markdown in separate files located in the priv/blog/posts
directory, but render the markdown into HEEx at compile time for good performance.
Add Support for Compiling Markdown to HEEx:
For regular HTML HEEx templates, you can place your templates anywhere and use the embed_templates/2
macro to import the templates as function components. The default supported templates are :eex
, :exs
, :leex
, and :heex
Fortunately, Phoenix makes it super easy to add a new template engine, it just needs to implement the Phoenix.Template.Engine
behavior. We can take the implementation of sigil_M
in the MDEx examples and tweak it to load a file, read the contents and then convert the markdown string to HEEx.
Note: this requires that you add
{:mdex, "~> 0.3.2"}
and{:html_entities, "~> 0.5.2"}
to yourmix.exs
Engine implementation:
# lib/my_app_web/md_engine.ex
defmodule MyAppWeb.MdEngine do
@behaviour Phoenix.Template.Engine
# based on https://github.com/phoenixframework/phoenix_live_view/blob/main/lib/phoenix_live_view/html_engine.ex#L12
def compile(path, _name) do
quote do
require MyAppWeb.MdEngine
MyAppWeb.MdEngine.compile(unquote(path))
end
end
# based on https://github.com/leandrocp/mdex/blob/main/examples/live_view.exs
@doc false
defmacro compile(path) do
trim = Application.get_env(:phoenix, :trim_on_html_eex_engine, true)
source = File.read!(path)
mdex_opts = [
extension: [
strikethrough: true,
tagfilter: true,
table: true,
tasklist: true,
footnotes: true,
shortcodes: true
],
parse: [
relaxed_tasklist_matching: true
],
render: [
unsafe_: true
]
]
md =
source
|> MDEx.to_html!(mdex_opts)
|> MyAppWeb.MdEngine.unescape()
|> IO.iodata_to_binary()
eex_opts = [
engine: Phoenix.LiveView.TagEngine,
file: path,
line: 1,
trim: trim,
caller: __CALLER__,
source: md,
tag_handler: Phoenix.LiveView.HTMLEngine
]
EEx.compile_string(md, eex_opts)
end
def unescape(html) do
~r/(<pre.*?<\/pre>)/s
|> Regex.split(html, include_captures: true)
|> Enum.map(fn part ->
if String.starts_with?(part, "<pre") do
part
else
HtmlEntities.decode(part)
end
end)
|> Enum.join()
end
end
To tell Phoenix how to use the new template engine, just add the following in your config/config.exs
:
config :phoenix, :template_engines, md: MyAppWeb.MdEngine
And that’s it - you can now use embed_templates/2
in your controllers or LiveViews to compile markdown files to HEEx components
Example:
Markdown file located at priv/blog/posts/example.md
# You can use regular markdown
_Some_ **markdown** [here](https://phoenixframework.org)
## You can also embed components!
<.link navigate={~p"/home"}>Go home</.link>
<.button phx-click="clicked">Hello there!</.button>
## You can even use assigns:
<.form for={@form} :let={f} phx-submit="submit">
<.input field={f[:email]} label="Email address" />
</.form>
LiveView:
# lib/my_app_web/live/test_live.ex
defmodule MyAppWeb.TestLive do
use MyAppWeb, :live_view
# note, do not add the `.md` extension for the template!
embed_templates "example", root: Application.app_dir(:my_app, "priv/blog/posts")
def render(assigns) do
~H"""
<div class="prose">
<p>This is outside of the component</p>
<.example form={@form} />
</div>
"""
end
def mount(_params, _session, socket) do
{:ok, assign(socket, form: to_form(%{}))}
end
end
Conclusion
- I use this method with Nimble Publisher to render blog posts. You can add support for the Elixir map attributes at to the top of the file by modifying
md_engine.ex
with the following:
source =
File.read!(path)
|> String.split(["\n---\n", "\r\n---\r\n"])
|> List.last()
- I like external markdown files because you can integrate additional tooling to help you write. For example, I use dprint for auto-format and harper for spell check.
Let me know if this is helpful!