Skip to main content

lib/phoenix_image/component.ex

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