lib/compilers/node.ex

if Code.ensure_loaded?(:exec) do
  defmodule MjmlEEx.Compilers.Node do
    @moduledoc """
    This module implements the `MjmlEEx.Compiler` behaviour
    and allows you to compile your MJML templates using the Node
    CLI tool. This compiler expects you to have the `mjml` Node
    script accessible from the running environment.

    For information regarding the Node mjml compiler see:
    https://documentation.mjml.io/#command-line-interface

    ## Configuration

    In order to use this compiler, you need to set your application
    configration like so (in your `config.exs` file for example):

    ```elixir
    config :mjml_eex,
      compiler: MjmlEEx.Compilers.Node,
      compiler_opts: [
        timeout: 10_000,
        path: "mjml"
    ]
    ```

    In addition, since the Node compiler is run via `:erlexec`, you will
    need to add this optional dependency to your `mix.exs` file and also
    start the optional application:

    ```elixir
    def application do
      [
        extra_applications: [..., :erlexec]
      ]
    end

    defp deps do
      [
        ...
        {:erlexec, "~> 2.0"}
      ]
    end
    ```
    """

    @behaviour MjmlEEx.Compiler

    @impl true
    def compile(mjml_template) do
      # Get the configs for the compiler
      compiler_opts = Application.get_env(:mjml_eex, :compiler_opts)
      timeout = Keyword.get(compiler_opts, :timeout, 10_000)
      compiler_path = Keyword.get(compiler_opts, :path, "mjml")

      # Start the erlexec port
      {:ok, pid, os_pid} =
        :exec.run("#{compiler_path} -s -i --noStdoutFileComment", [:stdin, :stdout, :stderr, :monitor])

      # Send the MJML template to the compiler via STDIN
      :exec.send(pid, mjml_template)
      :exec.send(pid, :eof)

      # Initial state for reduce
      initial_reduce_results = %{
        stdout: "",
        stderr: []
      }

      result =
        [nil]
        |> Stream.cycle()
        |> Enum.reduce_while(initial_reduce_results, fn _, acc ->
          receive do
            {:DOWN, ^os_pid, _, ^pid, {:exit_status, exit_status}} when exit_status != 0 ->
              error = "Node mjml CLI compiler exited with status code #{inspect(exit_status)}"
              existing_errors = Map.get(acc, :stderr, [])
              {:halt, Map.put(acc, :stderr, [error | existing_errors])}

            {:DOWN, ^os_pid, _, ^pid, _} ->
              {:halt, acc}

            {:stderr, ^os_pid, error} ->
              error = String.trim(error)
              existing_errors = Map.get(acc, :stderr, [])
              {:cont, Map.put(acc, :stderr, [error | existing_errors])}

            {:stdout, ^os_pid, compiled_template_fragment} ->
              aggregated_template = Map.get(acc, :stdout, "")
              {:cont, Map.put(acc, :stdout, aggregated_template <> compiled_template_fragment)}
          after
            timeout ->
              :exec.kill(os_pid, :sigterm)
              time_in_seconds = System.convert_time_unit(timeout, :millisecond, :second)
              error = "Node mjml CLI compiler timed out after #{time_in_seconds} second(s)"
              existing_errors = Map.get(acc, :stderr, [])
              {:halt, Map.put(acc, :stderr, [error | existing_errors])}
          end
        end)

      case result do
        %{stderr: [], stdout: compiled_template} ->
          {:ok, compiled_template}

        %{stderr: errors} ->
          {:error, Enum.join(errors, "\n")}
      end
    end
  end
else
  defmodule MjmlEEx.Compilers.Node do
    @moduledoc false

    @behaviour MjmlEEx.Compiler

    @impl true
    def compile(_mjml_template) do
      raise("""
      In order to use the Node compiler you must also update your mix.exs file like so:

      def application do
      [
        extra_applications: [..., :erlexec]
      ]
      end

      defp deps do
      [
        ...
        {:erlexec, "~> 2.0"}
      ]
      end
      """)
    end
  end
end