gus

gus

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!

Where Next?

Popular in Guides/Tuts Top

voltone
The EEF’s Security WG has released the first public draft of the Secure Coding and Deployment Hardening Guidelines for BEAM languages. “...
New
smpallen99
Did you know that IO.inspect/2 returns the the first argument and accepts a label option as a second argument. This makes it a perfect to...
New
bentanweihao
I wrote a thing: http://engineering.pivotal.io/post/how-to-set-up-an-elixir-cluster-on-amazon-ec2/ Hope this is helpful!
New
eclark
I’ve been working on a phoenix project lately and I wanted to use the latest versions of everything. Webpack 5 had some breaking changes ...
New
anuragg
Hi everyone, I’m the founder of Render and we just released a guide to deploying Phoenix apps with Mix releases. Most of it is generali...
New
benwilson512
Correct if me I’m wrong, as best I can tell there aren’t any reasons to use mix run --no-halt in production vs releases. The marginal val...
New
lukertty
Install web-mode and mmm-mode first and put this in your config file: (require 'mmm-mode) (require 'web-mode) (setq mmm-global-mode 'may...
New
bitli
In case this is handy for other people, here is how you can run Elixir on Android: Install https://termux.com/ apt update; apt upgrad...
New
anuragg
We just published a guide to automatic clustering in Elixir 1.9, with Mix releases and libcluster. The cluster automatically discovers n...
New
niku
I have published an elixir project with using Travis CI. I would like to share some tips &amp; thoughts that I was getting through this ...
New

Other popular topics Top

shahryarjb
Hello, I get Persian date from my client and convert it to normal calendar like this: def jalali_string_to_miladi_english_number(persi...
New
srinivasu
How to handle excepions in elixir? Suppose i have A, B, C ,D, E modules. and each module has get() function. A.get() method will call th...
New
pmjoe
I have a relationship of love and hate with Elixir. Lots of things are just absolutely right, but there are some things that are kind of ...
New
albydarned
Hello all! I am typing this post from my new MacBook Pro with the M1 chip. I’m loving it so far, and will probably use it as my daily dr...
New
grych
Hi folks, Few months ago I have announced the proof-of-concept of the library to manipulate the browsers DOM objects directly from Elixi...
639 49522 488
New
jononomo
I am trying to figure out how Mix knows whether the environment is test, dev, or prod -- where is this set? Thanks.
New
freewebwithme
Using vs code and installed ElixirLS: support and debugger. And I got an error popped up on start up says Failed to run ‘elixir’ comma...
New
minhajuddin
I have seen a lot of code which picks the first element from a list using Enum.at(0) instead of List.first. Is there a reason why people ...
New
alice
Hey, Just curious what are the main benefits of Elixir compared to Clojure? When is Elixir more useful than Clojure and vice versa? Th...
New
ovidiubadita
Hey all, I discovered Elixir and I love it. I always wanted to learn a functional programming and I intended to go for Haskell, but afte...
New

We're in Beta

About us Mission Statement