lib/serum/plugin.ex

defmodule Serum.Plugin do
  @moduledoc """
  A behaviour that all Serum plugin module must implement.

  This module allows experienced Serum users and developers to make their own
  Serum plugins which can extend the functionality of Serum.

  A Serum plugin can...

  - Alter contents of input or output files,
  - Execute arbitrary codes during some stages of site building,
  - And optionally provide extra Mix tasks that extends Serum.

  ## For Plugin Developers

  In order for a Serum plugin to work, you must implement at least these
  four callbacks:

  - `name/0`
  - `version/0`
  - `elixir/0`
  - `serum/0`
  - `description/0`
  - `implements/0`

  Also there are a number of other callbacks you can optionally implement.
  Read the rest of the documentation for this module to see which callbacks
  you can implement and what each callback should do.

  ## For Plugin Users

  To enable Serum plugins, add a `plugins` key to your `serum.exs`(if it does
  not exist), and put names of Serum plugin modules there.

      %{
        plugins: [
          Awesome.Serum.Plugin,
          Great.Serum.Plugin
        ]
      }

  You can also restrict some plugins to run only in specific Mix environments.
  For example, if plugins are configured like the code below, only
  `Awesome.Serum.Plugin` plugin will be loaded when `MIX_ENV` is set to `prod`.

      %{
        plugins: [
          Awesome.Serum.Plugin,
          {Great.Serum.Plugin, only: :dev},
          {Another.Serum.Plugin, only: [:dev, :test]}
        ]
      }

  The order of plugins is important, as Serum will call plugins one by one,
  from the first item to the last one. Therefore these two configurations below
  may produce different results.

  Configuration 1:

      %{
        plugins: [
          Awesome.Serum.Plugin,
          Another.Serum.Plugin
        ]
      }

  Configuration 2:

      %{
        plugins: [
          Another.Serum.Plugin,
          Awesome.Serum.Plugin
        ]
      }
  """

  use Agent
  require Serum.Plugin.Macros
  import Serum.IOProxy, only: [put_msg: 2]
  import Serum.Plugin.Macros
  alias Serum.File
  alias Serum.Fragment
  alias Serum.Page
  alias Serum.Plugin.Loader
  alias Serum.Post
  alias Serum.PostList
  alias Serum.Result
  alias Serum.Template

  defstruct [:module, :name, :version, :description, :implements, :args]

  @type t :: %__MODULE__{
          module: atom(),
          name: binary(),
          version: binary(),
          description: binary(),
          implements: [atom()],
          args: term()
        }

  @type spec :: atom() | {atom(), plugin_options()}
  @type plugin_options :: [only: atom() | [atom()], args: term()]

  @old_callback_arities [
    build_started: 2,
    reading_pages: 1,
    reading_posts: 1,
    reading_templates: 1,
    processing_page: 1,
    processing_post: 1,
    processing_template: 1,
    processed_page: 1,
    processed_post: 1,
    processed_template: 1,
    processed_list: 1,
    processed_pages: 1,
    processed_posts: 1,
    rendering_fragment: 2,
    rendered_fragment: 1,
    rendered_page: 1,
    wrote_file: 1,
    build_succeeded: 2,
    build_failed: 3,
    finalizing: 2
  ]

  @optional_callbacks @old_callback_arities
                      |> Enum.map(fn {name, arity} ->
                        [{name, arity}, {name, arity + 1}]
                      end)
                      |> List.flatten()

  @required_msg "You must implement this callback, or the plugin may fail."

  #
  # Required Callbacks
  #

  @doc """
  Returns the name of the plugin.

  #{@required_msg}
  """
  @callback name() :: binary()

  @doc """
  Returns the version of the plugin.

  The returned version string must follow the semantic versioning scheme.

  #{@required_msg}
  """
  @callback version() :: binary()

  @doc """
  Returns the version requirement of Elixir.

  Refer to [this document](https://hexdocs.pm/elixir/Version.html#module-requirements)
  for the string format.

  #{@required_msg}
  """
  @callback elixir() :: binary()

  @doc """
  Returns the version requirement of Serum.

  Refer to [this document](https://hexdocs.pm/elixir/Version.html#module-requirements)
  for the string format.

  #{@required_msg}
  """
  @callback serum() :: binary()

  @doc """
  Returns the short description of the plugin.

  #{@required_msg}
  """
  @callback description() :: binary()

  @doc """
  Returns a list of optional callbacks which the plugin implements.

  Each list item can be in one of two forms:

  - `{callback_name, arity}`
  - `callback_name` - This is deprecated and left for compatibility. New Serum
    plugins must use the above format.

  For example, if your plugin implements `build_started/2` and `finalizing/2`,
  you must implement this callback so that it returns `[build_started: 2,
  finalizing: 2]`.

  #{@required_msg}
  """
  @callback implements() :: [atom() | {atom(), integer()}]

  #
  # Optional Callbacks
  #

  @doc """
  Called right after the build process has started. Some necessary OTP
  applications or processes should be started here.
  """
  defcallback build_started(src :: binary(), dest :: binary()) :: Result.t()

  @doc """
  Called before reading input files.

  Plugins can manipulate the list of files to be read and pass it to
  the next plugin.
  """
  defcallback reading_pages(files :: [binary()]) :: Result.t([binary()])

  @doc """
  Called before reading input files.

  Plugins can manipulate the list of files to be read and pass it to
  the next plugin.
  """
  defcallback reading_posts(files :: [binary()]) :: Result.t([binary()])

  @doc """
  Called before reading input files.

  Plugins can manipulate the list of files to be read and pass it to
  the next plugin.
  """
  defcallback reading_templates(files :: [binary()]) :: Result.t([binary()])

  @doc """
  Called before Serum processes each input file.

  Plugins can alter the raw contents of input files here.
  """
  defcallback processing_page(file :: File.t()) :: Result.t(File.t())

  @doc """
  Called before Serum processes each input file.

  Plugins can alter the raw contents of input files here.
  """
  defcallback processing_post(file :: File.t()) :: Result.t(File.t())

  @doc """
  Called before Serum processes each input file.

  Plugins can alter the raw contents of input files here.
  """
  defcallback processing_template(file :: File.t()) :: Result.t(File.t())

  @doc """
  Called after Serum has processed each input file and produced
  the resulting struct.

  Plugins can alter the processed contents and metadata here.
  """
  defcallback processed_page(page :: Page.t()) :: Result.t(Page.t())

  @doc """
  Called after Serum has processed each input file and produced
  the resulting struct.

  Plugins can alter the processed contents and metadata here.
  """
  defcallback processed_post(post :: Post.t()) :: Result.t(Post.t())

  @doc """
  Called after Serum has processed each input file and produced
  the resulting struct.

  Plugins can alter the AST and its metadata here.
  """
  defcallback processed_template(template :: Template.t()) :: Result.t(Template.t())

  @doc """
  Called after Serum has processed each input file and produced
  the resulting struct.

  Plugins can alter the processed contents and metadata here.
  """
  defcallback processed_list(list :: PostList.t()) :: Result.t(PostList.t())

  @doc "Called after Serum has successfully processed all pages."
  defcallback processed_pages(pages :: [Page.t()]) :: Result.t([Page.t()])

  @doc "Called after Serum has successfully processed all blog posts."
  defcallback processed_posts(posts :: [Post.t()]) :: Result.t([Post.t()])

  @doc """
  Called while each fragment is being constructed.

  Plugins can alter the HTML tree of its contents (which is generated by
  Floki). It is recommended to implement this callback if you want to modify
  the HTML document without worrying about breaking it.
  """
  defcallback rendering_fragment(html :: Floki.html_tree(), metadata :: map()) ::
                Result.t(Floki.html_tree())

  @doc """
  Called after producing a HTML fragment for each page.

  Plugins can modify the contents and metadata of each fragment here.
  """
  defcallback rendered_fragment(frag :: Fragment.t()) :: Result.t(Fragment.t())

  @doc """
  Called when Serum has rendered a full page and it's about to write to an
  output file.

  Plugins can alter the raw contents of the page to be written.
  """
  defcallback rendered_page(file :: File.t()) :: Result.t(File.t())

  @doc """
  Called after writing each output to a file.
  """
  defcallback wrote_file(file :: File.t()) :: Result.t()

  @doc """
  Called if the whole build process has finished successfully.
  """
  defcallback build_succeeded(src :: binary(), dest :: binary()) :: Result.t()

  @doc """
  Called if the build process has failed for some reason.
  """
  defcallback build_failed(
                src :: binary(),
                dest :: binary(),
                result :: Result.t() | Result.t(term)
              ) ::
                Result.t()

  @doc """
  Called right before Serum exits, whether the build has succeeded or not.

  This is the place where you should clean up any temporary resources created
  in `build_started/2` callback.
  """
  defcallback finalizing(src :: binary(), dest :: binary()) :: Result.t()

  #
  # Plugin Consumer Functions
  #

  @doc false
  @spec start_link(any()) :: {:error, any()} | {:ok, pid()}
  def start_link(_) do
    Agent.start_link(fn -> %{} end, name: __MODULE__)
  end

  @doc false
  @spec load_plugins([spec()]) :: Result.t([t()])
  def load_plugins(plugin_specs), do: Loader.load_plugins(plugin_specs)

  @doc false
  @spec show_info([t()]) :: :ok
  def show_info(plugins)
  def show_info([]), do: :ok

  def show_info(plugins) do
    Enum.each(plugins, fn p ->
      msg = [
        :bright,
        p.name,
        " v",
        to_string(p.version),
        :reset,
        " (#{module_name(p.module)})\n",
        :light_black,
        p.description
      ]

      put_msg(:plugin, msg)
    end)
  end

  action build_started(src :: binary(), dest :: binary()) :: Result.t()
  function reading_pages(files :: [binary()]) :: Result.t([binary()])
  function reading_posts(files :: [binary()]) :: Result.t([binary()])
  function reading_templates(files :: [binary()]) :: Result.t([binary()])
  function processing_page(file :: File.t()) :: Result.t(File.t())
  function processing_post(file :: File.t()) :: Result.t(File.t())
  function processing_template(file :: File.t()) :: Result.t(File.t())
  function processed_page(page :: Page.t()) :: Result.t(Page.t())
  function processed_post(post :: Post.t()) :: Result.t(Post.t())
  function processed_template(template :: Template.t()) :: Result.t(Template.t())
  function processed_list(list :: PostList.t()) :: Result.t(PostList.t())
  function processed_pages(pages :: [Page.t()]) :: Result.t([Page.t()])
  function processed_posts(posts :: [Post.t()]) :: Result.t([Post.t()])

  function rendering_fragment(html :: Floki.html_tree(), metadata :: map()) ::
             Result.t(Floki.html_tree())

  function rendered_fragment(frag :: Fragment.t()) :: Result.t(Fragment.t())
  function rendered_page(file :: File.t()) :: Result.t(File.t())
  action wrote_file(file :: File.t()) :: Result.t()
  action build_succeeded(src :: binary(), dest :: binary()) :: Result.t()

  action build_failed(src :: binary(), dest :: binary(), result :: Result.t() | Result.t(term)) ::
           Result.t()

  action finalizing(src :: binary(), dest :: binary()) :: Result.t()

  @spec call_action(atom(), [term()]) :: Result.t()
  defp call_action(fun, args) do
    __MODULE__
    |> Agent.get(&(&1[fun] || []))
    |> do_call_action(fun, args)
  end

  @spec do_call_action([{integer(), t()}], atom(), [term()]) :: Result.t()
  defp do_call_action(arity_and_plugins, fun, args)
  defp do_call_action([], _fun, _args), do: :ok

  defp do_call_action([{arity, plugin} | arity_and_plugins], fun, args) do
    new_args = update_callback_args(args, plugin, fun, arity)

    case apply(plugin.module, fun, new_args) do
      :ok ->
        do_call_action(arity_and_plugins, fun, args)

      {:error, _} = error ->
        error

      term ->
        message =
          "#{module_name(plugin.module)}.#{fun} returned " <>
            "an unexpected value: #{inspect(term)}"

        {:error, message}
    end
  rescue
    exception -> handle_exception(exception, plugin.module, fun)
  end

  @spec call_function(atom(), [term()]) :: Result.t(term())
  defp call_function(fun, [arg | args]) do
    __MODULE__
    |> Agent.get(&(&1[fun] || []))
    |> do_call_function(fun, args, arg)
  end

  @spec do_call_function([{integer, t()}], atom(), [term()], term()) :: Result.t(term())
  defp do_call_function(arity_and_plugins, fun, args, acc)
  defp do_call_function([], _fun, _args, acc), do: {:ok, acc}

  defp do_call_function([{arity, plugin} | arity_and_plugins], fun, args, acc) do
    new_args = update_callback_args(args, plugin, fun, arity)

    case apply(plugin.module, fun, [acc | new_args]) do
      {:ok, new_acc} ->
        do_call_function(arity_and_plugins, fun, args, new_acc)

      {:error, _} = error ->
        error

      term ->
        message =
          "#{module_name(plugin.module)}.#{fun} returned " <>
            "an unexpected value: #{inspect(term)}"

        {:error, message}
    end
  rescue
    exception -> handle_exception(exception, plugin.module, fun)
  end

  @spec update_callback_args(list(), t(), atom(), integer()) :: list()
  defp update_callback_args(args, plugin, fun, arity) do
    if arity === @old_callback_arities[fun] do
      old_fun_name = "#{fun}/#{arity}"
      new_fun_name = "#{fun}/#{arity + 1}"

      msg = [
        old_fun_name,
        " is deprecated. Use ",
        new_fun_name,
        " instead.\nfrom plugin: ",
        plugin.name,
        " (",
        module_name(plugin.module),
        ")"
      ]

      put_msg(:warn, IO.iodata_to_binary(msg))

      args
    else
      args ++ [plugin.args]
    end
  end

  @spec handle_exception(Exception.t(), atom(), atom()) :: Result.t()
  defp handle_exception(exception, module, fun) do
    ex_name = module_name(exception.__struct__)
    ex_msg = Exception.message(exception)
    msg = "#{ex_name} at #{module_name(module)}.#{fun}: #{ex_msg}"

    {:error, msg}
  end

  @spec module_name(atom()) :: binary()
  defp module_name(module) do
    module |> to_string() |> String.replace_prefix("Elixir.", "")
  end
end