gus

gus

Nerves Core Team

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? Top

Trending in Guides/Tuts Top

Other Trending Topics Top

JesseHerrick
Hey, I’m Jesse and I’m the main contributor behind Dexter, a full-featured, lightning-fast Elixir LSP optimized for large codebases. It s...
New
jimsynz
Beam Bots (or just BB for short) is a framework for building fault-tolerant robotics applications in Elixir using familiar OTP patterns. ...
New
mcass19
ExRatatui lets you cook up rich terminal UIs in Elixir, powered by Rust’s ratatui via Rustler NIFs. Build interactive terminal applicatio...
New
Damirados
Hello everyone. After busy few months I am happy to announce v0.1.0 of Emerge &amp; Solve. They are GUI (Emerge) and State management (S...
New
netoum
Corex is an accessible, unstyled UI component library for Phoenix that integrates Zag.js state machines using Vanilla JavaScript and Live...
New
ausimian
Emily is an Elixir library that runs Nx computations on Apple’s MLX. Install it as the default Nx backend and Nx, defn, Axon, Nx.Serving,...
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews