lib/mjml_eex.ex

defmodule MjmlEEx do
  @moduledoc """
  Documentation for `MjmlEEx`.
  """

  defmacro __using__(opts) do
    mjml_file =
      case Keyword.fetch(opts, :mjml_file) do
        {:ok, mjml_file} ->
          %Macro.Env{file: calling_module_file} = __CALLER__

          calling_module_file
          |> Path.dirname()
          |> Path.join(mjml_file)

        :error ->
          raise "The :mjml_file option is required."
      end

    unless File.exists?(mjml_file) do
      raise "The provided :mjml_file does not exist at #{inspect(mjml_file)}"
    end

    quote do
      require EEx

      @template_path unquote(mjml_file)
      @external_resource @template_path

      rendered_mjml = MjmlEEx.generate_eex_template(@template_path, unquote(opts))
      EEx.function_from_string(:def, :render, rendered_mjml, [:assigns])
    end
  end

  @doc """
  This function will read the MJML EEx file, tokenize all of the EEx clauses,
  convert the tokenized clauses into base64 encoded strings, compile the MJML
  template using the Rust MJML NIF compiler, and then finally convert all of the
  base64 encoded strings back into valid Elixir code.

  The reason for the base64 encoding/decoding step is to ensure that EEx
  `<%= ... %>` and `<% ... %>` statements do not break the Rust MJML compiler.
  """
  def generate_eex_template(file_path, opts) do
    {:ok, tokens} =
      file_path
      |> File.read!()
      |> EEx.Tokenizer.tokenize(1, 1, %{indentation: 0, trim: true})

    {:ok, email_html} =
      tokens
      |> Enum.reduce("", fn
        {:text, _, _, content}, acc ->
          additional_content = :binary.list_to_bin(content)
          acc <> additional_content

        {:expr, _, _, marker, content}, acc ->
          code = :binary.list_to_bin(content)
          encoded_code = Base.encode64("<%#{normalize_marker(marker)} #{code} %>")
          acc <> "MJML_EEX_START:#{encoded_code}:MJML_EEX_END"

        {:start_expr, _, _, marker, content}, acc ->
          code = :binary.list_to_bin(content)
          encoded_code = Base.encode64("<%#{normalize_marker(marker)} #{code} %>")
          acc <> "MJML_EEX_START:#{encoded_code}:MJML_EEX_END"

        {:middle_expr, _, _, marker, content}, acc ->
          code = :binary.list_to_bin(content)
          encoded_code = Base.encode64("<%#{normalize_marker(marker)} #{code} %>")
          acc <> "MJML_EEX_START:#{encoded_code}:MJML_EEX_END"

        {:end_expr, _, _, marker, content}, acc ->
          code = :binary.list_to_bin(content)
          encoded_code = Base.encode64("<%#{normalize_marker(marker)} #{code} %>")
          acc <> "MJML_EEX_START:#{encoded_code}:MJML_EEX_END"

        {:eof, _, _}, acc ->
          acc
      end)
      |> Mjml.to_html()

    email_output = decode_eex_expressions(email_html)

    if Keyword.get(opts, :debug, false) do
      IO.puts("---------------- START COMPILED MJML TEMPLATE ----------------")
      IO.puts(email_output)
      IO.puts("---------------- END COMPILED MJML TEMPLATE ----------------")
    end

    email_output
  end

  defp decode_eex_expressions(email_document) do
    ~r/MJML_EEX_START:([^:]+):MJML_EEX_END/
    |> Regex.replace(email_document, fn _, base64_code ->
      "#{Base.decode64!(base64_code)}"
    end)
  end

  defp normalize_marker([]), do: ""
  defp normalize_marker(marker), do: :binary.list_to_bin(marker)
end