Phoenix Components and macros for generating icons

Hi dear Alchemists.

I have a small Phoenix app using LiveView and Tailwind. I’ve been using FontAwesome with @fontface so far but I’d prefer to user SVG icons.

So I’ve been experimenting with generating SVGs at compile time with the use of macros. My first approach was to scan the priv/fontawesome/*/*.svg dir and generate a function for each icon. While this is quite nice to use, this approach has two drawbacks:

  • Compiling the helper module takes quite some time (1min).
  • It generates a lot of code that I might never use and increases binary size.

So I went with a different approach:

  defmacro icon(opts) do
    {name, opts} = Keyword.pop!(opts, :name)
    {style, opts} = Keyword.pop(opts, :style, "duotone")

    path = Path.join([@root, style, String.replace(name, "-", "_") <> ".svg"])
    icon = File.read!(path)
    {i, _} = :binary.match(icon, ">")
    {head, body} = String.split_at(icon, i)

    attrs =
      for {k, v} <- opts do
        safe_k = k |> Atom.to_string() |> String.replace("_", "-") |> Phoenix.HTML.Safe.to_iodata()
        safe_v = v |> Phoenix.HTML.Safe.to_iodata()
        {:safe, [?\s, safe_k, ?=, ?", safe_v, ?"]}
      end
    {:safe, [head, Phoenix.HTML.Safe.to_iodata(attrs), body]}
  end

The icon/1 macro generates the required SVG at compile time only when needed. In my template, I’m using it like this:

<%= icon name: "bell", style: "solid", class: "h-6 w-6" %>

So I’m thinking it’s working quite well with Phoenix EEx and follow the idea of generating static IO-data at compile time.

I’d like to expand the idea further and rewrite my macro to work as a Phoenix.Component. But the dark world of AST is quite hard to approach and it’s seems much harder than I thought.

defmacro icon(var!(assigns)) do
  # somehow assigns might not been evaluated yet. the AST is {:x1, [], :elixir_fn}

I can retrieve the assigns in a quote block. But I’d like to generate the SVG at compile time (so outside any quote block).

defmacro icon(var!(assigns)) do
  # I still need to retrieve @name and @style from the assigns
  # in order load my SVG here.

  quote do
      var!(attrs) = Phoenix.LiveView.Helpers.assigns_to_attributes(var!(assigns))
      var!(assigns) = Phoenix.LiveView.assign(var!(assigns), :attrs, var!(attrs))

      unquote(
        EEx.compile_string(head <> "{@attrs}" <> body,
          engine: Phoenix.LiveView.HTMLEngine,
          file: __ENV__.file,
          line: __ENV__.line + 1,
          module: __ENV__.module,
          indentation: 0
        )
      )
  end
end

Maybe Phoenix.Component and HEEX provide the assigns only at runtime and I’m trying to evaluate them before they even exist?

I’ve been trying to evaluate the assigns quoted expression with Code.evaluate_quoted/3 without success. It also seems counter-intuitive to do so.

Any help warmly welcome :innocent:

Take a look at how Heroicons does it

1 Like

It does it in a similar fashion than I did in my first attempt. Generating functions for all available icons. But FontAwesome provides much more icons (see drawbacks in my first post).

I have a mix task that I run which checks remixicons.txt, where I list the ones I want, and generates a module with components for each. Not as fancy as a macro but it does the job.

Generating loads of unused functions certainly isn’t efficient, but inlining a ~thousand byte icon into the template every time icon is called doesn’t seem great either.

+1 for explicitly listing the subset of icons you care about in one place - you could take that list and generate a bunch of icon heads in a module, similar to how Phoenix compiles templates into many render heads. Then you’d have the efficiency of only having code for the icons you use, but also the efficiency of only having the SVG data embedded into the AST in one place.

I’ve recently put this together: GitHub - bonfire-networks/iconify_ex: Iconify (giving access to 100,000+ icons from 100+ icon sets from https://iconify.design)
Open to any feedback and can put it on hex.pm if there’s interest…

1 Like