lib/francis_htmx.ex

defmodule FrancisHtmx do
  @moduledoc """
  Provides a macro to render htmx content by loading htmx.js.
  Also provides a sigil to render EEx content similar to ~H from Phoenix.LiveView

  Usage:
  ```elixir
    defmodule Example do
      use Francis
      import FrancisHtmx

      htmx(fn _conn ->
        assigns = %{}
        ~E\"\"\"
        <style>
          .smooth {   transition: all 1s ease-in; font-size: 8rem; }
        </style>
        <div hx-get="/colors" hx-trigger="every 1s">
          <p id="color-demo" class="smooth">Color Swap Demo</p>
        </div>
        \"\"\"
      end)

      get("/colors", fn _ ->
        new_color = 3 |> :crypto.strong_rand_bytes() |> Base.encode16() |> then(&"\#{&1}")
        assigns = %{new_color: new_color}

        ~E\"\"\"
        <p id="color-demo" class="smooth" style="<%= "color:\#{@new_color}"%>">
        Color Swap Demo
        </p>
        \"\"\"
      end)
    end
  ```

  In this scenario we are loading serving an HTML that has the htmx.js library loaded and serves the root content given by htmx/1
  """

  defmacro __using__(opts) do
    quote do
      import FrancisHtmx
      import unquote(__MODULE__), only: [htmx: 1, htmx: 2, sigil_E: 2]
      import Phoenix.HTML

      checker = ~r/^(\d+\.)?(\d+\.)?(\*|\d+)$/
      version = Application.compile_env(:francis_htmx, :version, "2")
      version = Keyword.get(unquote(opts), :version, version)
      title = Keyword.get(unquote(opts), :title, "")
      head = Keyword.get(unquote(opts), :head, "")

      if !Regex.match?(checker, version) do
        raise "Invalid version format. Expected format is 'x.y.z' or 'x.y.*'. Got: '#{version}'"
      end

      Module.put_attribute(__MODULE__, :htmx_version, version)
      Module.put_attribute(__MODULE__, :htmx_title, title)
      Module.put_attribute(__MODULE__, :htmx_head, head)
      Module.register_attribute(__MODULE__, :htmx_version, accumulate: false)
      Module.register_attribute(__MODULE__, :htmx_title, accumulate: false)
      Module.register_attribute(__MODULE__, :htmx_head, accumulate: false)
    end
  end

  @doc """
  Renders htmx content by loading htmx.js and rendering binary content.
  """
  @spec htmx((Plug.Conn.t() -> binary())) :: Macro.t()
  defmacro htmx(content) do
    quote location: :keep do
      get("/", fn conn ->
        html(conn, """
        <!DOCTYPE html>
        <html>
          <head>
            #{@htmx_head}

            <script src="https://unpkg.com/htmx.org@#{@htmx_version}"></script>
            <title>#{@htmx_title}</title>
          </head>
          <body>
            #{unquote(content).(conn)}
          </body>
        </html>
        """)
      end)
    end
  end

  @doc """
  Renders htmx content by loading htmx.js and rendering binary content.
  """
  @spec htmx((Plug.Conn.t() -> binary()), Keyword.t()) :: Macro.t()
  defmacro htmx(content, opts) do
    quote location: :keep do
      get("/", fn conn ->
        title = Keyword.get(unquote(opts), :title, @htmx_title)
        head = Keyword.get(unquote(opts), :head, @htmx_head)

        html(conn, """
        <!DOCTYPE html>
        <html>
          <head>
            #{head}

            <script src="https://unpkg.com/htmx.org@#{@htmx_version}"></script>
            <title>#{title}</title>
          </head>
          <body>
            #{unquote(content).(conn)}
          </body>
        </html>
        """)
      end)
    end
  end

  @doc """
  Provides a sigil to render EEx content similar to ~H from Phoenix.LiveView

  If a variable named "assigns" doesn't exist, it will be set to an empty map.
  """
  @spec sigil_E(String.t(), Keyword.t()) :: Macro.t()
  defmacro sigil_E(content, _opts \\ []) do
    if Macro.Env.has_var?(__CALLER__, {:assigns, nil}) do
      quote location: :keep do
        content =
          EEx.eval_string(unquote(content), [assigns: var!(assigns)], engine: Phoenix.HTML.Engine)

        content
        |> Phoenix.HTML.html_escape()
        |> Phoenix.HTML.safe_to_string()
      end
    else
      quote location: :keep do
        assigns = %{}

        content =
          EEx.eval_string(unquote(content), [assigns: assigns], engine: Phoenix.HTML.Engine)

        content
        |> Phoenix.HTML.html_escape()
        |> Phoenix.HTML.safe_to_string()
      end
    end
  end
end