if Code.ensure_loaded?(Phoenix.Component) do
defmodule PhoenixImage.Component do
@moduledoc """
Next.js-like image function component for Phoenix templates.
Import this module and use:
<.image src="/images/logo.png" alt="Logo" width={240} height={120} />
"""
use Phoenix.Component
@default_device_sizes [640, 750, 828, 1080, 1200, 1920, 2048, 3840]
@default_image_sizes [16, 32, 48, 64, 96, 128, 256, 384]
@default_optimize_path "/images/optimize"
attr(:src, :string, required: true)
attr(:alt, :string, required: true)
attr(:width, :integer, default: nil)
attr(:height, :integer, default: nil)
attr(:fill, :boolean, default: false)
attr(:sizes, :string, default: nil)
attr(:quality, :integer, default: nil)
attr(:format, :string, default: "webp", values: ~w(webp avif jpg png))
attr(:loading, :string, default: "lazy", values: ~w(lazy eager))
attr(:preload, :boolean, default: false)
attr(:unoptimized, :boolean, default: false)
attr(:upscale, :boolean, default: false)
attr(:path, :string, default: nil)
attr(:allowed_hosts, :list, default: nil)
attr(:rest, :global)
def image(assigns) do
attrs = image_attrs(assigns)
assigns = assign(assigns, :attrs, attrs)
~H"""
<img {@attrs} />
"""
end
@doc """
Returns normalized `<img>` attributes for the given image assigns.
"""
def image_attrs(assigns) when is_map(assigns) do
assigns
|> validate_assigns!()
|> build_attrs()
end
def image_attrs(assigns) when is_list(assigns) do
assigns
|> Enum.into(%{})
|> image_attrs()
end
defp validate_assigns!(assigns) do
fill = Map.get(assigns, :fill, false)
width = Map.get(assigns, :width)
height = Map.get(assigns, :height)
cond do
fill and (width || height) ->
raise ArgumentError, "fill=true cannot be used with width/height"
not fill and (not is_integer(width) or not is_integer(height)) ->
raise ArgumentError, "width and height are required when fill is false"
not fill and (width <= 0 or height <= 0) ->
raise ArgumentError, "width and height must be positive integers"
true ->
validate_source_host!(assigns)
assigns
end
end
defp validate_source_host!(assigns) do
src = Map.fetch!(assigns, :src)
uri = URI.parse(src)
cond do
String.starts_with?(src, "/") ->
:ok
uri.scheme in ["http", "https"] and is_binary(uri.host) ->
allowed_hosts =
(Map.get(assigns, :allowed_hosts) || component_config()[:allowed_hosts] || [])
|> Enum.map(&to_string/1)
if uri.host in allowed_hosts do
:ok
else
raise ArgumentError, "src host not allowed: #{uri.host}"
end
true ->
raise ArgumentError, "src must be an absolute http(s) URL or root-relative path"
end
end
defp build_attrs(assigns) do
src = Map.fetch!(assigns, :src)
alt = Map.fetch!(assigns, :alt)
width = Map.get(assigns, :width)
height = Map.get(assigns, :height)
fill = Map.get(assigns, :fill, false)
preload = Map.get(assigns, :preload, false)
loading = Map.get(assigns, :loading, "lazy")
sizes = Map.get(assigns, :sizes)
source =
if Map.get(assigns, :unoptimized, false) do
src
else
optimize_url(src, width, height, assigns)
end
attrs =
%{
src: source,
alt: alt,
loading: if(preload, do: "eager", else: loading),
fetchpriority: if(preload, do: "high", else: nil),
width: if(fill, do: nil, else: width),
height: if(fill, do: nil, else: height),
sizes: sizes
}
|> maybe_put_srcset(assigns, fill)
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Enum.into(%{})
Map.merge(attrs, Map.get(assigns, :rest, %{}))
end
defp maybe_put_srcset(attrs, assigns, fill) do
if Map.get(assigns, :unoptimized, false) do
attrs
else
widths = srcset_widths(assigns, fill)
srcset =
widths
|> Enum.map(fn width ->
"#{optimize_url(Map.fetch!(assigns, :src), width, nil, assigns)} #{width}w"
end)
|> Enum.join(", ")
Map.put(attrs, :srcset, srcset)
end
end
defp srcset_widths(assigns, fill) do
config = component_config()
width = Map.get(assigns, :width)
sizes = Map.get(assigns, :sizes)
cond do
fill or (is_binary(sizes) and sizes != "") ->
(config[:device_sizes] || @default_device_sizes)
|> Enum.uniq()
|> Enum.sort()
is_integer(width) ->
candidates = config[:image_sizes] || @default_image_sizes
[width | candidates]
|> Enum.filter(&(&1 <= width))
|> Enum.uniq()
|> Enum.sort()
true ->
config[:device_sizes] || @default_device_sizes
end
end
defp optimize_url(src, width, height, assigns) do
path =
Map.get(assigns, :path) || component_config()[:optimize_path] || @default_optimize_path
params =
%{
"src" => src,
"w" => width,
"h" => height,
"q" => Map.get(assigns, :quality),
"f" => Map.get(assigns, :format, "webp"),
"upscale" => if(Map.get(assigns, :upscale, false), do: "true", else: nil)
}
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Enum.into(%{})
encoded = URI.encode_query(params)
path <> "?" <> encoded
end
defp component_config do
Application.get_env(:phx_image, :image_component, [])
end
end
else
defmodule PhoenixImage.Component do
@moduledoc """
Stub module when `:phoenix_live_view` is not available.
Add `{:phoenix_live_view, "~> 1.0"}` to your app dependencies to use
`PhoenixImage.Component`.
"""
@live_view_message "PhoenixImage.Component requires the optional dependency :phoenix_live_view"
@doc """
Raises unless `:phoenix_live_view` is added as a dependency.
"""
def image(_assigns), do: raise(ArgumentError, @live_view_message)
@doc """
Raises unless `:phoenix_live_view` is added as a dependency.
"""
def image_attrs(_assigns), do: raise(ArgumentError, @live_view_message)
end
end