lib/phoenix_svg.ex

defmodule PhoenixSVG do
  @moduledoc """
  Use inline SVGs in Phoenix
  """

  import Phoenix.Component

  defmacro __using__(opts) do
    otp_app = Keyword.fetch!(opts, :otp_app)
    as = Keyword.get(opts, :as, :svg)
    from = Keyword.get(opts, :from, "priv/svgs")
    attributes = Keyword.get(opts, :attributes, [])

    svgs_path = Application.app_dir(otp_app, from)
    {svgs, hash} = PhoenixSVG.list_files(svgs_path)

    [
      for svg <- svgs do
        {name, path, data} = PhoenixSVG.read_file!(svg, svgs_path)
        pattern_match = if path == [], do: %{name: name}, else: %{name: name, path: path}

        quote do
          @external_resource unquote(svg)

          def unquote(as)(unquote(Macro.escape(pattern_match)) = assigns) do
            html_attrs =
              unquote(attributes)
              |> Enum.into(%{})
              |> Map.merge(assigns)
              |> Phoenix.Component.assigns_to_attributes([:name, :path])
              |> PhoenixSVG.to_safe_html_attrs()

            "<svg" <> tail = unquote(data)

            PhoenixSVG.svg(%{
              inner_content: Phoenix.HTML.raw(["<svg ", html_attrs, String.trim(tail)])
            })
          end
        end
      end,
      quote do
        def unquote(as)(assigns) do
          for_path = if assigns[:path], do: " for path \"#{inspect(assigns.path)}\"", else: ""
          raise "#{inspect(assigns.name)} is not a valid svg#{for_path}"
        end

        def __mix_recompile__? do
          unquote(hash) != PhoenixSVG.list_files(unquote(svgs_path)) |> elem(1)
        end
      end
    ]
  end

  @doc """
  Renders an inline svg from a cached file.

  ## Attributes

    * `:name` - The name of the svg file, excluding the `.svg` extension.
    * `:path` - A list of nested paths if the file is not in the root.

  Any other attributes will be passed through to the `<svg>` tag.

  Note that this function should never be called directly with `PhoenixSVG.svg`. It's meant to
  be called from the `svg` function generated in the `__using__` macro. See the [Getting Started](readme.html#getting-started)
  guide.
  """
  def svg(assigns) do
    ~H"""
    <%= Phoenix.HTML.raw(@inner_content) %>
    """
  end

  @doc """
  List all of the SVG files in the given directory.

  Returns a list of all the files, and an MD5 hash of the list so it can be determined if the list
  changed and needs to be re-compiled.
  """
  def list_files(path) do
    files =
      path
      |> Path.join("**/*.svg")
      |> Path.wildcard()

    {files, :erlang.md5(files)}
  end

  @doc """
  Reads a file and parses out the name and path.

  The name will be the filename without the extension, and the path will be a list of directory
  names the file is nested in relative to the base path.
  """
  def read_file!(filepath, basepath) do
    data = File.read!(filepath) |> String.trim()
    name = Path.basename(filepath) |> Path.rootname()
    rel_path = Path.relative_to(filepath, basepath)

    path =
      rel_path
      |> Path.dirname()
      |> Path.split()
      |> Enum.reject(&(&1 == "."))

    {name, path, data}
  end

  @doc """
  Converts a map or keyword list into HTML-safe attributes.

  Any keys that contain an underscore will be converted to a dash in the HTMl attribute. For
  example, `%{foo_bar: "foo_bar"}` will result in the attribute `foo-bar="foo_bar"`.
  """
  def to_safe_html_attrs(data) do
    for {key, value} <- data do
      key =
        key
        |> Atom.to_string()
        |> String.replace("_", "-")
        |> Phoenix.HTML.Safe.to_iodata()

      [key, ?=, ?", Phoenix.HTML.Safe.to_iodata(value), ?", ?\s]
    end
  end
end