HEEx inside of standalone markdown templates - a guide

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 your mix.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!

12 Likes