Heexify Mix Task, that will generate HEEX components directly from SVGs.

Hi Team,

I wrote a mix task, that generates HEEX components from SVGs kept inside prv/static/svgs folder:

image

And puts it inside the components folder:
image

Can it become part of Phoenix Framework?

I know that the SVG libraries exists, but I was thinking we don’t need them, because most of the SVG Icons library reimplement a mix task svg to heex generator like this anyway, and I studied 2 such implementation to make a generalised one, so I can import from any Icon pack.

Plus:

  1. We won’t have to needlessly import packages for each icon pack.
  2. It will be agnostic to SVG Icons. (So, we can just as easily use Heroicons, FontAwesome Icons, Remix Icons, etc)
  3. SVG is better than font icons, and every project needs them.
  4. Icons will have proper naming convention across icon packs.

Mix Task: heexify.ex

defmodule Mix.Tasks.App.Heexify do
  @moduledoc """
  Used to convert static SVGs to Heex components.

  command: `mix app.heexify`

  Inspired by: PetalFramework
  Git: https://github.com/petalframework/petal_components/blob/main/lib/mix/tasks/heroicons/generate.ex
  """

  @shortdoc "Used to enable usage of SVGs in project"

  @app_name "DerpyCoder"
  @svg_path "priv/static/svgs/"

  use Mix.Task
  alias Phoenix.Naming

  @impl Mix.Task
  def run(_) do
    Mix.Task.run("app.start")

    crunch_svgs_folder()
  end


  # ==============================================================================
  # It Crunches SVGs and for that, it:
  # - Goes over each file in SVGs folder.
  # - Segments modules based on folder structure by building hashmap.
  # - Assembles the module with helper functions.
  # - Writes them into Separate files.
  # ==============================================================================
  defp crunch_svgs_folder() do
    @svg_path <> "**/*.svg"
    |> Path.wildcard()
    |> Enum.map(&String.replace(&1, @svg_path, ""))
    |> Enum.reduce(%{}, &build_hashmap/2)
    |> Enum.map(&assemble_module/1)
    |> Enum.each(&write_module/1)
  end

  # ==============================================================================
  # Hashmap
  # It goes over array of paths, which looks like:
  # ["hero-icons/solid/eye", "hero-icons/solid/code"]
  #
  # And groups them based on immediate containing folder.
  # Which looks like:
  # %{
  #   "hero-icons/solid => ["hero-icons/solid/eye", "hero-icons/solid/code"]
  # }
  #
  # This helps us with colocating each file inside their respective modules.
  # So the key in above hashmap becomes the module: HeroIcons.Solid
  # And the value becomes the HEEX Component: HeroIcons.Solid.code
  # ==============================================================================
  defp build_hashmap(path, acc) do
    value = case Map.get(acc, Path.dirname(path)) do
      nil -> [path]
      array -> [path | array]
    end

    Map.put(acc, Path.dirname(path), value)
  end

  # ==============================================================================
  # Module Assembler
  # It creates the file structure for the Module.
  # Adds sensible commments with examples.
  # And assimilates all the svgs withing as functions.
  # ==============================================================================
  defp assemble_module({module_path, file_paths}) do
    module_name = module_name(module_path)
    module = """
      defmodule #{module_name} do
        @moduledoc \"\"\"
        # #{module_name}
        Contains Heexified SVG Components, to ease usage of SVG without cluttering markup.

        ## Examples:
            <#{module_name}.svg class="w-5 h-5" />
            <#{module_name}.svg class="w-5 h-5" title="Accessible Title" />
        \"\"\"
        use Phoenix.Component
        import #{@app_name}Web.SVG

        # coveralls-ignore-start

      #{assimilate_svg(file_paths)}
        # coveralls-ignore-stop
      end
      """

      {module_path, module}
  end

  # ==============================================================================
  # Module Name Helper
  # ==============================================================================
  defp module_name(module_path) do
    module_path
      |> String.replace("-", "_")
      |> String.split("/")
      |> Enum.map(&Naming.camelize/1)
      |> case do
        nil -> ""
        module_name -> Enum.join(module_name, ".")
      end
  end

  # ==============================================================================
  # Loops over each file.
  # And Creates HEEX Components array.
  # ==============================================================================
  defp assimilate_svg(file_paths) do
    file_paths
      |> Enum.map(&create_component/1)
      |> Enum.join("\n")
  end

  # ==============================================================================
  # Creates HEEX Component.
  # Reads SVG Files.
  # Alters and Assemble it.
  # ==============================================================================
  defp create_component(file_path) do
    File.read!(@svg_path <> file_path)
    |> alter_svg()
    |> assemble_component(file_path)
  end

  # ==============================================================================
  # Trims and adds some new lines to beautify the svg.
  # Also adds the ability to pass in class, title and any attributes to the SVG.
  # ==============================================================================
  defp alter_svg(svg) do
    svg
    |> String.trim()
    |> String.replace(~r/<svg /, "<svg class={@class} {@extra} ")
    |> String.replace(~r/\"><path/, "\">\n      <.title title={@title} />\n      <path")
    |> String.replace(~r/><path/, ">\n      <path")
    |> String.replace(~r/<\/svg/, "\n    <\/svg")
  end

  # ==============================================================================
  # Assembles the HEEX Component.
  # Adds Comments and Assigns to create flexible HEEX Component.
  # ==============================================================================
  defp assemble_component(svg, file_path) do
    module_name =
      file_path
      |> Path.dirname()
      |> module_name()
    function_name = function_name(file_path)
    """
      @doc \"\"\"
      # #{module_name}.#{function_name}
      A Heexified SVG component, that can be passed class, title and extra attributes, to alter it.

      ## Examples:
          <#{module_name}.#{function_name} class="w-5 h-5" />
          <#{module_name}.#{function_name} class="w-5 h-5" title="Accessible Title" />
      \"\"\"
      def #{function_name}(assigns) do
        assigns = assigns
          |> assign_new(:title, fn -> nil end)
          |> assign_new(:class, fn -> nil end)
          |> assign_new(:extra, fn -> assigns_to_attributes(assigns, ~w(class)a) end)

        ~H\"\"\"
        #{svg}
        \"\"\"
      end
    """
  end

  # ==============================================================================
  # Function Name Helper
  # ==============================================================================
  defp function_name(file_path) do
    file_path
      |> Path.basename(".svg")
      |> String.replace("-", "_")
  end

  # ==============================================================================
  # Writes the Assembled Module to file.
  # ==============================================================================
  defp write_module({module_path, module}) do
    module_path = String.replace(module_path, "-", "_")
    destination = "lib/#{Naming.underscore(@app_name)}_web/components/_svg/#{module_path}.ex"

    unless File.exists?(destination) do
      File.mkdir_p(Path.dirname(destination))
    end

    File.write!(destination, module)
  end
end

Helper Component: svg.ex

defmodule DerpyCoderWeb.SVG do
  @moduledoc """
  SVG helper components.
  """
  use Phoenix.Component

  def title(assigns) do
    ~H"""
      <%= if not is_nil(@title) do %>
        <title><%= @title %></title>
      <% end %>
    """
  end
end

Input: priv/static/svgs/hero-icons/solid/eye.svg

<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path d="M10 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/><path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 1 1-8 0 4 4 0 0 1 8 0z" clip-rule="evenodd"/></svg>

Input: priv/static/svgs/hero-icons/solid/code.svg

<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M12.316 3.051a1 1 0 0 1 .633 1.265l-4 12a1 1 0 1 1-1.898-.632l4-12a1 1 0 0 1 1.265-.633zM5.707 6.293a1 1 0 0 1 0 1.414L3.414 10l2.293 2.293a1 1 0 1 1-1.414 1.414l-3-3a1 1 0 0 1 0-1.414l3-3a1 1 0 0 1 1.414 0zm8.586 0a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-3 3a1 1 0 1 1-1.414-1.414L16.586 10l-2.293-2.293a1 1 0 0 1 0-1.414z" clip-rule="evenodd"/></svg>

Output: lib/derpy_coder_web/components/_svg/hero_icons/solid.ex

defmodule HeroIcons.Solid do
  @moduledoc """
  # HeroIcons.Solid
  Contains Heexified SVG Components, to ease usage of SVG without cluttering markup.

  ## Examples:
      <HeroIcons.Solid.svg class="w-5 h-5" />
      <HeroIcons.Solid.svg class="w-5 h-5" title="Accessible Title" />
  """
  use Phoenix.Component
  import DerpyCoderWeb.SVG

  # coveralls-ignore-start

  @doc """
  # HeroIcons.Solid.eye
  A Heexified SVG component, that can be passed class, title and extra attributes, to alter it.

  ## Examples:
      <HeroIcons.Solid.eye class="w-5 h-5" />
      <HeroIcons.Solid.eye class="w-5 h-5" title="Accessible Title" />
  """
  def eye(assigns) do
    assigns = assigns
      |> assign_new(:title, fn -> nil end)
      |> assign_new(:class, fn -> nil end)
      |> assign_new(:extra, fn -> assigns_to_attributes(assigns, ~w(class)a) end)

    ~H"""
    <svg class={@class} {@extra} xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
      <.title title={@title} />
      <path d="M10 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/>
      <path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 1 1-8 0 4 4 0 0 1 8 0z" clip-rule="evenodd"/>
    </svg>
    """
  end

  @doc """
  # HeroIcons.Solid.code
  A Heexified SVG component, that can be passed class, title and extra attributes, to alter it.

  ## Examples:
      <HeroIcons.Solid.code class="w-5 h-5" />
      <HeroIcons.Solid.code class="w-5 h-5" title="Accessible Title" />
  """
  def code(assigns) do
    assigns = assigns
      |> assign_new(:title, fn -> nil end)
      |> assign_new(:class, fn -> nil end)
      |> assign_new(:extra, fn -> assigns_to_attributes(assigns, ~w(class)a) end)

    ~H"""
    <svg class={@class} {@extra} xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
      <.title title={@title} />
      <path fill-rule="evenodd" d="M12.316 3.051a1 1 0 0 1 .633 1.265l-4 12a1 1 0 1 1-1.898-.632l4-12a1 1 0 0 1 1.265-.633zM5.707 6.293a1 1 0 0 1 0 1.414L3.414 10l2.293 2.293a1 1 0 1 1-1.414 1.414l-3-3a1 1 0 0 1 0-1.414l3-3a1 1 0 0 1 1.414 0zm8.586 0a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-3 3a1 1 0 1 1-1.414-1.414L16.586 10l-2.293-2.293a1 1 0 0 1 0-1.414z" clip-rule="evenodd"/>
    </svg>
    """
  end

  # coveralls-ignore-stop
end
1 Like
defp alter_svg(svg) do
  svg
  |> String.trim()
  |> String.replace(~r/<svg /, "<svg class={@class} {@extra} ")
  |> String.replace(~r/\"><path/, "\">\n      <.title title={@title} />\n      <path")
  |> String.replace(~r/><path/, ">\n      <path")
  |> String.replace(~r/<\/svg/, "\n    <\/svg")
end

Naïve regex manipulation of XML is asking for trouble. Here, SVGs commonly already have classes, what happens there? SVGs commonly already have titles, what happens there? I don’t understand where in the structure you’re injecting the title either? Also, why is the only tag path? SVGs commonly aren’t just made up of path elements.

This seems to be written for a specific set of SVGs, it’s doesn’t seem general. If you are injecting attributes into SVG markup, you generally always need to parse the markup string (then in this case add properties to nodes & write back to a string without validating)

And that’s the reason I posted here for feedback.

Also, I found a nifty tool yesterday!!

Perhaps this can solve what that Regex messes up!!


P.S. That RegEx part is to beautify the SVG generated, we don’t even have to do that.

It can be auto formatted by prettier plugin or left alone.

And instead of inserting the way it’s done, can be replaced with slots!!

I just remembered,

That latest version of mix formatter will be able to format any Sigil H code anywhere!!!

Yay.

Similar Discussions:

Better Library: (Even Chris McCord contributed to this lib!!)

It’s also updated to the latest version of LiveView!!

Yeees, but that’s a library for writing parsers, it doesn’t just give you the solution. Parsing SVG is non-trivial, it’s a huge spec. You can do it with an XML parser (and xmerl is part of OTP, don’t even have to look for a library), but even then you hit grey areas. It’s not an accident that there are only really three or four relatively complete userland parsers outside of the browser ones (the JS one, the Python one, the Rust one & one other IIRC)

Surely you need an ability to inject attributes, as it’s not going to give you much over just a folder of svgs without that?

I pointed out another library which has novel approach to converting svg to heex component.

I’m going to reverse engineer this later!!

And yes I wont be parsing SVG with regex. That first draft was my inexperience speaking!!