defmodule PhoenixIconify do @moduledoc """ Phoenix components for Iconify icons with compile-time discovery. """ use Phoenix.Component require Logger alias Phoenix.HTML @doc """ Renders an icon as an inline SVG. ## Examples <.icon name="lucide:settings" class="size-5" /> <.icon name="hero-user" class="size-6 text-zinc-500" /> <.icon name="mdi:account" label="Account" /> <.icon name="lucide:x" phx-click="close" /> """ attr(:name, :string, required: true, doc: "Icon name (for example, lucide:settings)") attr(:class, :string, default: nil, doc: "CSS classes") attr(:label, :string, default: nil, doc: "Accessible label. Makes the SVG role=img.") attr(:title, :string, default: nil, doc: "Optional SVG title text") attr(:size, :any, default: nil, doc: "Width and height to apply together") attr(:width, :any, default: nil, doc: "SVG width attribute") attr(:height, :any, default: nil, doc: "SVG height attribute") attr(:rotate, :integer, default: 0, doc: "Additional 90-degree rotations") attr(:flip, :string, default: nil, doc: "Flip direction: horizontal, vertical, or both") attr(:h_flip, :boolean, default: false, doc: "Apply horizontal flip") attr(:v_flip, :boolean, default: false, doc: "Apply vertical flip") attr(:rest, :global, doc: "Additional SVG attributes") def icon(assigns) do icon_data = get_icon(assigns.name) render_data = render_data(icon_data, assigns) svg_attrs = svg_attrs(assigns, render_data) assigns = assigns |> assign(:body, render_data.body) |> assign(:svg_attrs, svg_attrs) ~H""" <%= if @title do %><%= @title %><% end %><%= HTML.raw(@body) %> """ end @doc """ Gets icon data by name. """ def get_icon(name) when is_binary(name) do normalized = normalize_name(name) icons = PhoenixIconify.Manifest.get_icons() case Map.fetch(icons, normalized) do {:ok, icon_data} -> icon_data :error -> handle_missing_icon(normalized, name) end end def get_icon(nil) do maybe_warn("Icon name is nil") fallback_icon() end def get_icon(other) do maybe_warn("Invalid icon name: #{inspect(other)}") fallback_icon() end @doc """ Checks if an icon exists in the manifest. """ def icon_exists?(name) when is_binary(name) do normalized = normalize_name(name) PhoenixIconify.Manifest.get_icons() |> Map.has_key?(normalized) end def icon_exists?(_), do: false @doc """ Lists all available icon names from the manifest. """ def list_icons do PhoenixIconify.Manifest.get_icons() |> Map.keys() |> Enum.sort() end @doc """ Normalizes icon names to the canonical Iconify format. """ def normalize_name("hero-" <> rest) do normalized = rest |> String.replace_suffix("-micro", "-16-solid") |> String.replace_suffix("-mini", "-20-solid") "heroicons:#{normalized}" end def normalize_name(name) when is_binary(name), do: name def normalize_name(_), do: nil defp render_data(%Iconify.Icon{} = icon, assigns) do {h_flip, v_flip} = flip_options(assigns.flip, assigns.h_flip, assigns.v_flip) {body, viewbox} = Iconify.SVG.build_body(icon, rotate: assigns.rotate, h_flip: h_flip, v_flip: v_flip) %{body: body, viewbox: viewbox} end defp flip_options(flip, h_flip, v_flip) do flip = to_string(flip || "") { h_flip or String.contains?(flip, "horizontal") or String.contains?(flip, "both"), v_flip or String.contains?(flip, "vertical") or String.contains?(flip, "both") } end defp svg_attrs(assigns, render_data) do base = %{ xmlns: "http://www.w3.org/2000/svg", viewBox: render_data.viewbox, fill: "currentColor", class: assigns.class } base |> maybe_put(:width, assigns.width || assigns.size) |> maybe_put(:height, assigns.height || assigns.size) |> Map.merge(accessibility_attrs(assigns)) |> Map.merge(assigns.rest) |> Enum.reject(fn {_key, value} -> is_nil(value) end) |> Map.new() end defp accessibility_attrs(%{label: label, title: title}) when is_binary(label) or is_binary(title) do %{ role: "img", "aria-label": label || title } end defp accessibility_attrs(_assigns), do: %{"aria-hidden": "true"} defp maybe_put(map, _key, nil), do: map defp maybe_put(map, key, value), do: Map.put(map, key, value) defp handle_missing_icon(normalized, original) do maybe_warn(missing_icon_message(normalized, original)) if runtime_fetch_enabled?() do fetch_icon_at_runtime(normalized) else fallback_icon() end end defp missing_icon_message(normalized, original) do suffix = if normalized != original, do: " (from #{original})", else: "" "Icon not found: #{normalized}#{suffix}. " <> "If this icon name is dynamic, add it to config :phoenix_iconify, extra_icons: [#{inspect(normalized)}] and run mix compile." end defp fetch_icon_at_runtime(name) do case Iconify.parse_name(name) do {:ok, prefix, icon_name} -> case Iconify.Fetcher.fetch_icon(prefix, icon_name) do {:ok, icon} -> icon = %{icon | name: name} PhoenixIconify.Manifest.add_icon(name, icon) icon {:error, _} -> fallback_icon() end :error -> fallback_icon() end rescue _ -> fallback_icon() end defp maybe_warn(message) do if Application.get_env(:phoenix_iconify, :warn_on_missing, true) do Logger.warning("[PhoenixIconify] #{message}") end end defp runtime_fetch_enabled? do Application.get_env(:phoenix_iconify, :runtime_fetch, false) end defp fallback_icon do case Application.get_env(:phoenix_iconify, :fallback) do nil -> default_fallback_icon() fallback_name -> icons = PhoenixIconify.Manifest.get_icons() Map.get(icons, normalize_name(fallback_name), default_fallback_icon()) end end defp default_fallback_icon do %Iconify.Icon{ name: "heroicons:question-mark-circle", body: ~S(), width: 24, height: 24 } end end