Skip to main content

lib/astral/plugin_runner.ex

defmodule Astral.PluginRunner do
  @moduledoc """
  Execute Astral plugin hooks.
  """

  @type plugin :: Astral.Plugin.plugin()

  @doc "Normalize and order plugins."
  @spec plugins([plugin()] | plugin() | nil) :: [plugin()]
  def plugins(plugins) do
    plugins
    |> List.wrap()
    |> Enum.reject(&is_nil/1)
    |> order_plugins()
  end

  @doc "Run config hooks in sequence."
  @spec config([plugin()], Astral.Config.t()) :: Astral.Config.t()
  def config(plugins, config) do
    Enum.reduce(plugins(plugins), config, fn plugin, acc ->
      call_optional(plugin, :config, [acc], acc)
    end)
  end

  @doc "Run build_start hooks until one returns an error."
  @spec build_start([plugin()], Astral.Config.t()) :: :ok | {:error, term()}
  def build_start(plugins, config) do
    Enum.reduce_while(plugins(plugins), :ok, fn plugin, :ok ->
      case call_optional(plugin, :build_start, [config], :ok) do
        :ok -> {:cont, :ok}
        {:error, _reason} = error -> {:halt, error}
      end
    end)
  end

  @doc "Run site_discovered hooks in sequence."
  @spec site_discovered([plugin()], Astral.Site.t()) :: Astral.Site.t()
  def site_discovered(plugins, site) do
    Enum.reduce(plugins(plugins), site, fn plugin, acc ->
      call_optional(plugin, :site_discovered, [acc], acc)
    end)
  end

  @doc "Collect generated routes from plugins."
  @spec routes([plugin()], Astral.Site.t()) :: [Astral.Route.t()]
  def routes(plugins, site) do
    plugins
    |> plugins()
    |> Enum.flat_map(fn plugin -> call_optional(plugin, :routes, [site], []) end)
    |> Enum.map(&Astral.Route.with_output_path(&1, site.config))
  end

  @doc "Render a generated route with the first plugin that owns it."
  @spec render_route([plugin()], Astral.Route.t(), Astral.Site.t()) ::
          {:ok, iodata(), String.t()}
          | {:ok, iodata(), String.t(), [{String.t(), String.t()}]}
          | {:error, term()}
          | nil
  def render_route(plugins, route, site) do
    Enum.find_value(plugins(plugins), fn plugin ->
      case call_optional(plugin, :render_route, [route, site], nil) do
        {:ok, body} -> {:ok, body, route.content_type}
        {:ok, body, content_type} -> {:ok, body, content_type}
        {:ok, body, content_type, headers} -> {:ok, body, content_type, headers}
        {:error, _reason} = error -> error
        nil -> nil
      end
    end)
  end

  @doc "Run render_page hooks in sequence, piping HTML through each plugin."
  @spec render_page([plugin()], String.t(), Astral.Page.t(), Astral.Site.t()) ::
          {:ok, String.t()} | {:error, term()}
  def render_page(plugins, html, page, site) do
    Enum.reduce_while(plugins(plugins), {:ok, html}, fn plugin, {:ok, acc} ->
      case call_optional(plugin, :render_page, [acc, page, site], nil) do
        {:ok, transformed} -> {:cont, {:ok, transformed}}
        {:error, _reason} = error -> {:halt, error}
        nil -> {:cont, {:ok, acc}}
      end
    end)
  end

  @doc "Run build_done hooks until one returns an error."
  @spec build_done([plugin()], Astral.BuildResult.t()) :: :ok | {:error, term()}
  def build_done(plugins, result) do
    Enum.reduce_while(plugins(plugins), :ok, fn plugin, :ok ->
      case call_optional(plugin, :build_done, [result], :ok) do
        :ok -> {:cont, :ok}
        {:error, _reason} = error -> {:halt, error}
      end
    end)
  end

  @doc "Run an optional hook with Volt-style extra-arity opts support."
  @spec call_optional(plugin(), atom(), [term()], term()) :: term()
  def call_optional(plugin, fun, args, default) do
    module = plugin_module(plugin)
    opts = plugin_opts(plugin)

    cond do
      Code.ensure_loaded?(module) and function_exported?(module, fun, length(args) + 1) ->
        apply(module, fun, args_with_opts(args, opts))

      Code.ensure_loaded?(module) and function_exported?(module, fun, length(args)) ->
        apply(module, fun, args)

      true ->
        default
    end
  end

  defp order_plugins(plugins) do
    plugins
    |> Enum.with_index()
    |> Enum.sort_by(fn {plugin, index} -> {order_rank(plugin), index} end)
    |> Enum.map(fn {plugin, _index} -> plugin end)
  end

  defp order_rank(plugin) do
    case call_optional(plugin, :enforce, [], nil) do
      :pre -> 0
      :post -> 2
      _ -> 1
    end
  end

  defp args_with_opts(args, opts), do: args |> Enum.reverse() |> then(&Enum.reverse([opts | &1]))

  defp plugin_module({module, _opts}), do: module
  defp plugin_module(module), do: module

  defp plugin_opts({_module, opts}), do: opts
  defp plugin_opts(_module), do: []
end