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