lib/inline_svg.ex

defmodule InlineSVG do
  @moduledoc """
  Render inline SVG.


  ## Initialization

  ```elixir
  def SVGHelper do
    use InlineSVG, root: "assets/static/svg", default_collection: "generic"
  end
  ```

  This will generate functions for each SVG file, effectively caching them at
  compile time.


  ## Usage

  ### render SVG from default collection

  ```elixir
  svg("home")
  ```

  It will load the SVG file from `assets/static/svg/generic/home.svg`:

  ```html
  <svg>...</svg>
  ```

  ### render SVG from other collections

  You can break up SVG files into collections, and use the second argument of
  `svg/2` to specify the name of collection:

  ```elixir
  svg("user", "fontawesome")
  ```

  It will load the SVG file from `assets/static/svg/fontawesome/user.svg`:

  ```html
  <svg>...</svg>
  ```

  ### render SVG with custom HTML attributes

  You can also pass optional HTML attributes into the function to set those
  attributes on the SVG:

  ```elixir
  svg("home", class: "logo", id: "bounce-animation")
  svg("home", "fontawesome", class: "logo", id: "bounce-animation")
  ```

  It will output:

  ```html
  <svg class="logo" id="bounce-animation">...</svg>
  <svg class="logo" id="bounce-animation">...</svg>
  ```


  ## Options

  There are several configuration options for meeting your needs.

  ### `:root`

  Specify the directory from which to load SVG files.

  You must specify it by your own.

  ### `:function_prefix`

  Specify the prefix of functions.

  By the default, the value is `""`. The generated function name is `svg`.

  If this value is "_". Then generated function name is `_svg`.

  ### `:default_collection`

  Specify the default collection to use.

  The deafult value is `generic`.


  ## Use in Phoenix

  An example:

  ```elixir
  def DemoWeb.SVGHelper do
    use InlineSVG,
      root: "assets/static/svg",
      function_prefix: "_",
      default_collection: "generic"


    def svg(arg1) do
      Phoenix.HTML.raw(_svg(arg1))
    end

    def svg(arg1, arg2) do
      Phoenix.HTML.raw(_svg(arg1, arg2))
    end

    def svg(arg1, arg2, arg3) do
      Phoenix.HTML.raw(_svg(arg1, arg2, arg3))
    end
  end
  ```
  """

  alias InlineSVG.HTML

  @doc """
  The macro precompiles the SVG files into functions.
  """
  defmacro __using__(opts \\ []) do
    root = Keyword.fetch!(opts, :root)
    {root, _} = Code.eval_quoted(root)

    if !File.dir?(root) do
      raise "invalid :root option"
    end

    function_prefix = Keyword.get(opts, :function_prefix, "")
    default_collection = Keyword.get(opts, :default_collection, "generic")

    [recompile_hooks(root) | generate_svg_fns(root, function_prefix, default_collection)]
  end

  # Trigger recompile when SVG files change.
  # Read more at https://hexdocs.pm/mix/1.13/Mix.Tasks.Compile.Elixir.html
  defp recompile_hooks(root) do
    quote bind_quoted: [root: root] do
      @root root

      paths =
        @root
        |> Path.join("**/*.svg")
        |> Path.wildcard()
        |> Enum.filter(&File.regular?(&1))

      @paths_hash :erlang.md5(paths)

      for path <- paths do
        @external_resource path
      end

      def __mix_recompile__?() do
        @root
        |> Path.join("**/*.svg")
        |> Path.wildcard()
        |> Enum.filter(&File.regular?(&1))
        |> :erlang.md5() != @paths_hash
      end
    end
  end

  defp generate_svg_fns(root, function_prefix, default_collection) do
    root
    |> scan_svgs()
    |> Enum.flat_map(&cache_svg(&1, function_prefix, default_collection))
  end

  defp scan_svgs(root) do
    root
    |> Path.join("**/*.svg")
    |> Path.wildcard()
    |> Stream.filter(&File.regular?(&1))
    |> Enum.map(fn svg_path ->
      [collection_name, svg_name] =
        svg_path
        |> Path.relative_to(root)
        |> Path.rootname()
        |> String.split("/", parts: 2)

      {collection_name, svg_name, svg_path}
    end)
  end

  defp cache_svg({collection, name, path}, function_prefix, default_collection) do
    content = read_svg(path)

    # parse HTML at compile time.
    parsed_html =
      content
      |> HTML.parse_html!()
      |> Macro.escape()

    generic_functions =
      if collection == default_collection do
        quote do
          def unquote(:"#{function_prefix}svg")(unquote(name)) do
            unquote(:"#{function_prefix}svg")(unquote(name), unquote(collection), [])
          end

          def unquote(:"#{function_prefix}svg")(unquote(name), opts) when is_list(opts) do
            unquote(:"#{function_prefix}svg")(unquote(name), unquote(collection), opts)
          end
        end
      end

    explicit_functions =
      quote do
        def unquote(:"#{function_prefix}svg")(unquote(name), unquote(collection)) do
          unquote(:"#{function_prefix}svg")(unquote(name), unquote(collection), [])
        end

        def unquote(:"#{function_prefix}svg")(unquote(name), unquote(collection), []) do
          unquote(content)
        end

        def unquote(:"#{function_prefix}svg")(unquote(name), unquote(collection), opts) do
          unquote(parsed_html)
          |> HTML.insert_attrs(opts)
          |> HTML.to_html()
        end
      end

    [generic_functions, explicit_functions]
  end

  defp read_svg(path) do
    path
    |> File.read!()
    |> String.trim()
  end
end