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""" """ end @doc """ Returns normalized `` 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