lib/nimble_publisher.ex

defmodule NimblePublisher do
  @external_resource "README.md"
  @moduledoc "README.md"
             |> File.read!()
             |> String.split("<!-- MDOC !-->")
             |> Enum.fetch!(1)

  @doc false
  defmacro __using__(opts) do
    quote bind_quoted: [opts: opts] do
      {from, paths} = NimblePublisher.__extract__(__MODULE__, opts)

      for path <- paths do
        @external_resource Path.relative_to_cwd(path)
      end

      def __mix_recompile__? do
        unquote(from) |> Path.wildcard() |> Enum.sort() |> :erlang.md5() !=
          unquote(:erlang.md5(paths))
      end

      # TODO: Remove me once we require Elixir v1.11+.
      def __phoenix_recompile__?, do: __mix_recompile__?()
    end
  end

  @doc false
  def __extract__(module, opts) do
    builder = Keyword.fetch!(opts, :build)
    from = Keyword.fetch!(opts, :from)
    as = Keyword.fetch!(opts, :as)
    parser_module = Keyword.get(opts, :parser)

    for highlighter <- Keyword.get(opts, :highlighters, []) do
      Application.ensure_all_started(highlighter)
    end

    paths = from |> Path.wildcard() |> Enum.sort()

    entries =
      Enum.flat_map(paths, fn path ->
        parsed_contents = parse_contents!(path, File.read!(path), parser_module)
        build_entry(builder, path, parsed_contents, opts)
      end)

    Module.put_attribute(module, as, entries)
    {from, paths}
  end

  @doc """
  Highlights all code blocks in an already generated HTML document.

  It uses Makeup and expects the existing highlighters applications to
  be already started.

  Options:

    * `:regex` - the regex used to find code blocks in the HTML document. The regex
      should have two capture groups: the first one should be the language name
      and the second should contain the code to be highlighted. The default
      regex to match with generated HTML documents is:

          ~r/<pre><code(?:\s+class="(\w*)")?>([^<]*)<\/code><\/pre>/
  """
  defdelegate highlight(html, options \\ []), to: NimblePublisher.Highlighter

  defp build_entry(builder, path, {_attrs, _body} = parsed_contents, opts) do
    build_entry(builder, path, [parsed_contents], opts)
  end

  defp build_entry(builder, path, parsed_contents, opts) when is_list(parsed_contents) do
    converter_module = Keyword.get(opts, :html_converter)

    Enum.map(parsed_contents, fn {attrs, body} ->
      body =
        case converter_module do
          nil -> path |> Path.extname() |> String.downcase() |> convert_body(body, opts)
          module -> module.convert(path, body, attrs, opts)
        end

      builder.build(path, attrs, body)
    end)
  end

  defp parse_contents!(path, contents, nil) do
    case parse_contents(path, contents) do
      {:ok, attrs, body} ->
        {attrs, body}

      {:error, message} ->
        raise """
        #{message}

        Each entry must have a map with attributes, followed by --- and a body. For example:

            %{
              title: "Hello World"
            }
            ---
            Hello world!

        """
    end
  end

  defp parse_contents!(path, contents, parser_module) do
    parser_module.parse(path, contents)
  end

  defp parse_contents(path, contents) do
    case :binary.split(contents, ["\n---\n", "\r\n---\r\n"]) do
      [_] ->
        {:error, "could not find separator --- in #{inspect(path)}"}

      [code, body] ->
        case Code.eval_string(code, []) do
          {%{} = attrs, _} ->
            {:ok, attrs, body}

          {other, _} ->
            {:error,
             "expected attributes for #{inspect(path)} to return a map, got: #{inspect(other)}"}
        end
    end
  end

  defp convert_body(extname, body, opts) when extname in [".md", ".markdown", ".livemd"] do
    earmark_opts = Keyword.get(opts, :earmark_options, %Earmark.Options{})
    html = Earmark.as_html!(body, earmark_opts)

    case Keyword.get(opts, :highlighters, []) do
      [] -> html
      [_ | _] -> highlight(html)
    end
  end

  defp convert_body(_extname, body, _opts) do
    body
  end
end