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

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
hauks96
Hello everyone, I created a deployment tutorial for Phoenix applications with Kubernetes (microk8s) a few months back with the goal of s...
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
sergio
Wrote this guide on how to integrate DropzoneJS with Phoenix Liveview. Hope it helps someone out! https://sergiotapia.com/dropzonejs-dir...
New
Eiji
Hey, today I give amnesia library a try and found a few problems. I would like describe how to setup it properly and solve problems which...
New
jtormey
Hello! Having written a lot of LiveView code, I’ve made some VS Code snippets to speed up writing callbacks for LiveViews and LiveCompon...
New
caspg
Hi everyone, I recently implemented a real-time search feature in a Phoenix application using LiveView and Tailwind, and I wanted to sha...
New
alejandroErik
POST IN CONSTRUCTION Process for compile erlang otp 20 with odbc-unix for Oracle connections on Solaris 11.3 for 64 bits: Introductio...
New
dennisreimann
I wrote a guide for implementing Passwordless Authentication a.k.a. "Magic Login Links": Feedback welcome!
New
kevinlang
Hey all, With Phoenix 1.6 just around the corner, I figured I’d make a tutorial on how to add Bulma to a new Phoenix 1.6 project. By lev...
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
senggen
Erlang/OTP 25 [erts-13.2.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] 15:22:35.803 [error] gen_event {lager_file_backend...
New
Brian
What is the proper way to load a module from a file in to IEX? In the python world, doing something like this pretty standard: from ....
New
chrismccord
Phoenix 1.4.0 released Phoenix 1.4 is out! This release ships with exciting new features, most notably with HTTP2 support, improved deve...
688 30048 115
New
JorisKok
I have a server on AWS, and was running a load test using artillery. When looking at the Phoenix dashboard I see the Ports going to 100% ...
New
_russellb
I want to try my hand at web scraping. What tools/libraries do I need to use. I’m hoping to turn this into something professional so don’...
New
hariharasudhan94
I would like to know what is the best IDE for elixir development?
New
chrismccord
This release brings a number of exciting features, including integration with the new Phoenix LiveDashboard and Phoenix LiveView. There h...
New
myronmarston
The Elixir Typespec docs show the following syntax for keyword lists in typespecs: # ... | [key: type] # keyword lis...
New
vonH
When I run the Plug and I recompile I wind up having to use Ctrl C to quit iex and start again. Witht the help of rlwrap I can use the cu...
New

We're in Beta

About us Mission Statement