Code.require_file(Path.expand(Path.join([__DIR__, "phosphor", "generated", "builder.exs"]))) if Code.ensure_loaded?(Phoenix.Component) do defmodule Phosphor do @moduledoc """ Public entrypoint for rendering Phosphor icons inside Phoenix LiveView templates. The module exposes a dynamic `<.icon />` function component that dispatches to the statically generated icon modules committed with the library. Direct icon helpers such as `` will also be generated alongside the icon assets and are not defined in this file. """ use Phoenix.Component require Phosphor.Generated.Builder use Phosphor.Generated.Builder @known_weights ~w(thin light regular bold fill duotone) @default_weight "regular" @weight_atoms Map.new(@known_weights, &{&1, String.to_atom(&1)}) attr(:name, :string, required: true) attr(:weight, :string) attr(:id, :string) attr(:class, :string) attr(:style, :string) attr(:role, :string, default: "img") attr(:aria_label, :string) attr(:rest, :global) @doc """ Renders an icon dynamically by name. Example: <.icon name="airplane" weight="bold" class="h-6 w-6" /> """ def icon(assigns) when is_map(assigns) do assigns = assigns |> __ensure_assigns__() |> normalize_weight() case do_icon(assigns) do {:ok, rendered} -> rendered {:error, reason} -> log_icon_error(assigns[:name], reason) empty_rendered() end end def icon(assigns) when is_list(assigns), do: icon(Map.new(assigns)) def icon(assigns) do raise ArgumentError, "expected assigns to be a map or keyword list, got: #{inspect(assigns)}" end defp do_icon(%{name: name} = assigns) when is_binary(name) do case function_atom(name) do {:ok, function} -> if function_exported?(__MODULE__, function, 1) do {:ok, apply(__MODULE__, function, [assigns])} else dispatch_to_module(name, assigns) end :error -> dispatch_to_module(name, assigns) end end defp do_icon(%{name: name}) do {:error, {:invalid_name, name}} end defp do_icon(_assigns), do: {:error, :missing_name} defp dispatch_to_module(name, assigns) do module = name |> sanitize_icon_name() |> icon_module() with {:module, ^module} <- Code.ensure_loaded(module), available when available != [] <- available_weights(module), {:ok, weight} <- pick_weight(assigns[:weight], available), {:ok, rendered} <- render_weight(module, weight, assigns) do {:ok, rendered} else :error -> {:error, :unknown_icon} [] -> {:error, :no_weights} {:missing_weight, candidates} -> {:error, {:missing_weight, candidates}} {:error, _reason} -> {:error, :unknown_icon} {:module, _other} -> {:error, :unknown_icon} end end defp render_weight(module, weight, assigns) do weight_fun = weight_atom(weight) if function_exported?(module, weight_fun, 1) do {:ok, apply(module, weight_fun, [assigns])} else {:error, {:missing_weight, available_weights(module)}} end rescue kind -> {:error, {:render_failed, {kind, __STACKTRACE__}}} end defp available_weights(module) do Enum.filter(@known_weights, fn weight -> function_exported?(module, weight_atom(weight), 1) end) end defp pick_weight(requested, available) do requested = requested || @default_weight cond do requested in available -> {:ok, requested} @default_weight in available -> {:ok, @default_weight} true -> Enum.at(available, 0) |> case do nil -> {:missing_weight, available} weight -> {:ok, weight} end end end defp icon_module(sanitized_name) do sanitized_name |> Macro.camelize() |> Module.concat(__MODULE__) end defp sanitize_icon_name(name) do name |> String.trim() |> String.replace("-", "_") |> String.downcase() end defp function_atom(name) when is_binary(name) do sanitized = name |> String.trim() |> String.replace("-", "_") |> String.downcase() try do {:ok, String.to_existing_atom(sanitized)} rescue ArgumentError -> :error end end defp function_atom(_), do: :error defp normalize_weight(assigns) do weight = assigns |> Map.get(:weight) |> normalize_weight_value() Map.put(assigns, :weight, weight) end defp normalize_weight_value(nil), do: @default_weight defp normalize_weight_value(weight) when is_atom(weight) do weight |> Atom.to_string() |> normalize_weight_value() end defp normalize_weight_value(weight) when is_binary(weight) do weight |> String.trim() |> String.downcase() |> case do "" -> @default_weight value -> value end end defp normalize_weight_value(_other), do: @default_weight defp weight_atom(weight) do Map.fetch!(@weight_atoms, weight) end defp ensure_rest(assigns) do Map.update(assigns, :rest, %{}, fn nil -> %{} rest when is_map(rest) -> rest rest when is_list(rest) -> Map.new(rest) _ -> %{} end) end defp ensure_role(assigns) do case Map.get(assigns, :role) do nil -> Map.put(assigns, :role, "img") "" -> Map.put(assigns, :role, "img") _ -> assigns end end defp ensure_aria_hidden(%{aria_label: label} = assigns) when is_binary(label) and label not in [nil, ""] do assigns end defp ensure_aria_hidden(assigns) do rest = Map.get(assigns, :rest, %{}) cond do Map.has_key?(rest, "aria-hidden") -> assigns Map.has_key?(rest, "aria-label") -> assigns true -> Map.put(assigns, :rest, Map.put(rest, "aria-hidden", "true")) end end @doc false def __ensure_assigns__(assigns) do assigns |> normalize_assigns() |> ensure_rest() |> ensure_role() |> ensure_aria_hidden() end @doc false def __resolve_weight__(requested, available, default) when is_list(available) do available_set = MapSet.new(available) cond do is_atom(requested) and MapSet.member?(available_set, requested) -> requested is_binary(requested) -> requested |> String.trim() |> String.downcase() |> case do "" -> resolve_default(available, default) sanitized -> Enum.find(available, fn candidate -> Atom.to_string(candidate) == sanitized end) || resolve_default(available, default) end true -> resolve_default(available, default) end end @doc false def __render_svg__(assigns, svg, exclude \\ []) do assigns = assigns |> __ensure_assigns__() |> __prepare_assigns__(exclude) {opening, remainder} = split_svg(svg) attrs = attrs_to_iodata(Map.get(assigns, :attrs, %{})) opening_tag = case attrs do [] -> opening _ -> [opening, " ", Enum.intersperse(attrs, " ")] end rendered = [opening_tag, ">", remainder] %Phoenix.LiveView.Rendered{ static: ["", ""], dynamic: fn _ -> [rendered] end, fingerprint: :erlang.phash2({:phosphor, assigns[:name], assigns[:weight]}), root: true } end defp resolve_default([], default), do: default defp resolve_default(available, default) do cond do default in available -> default available != [] -> hd(available) true -> default end end defp split_svg(svg) do case String.split(svg, ">", parts: 2) do [opening, remainder] -> {opening, remainder} _ -> raise ArgumentError, "invalid SVG markup" end end defp attrs_to_iodata(attrs) when is_map(attrs) do attrs |> Enum.map(&attr_to_iodata/1) |> Enum.reject(&is_nil/1) end defp attr_to_iodata({key, true}) do key |> to_string() end defp attr_to_iodata({_key, value}) when value in [nil, false], do: nil defp attr_to_iodata({key, value}) do key = key |> to_string() escaped = Phoenix.HTML.Engine.html_escape(value) [key, ?=, ?", escaped, ?"] end def __prepare_assigns__(assigns, exclude \\ []) do assigns = normalize_assigns(assigns) rest = normalize_rest(Map.get(assigns, :rest)) attrs = build_attrs(assigns, rest, exclude) assigns |> Map.put(:rest, rest) |> Map.put(:attrs, attrs) end defp build_attrs(assigns, rest, exclude) do assigns |> Phoenix.Component.assigns_to_attributes([:rest, :attrs | exclude]) |> Map.new() |> Map.merge(rest) end defp normalize_assigns(%{} = assigns), do: assigns defp normalize_assigns(list) when is_list(list), do: Map.new(list) defp normalize_rest(nil), do: %{} defp normalize_rest(rest) when is_map(rest), do: rest defp normalize_rest(rest) when is_list(rest), do: Map.new(rest) defp normalize_rest(_), do: %{} defp log_icon_error(name, {:invalid_name, _}) do IO.warn("Phosphor.icon called with invalid name: #{inspect(name)}") end defp log_icon_error(name, {:missing_weight, available}) do IO.warn( "Phosphor.icon weight not available for #{inspect(name)}. Available weights: #{inspect(available)}" ) end defp log_icon_error(name, {:render_failed, {kind, _stack}}) do IO.warn("Phosphor.icon failed to render #{inspect(name)} (#{inspect(kind)})") end defp log_icon_error(name, :missing_name) do IO.warn("Phosphor.icon called without name assign (#{inspect(name)})") end defp log_icon_error(name, :unknown_icon) do IO.warn("Phosphor.icon could not find icon #{inspect(name)}") end defp log_icon_error(name, reason) do IO.warn("Phosphor.icon failed for #{inspect(name)} (#{inspect(reason)})") end defp empty_rendered do assigns = %{} ~H"" end end end