Skip to main content

lib/astral/image/config.ex

defmodule Astral.Image.Config do
  @moduledoc """
  Configuration for Astral's build-time image pipeline.

  Astral writes optimized image variants into the same browser asset output
  directory as Volt-managed JavaScript and CSS, while keeping image semantics in
  Astral. The cache directory stores generated variants between builds.
  """

  @type t :: %__MODULE__{
          cache_dir: String.t(),
          default_format: atom(),
          fallback_format: atom(),
          quality: pos_integer(),
          widths: [pos_integer()],
          formats: [atom()],
          source_dirs: [String.t()],
          remote_patterns: [Astral.Image.Remote.Pattern.t()],
          concurrency: pos_integer()
        }

  defstruct cache_dir: nil,
            default_format: :webp,
            fallback_format: :jpeg,
            quality: 82,
            widths: [480, 768, 1024, 1440],
            formats: [:webp],
            source_dirs: [],
            remote_patterns: [],
            concurrency: System.schedulers_online()

  @doc "Build normalized image configuration from site options."
  @spec new(keyword(), Astral.Config.t() | nil) :: t()
  def new(opts \\ [], site_config \\ nil) do
    root = Keyword.get(opts, :root) || (site_config && site_config.root) || "."
    root = Path.expand(root)

    cache_dir =
      opts
      |> Keyword.get(:cache_dir, Path.join([root, "_build", "astral", "image-cache"]))
      |> Path.expand(root)

    source_dirs =
      opts
      |> Keyword.get(:source_dirs, default_source_dirs(site_config, root))
      |> Enum.map(&Path.expand(&1, root))

    %__MODULE__{
      cache_dir: cache_dir,
      default_format:
        opts |> Keyword.get(:default_format, :webp) |> Astral.Image.Format.output!(),
      fallback_format:
        opts |> Keyword.get(:fallback_format, :jpeg) |> Astral.Image.Format.output!(),
      quality: Keyword.get(opts, :quality, 82),
      widths: opts |> Keyword.get(:widths, [480, 768, 1024, 1440]) |> normalize_widths(),
      formats:
        opts
        |> Keyword.get(:formats, [:webp])
        |> Enum.map(&Astral.Image.Format.output!/1),
      source_dirs: source_dirs,
      remote_patterns: opts |> Keyword.get_values(:allow_remote) |> normalize_remote_patterns(),
      concurrency: max(Keyword.get(opts, :concurrency, System.schedulers_online()), 1)
    }
  end

  defp default_source_dirs(nil, root), do: [Path.join(root, "assets"), root]

  defp default_source_dirs(config, _root) do
    [config.assets, config.root, config.public]
  end

  defp normalize_remote_patterns(patterns) do
    patterns
    |> List.flatten()
    |> Enum.map(fn pattern ->
      case Astral.Image.Remote.Pattern.parse(pattern) do
        {:ok, pattern} ->
          pattern

        {:error, reason} ->
          raise ArgumentError, "invalid remote image pattern: #{inspect(reason)}"
      end
    end)
  end

  defp normalize_widths(widths) do
    widths
    |> Enum.map(&to_integer/1)
    |> Enum.filter(&(&1 > 0))
    |> Enum.uniq()
    |> Enum.sort()
  end

  defp to_integer(value) when is_integer(value), do: value

  defp to_integer(value) when is_binary(value) do
    case Integer.parse(value) do
      {integer, ""} -> integer
      _ -> 0
    end
  end
end