lib/svg_icons.ex

defmodule SvgIcons do
  @moduledoc """
  This module is used to handle creating modules for SVG icons. This allows using inline svgs without
  having to maintain the svgs inline.

  Examples:
    defmodule HeroIcons do
      use SvgIcons,
        surface: false,
        path: ["support/test-icons/optimized", {:variant, [:outline, :solid], "outline"}, :icon]
    end

    ...

    > HeroIcons.svg({"outline", "chart-pie"}, class: "w-4 h-4")
    {:safe, "<svg class="w-4 h-4" ...>...</svg>"}
  """
  defmacro __using__(opts) do
    path_parts = Keyword.get(opts, :path)
    extension = Keyword.get(opts, :ext, ".svg")
    path_sep = Keyword.get(opts, :path_sep, "/")
    base_dir = Keyword.get(opts, :base_dir, Path.dirname(__CALLER__.file))
    include_surface = Keyword.get(opts, :surface, true)

    quote do
      @path_pattern SvgIcons.collect_pattern_parts(unquote(path_parts))

      @svgs SvgIcons.read_svgs(
              unquote(base_dir),
              @path_pattern,
              unquote(extension),
              unquote(path_sep)
            )

      defp svgs(), do: @svgs

      with {:module, _} <- Code.ensure_compiled(Surface) do
        unquote(if include_surface, do: define_surface_macro(__CALLER__))
      end

      def svg(id, attrs \\ []) do
        Phoenix.HTML.raw(render_svg(id, attrs))
      end

      def render_svg(id, attrs), do: SvgIcons.render_svg(svgs(), id, attrs)
    end
  end

  def render_svg(svgs, id, attrs) do
    if Map.has_key?(svgs, id) do
      [head, tail] = Map.get(svgs, id)

      [head, translate_attrs(attrs), tail]
    else
      IO.warn("Could not find icon for #{inspect(id)}")
      ["<span>", "Failed to load icon ", inspect(id), "</span>"]
    end
  end

  def define_surface_macro(caller) do
    quote do
      use Surface.MacroComponent

      for {name, _, _, default} <- @path_pattern do
        Surface.API.put_assign(
          __ENV__,
          :prop,
          name,
          :string,
          [default: default, static: true],
          [default: default, static: true],
          unquote(caller.line)
        )
      end

      prop(id, :string, static: true)
      prop(class, :string, static: true)
      prop(opts, :keyword, default: [], static: true)

      def expand(attributes, _children, meta),
        do: SvgIcons.expand(__MODULE__, svgs(), attributes, meta, @path_pattern)
    end
  end

  defmacro read_svgs(base_dir, pattern_parts, extension, path_sep) do
    quote do
      SvgIcons.read_svgs(
        __MODULE__,
        unquote(base_dir),
        unquote(pattern_parts),
        unquote(extension),
        unquote(path_sep)
      )
    end
  end

  def expand(module, svgs, attributes, meta, path_pattern) do
    with {:module, _module} <- Code.ensure_compiled(Surface.MacroComponent) do
      props = Surface.MacroComponent.eval_static_props!(module, attributes, meta.caller)

      defaults =
        for {name, _, _, default} <- path_pattern, into: %{} do
          {name, default}
        end

      capture_names =
        path_pattern
        |> Enum.map(fn {name, _, _, _} -> name end)
        |> Enum.reject(&is_nil/1)

      id =
        capture_names
        |> Enum.map(fn name -> props[name] || Map.get(defaults, name) end)
        |> List.to_tuple()

      class = props[:class] || ""
      opts = props[:opts] || []

      attrs =
        opts ++
          [class: class] ++
          Enum.map(capture_names, fn name ->
            {"data-#{to_string(name)}", props[name]}
          end)

      struct!(Surface.AST.Literal, value: render_svg(svgs, id, attrs) |> IO.iodata_to_binary())
    end
  end

  def read_svgs(module, base_dir, pattern_parts, extension, path_sep) do
    regex =
      ((pattern_parts
        |> Enum.map(fn {_, pattern, _, _} -> pattern end)
        |> Enum.join(path_sep)) <> Regex.escape(extension))
      |> Regex.compile!()

    capture_names =
      pattern_parts
      |> Enum.map(fn {name, _, _, _} -> name end)
      |> Enum.reject(&is_nil/1)

    capture_name_strings = Enum.map(capture_names, &to_string/1)

    files =
      ((pattern_parts
        |> Enum.map(fn {_, _, wildcard, _} -> wildcard end)
        |> Enum.join(path_sep)) <> extension)
      |> Path.expand(base_dir)
      |> Path.wildcard()
      |> Enum.sort()

    for path <- files,
        relative_path = Path.relative_to(path, base_dir),
        captures = Regex.named_captures(regex, relative_path),
        captures != nil,
        id =
          capture_name_strings
          |> Enum.map(fn name -> captures[name] end)
          |> List.to_tuple(),
        into: %{} do
      Module.put_attribute(module, :external_resource, Path.relative_to_cwd(path))

      "<svg" <> contents =
        path
        |> File.read!()
        |> String.replace("\n", "")
        |> String.trim()

      {id, ["<svg", contents]}
    end
  end

  def collect_pattern_parts(path_parts) do
    Enum.map(path_parts, fn
      path when is_binary(path) ->
        path_segment(path)

      name when is_atom(name) ->
        named_path_segment(name)

      {name, default} when is_atom(name) and is_binary(default) ->
        named_path_segment(name, default)

      {name, options} when is_atom(name) and is_list(options) ->
        enum_path_segment(name, options)

      {name, options, default} when is_atom(name) and is_list(options) and is_binary(default) ->
        enum_path_segment(name, options, default)
    end)
  end

  defp path_segment(path), do: {nil, Regex.escape(path), path, nil}

  defp named_path_segment(name, default \\ nil),
    do: {name, "(?<#{to_string(name)}>[^/]+)", "*", default}

  defp enum_path_segment(name, values, default \\ nil) do
    regex_or =
      values
      |> Enum.map(&to_string/1)
      |> Enum.map(&Regex.escape/1)
      |> Enum.join("|")

    wildcard_or =
      values
      |> Enum.map(&to_string/1)
      |> Enum.join(",")

    {name, "(?<#{to_string(name)}>#{regex_or})", "{#{wildcard_or}}", default}
  end

  defp translate_attrs([]) do
    []
  end

  defp translate_attrs([{key, true} | tail]) do
    [" ", to_string(key), translate_attrs(tail)]
  end

  defp translate_attrs([{_, value} | tail]) when is_nil(value) or value == false do
    translate_attrs(tail)
  end

  defp translate_attrs([{key, value} | tail]) do
    [" ", to_string(key), ~S(="), value, ~S("), translate_attrs(tail)]
  end
end