Packages
phosphor_live_view
2.1.0
Typed Phosphor icons for Phoenix LiveView with compile-time SVG components and dynamic helpers.
Current section
Files
Jump to
Current section
Files
lib/phosphor.ex
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 `<Phosphor.airplane />` 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