lib/mix/tasks/tableau.build.ex

defmodule Mix.Tasks.Tableau.Build do
  @shortdoc "Builds the site"

  @moduledoc "Task to build the tableau site"
  use Mix.Task

  alias Tableau.Graph.Nodable

  require Logger

  @impl Mix.Task
  def run(argv) do
    Application.ensure_all_started(:telemetry)
    {:ok, config} = Tableau.Config.get()
    token = %{site: %{config: config}, graph: Graph.new()}
    Mix.Task.run("app.start", ["--preload-modules"])

    {opts, _argv} = OptionParser.parse!(argv, strict: [out: :string])

    out = opts[:out] || config.out_dir

    mods =
      :code.all_available()
      |> Task.async_stream(fn {mod, _, _} -> Module.concat([to_string(mod)]) end)
      |> Stream.map(fn {:ok, mod} -> mod end)
      |> Enum.to_list()

    {:ok, config} = Tableau.Config.get()
    token = Map.put(token, :extensions, %{})

    token = mods |> extensions_for(:pre_build) |> run_extensions(:pre_build, token)

    token = mods |> extensions_for(:pre_render) |> run_extensions(:pre_render, token)
    graph = Tableau.Graph.insert(token.graph, mods)

    File.mkdir_p!(out)

    pages =
      for page <- Graph.vertices(graph), {:ok, :page} == Nodable.type(page) do
        {page, Map.new(Nodable.opts(page) || [])}
      end

    token = put_in(token.site[:pages], Enum.map(pages, fn {_mod, page} -> page end))

    pages =
      pages
      |> Task.async_stream(fn {mod, page} ->
        try do
          content = Tableau.Document.render(graph, mod, token, page)
          permalink = Nodable.permalink(mod)

          Map.merge(page, %{body: content, permalink: permalink})
        rescue
          exception ->
            reraise TableauDevServer.BuildException, [page: page, exception: exception], __STACKTRACE__
        end
      end)
      |> Stream.map(fn
        {:ok, result} -> result
        {:exit, {exception, stacktrace}} -> reraise exception, stacktrace
      end)
      |> Enum.to_list()

    token = put_in(token.site[:pages], pages)

    token = mods |> extensions_for(:pre_write) |> run_extensions(:pre_write, token)

    for %{body: body, permalink: permalink} <- token.site[:pages] do
      file_path = build_file_path(out, permalink)
      dir = Path.dirname(file_path)

      File.mkdir_p!(dir)

      File.write!(file_path, body)
    end

    if File.exists?(config.include_dir) do
      File.cp_r!(config.include_dir, out)
    end

    token = mods |> extensions_for(:post_write) |> run_extensions(:post_write, token)

    token
  end

  @file_extensions [".html"]
  defp build_file_path(out, permalink) do
    if Path.extname(permalink) in @file_extensions do
      Path.join(out, permalink)
    else
      Path.join([out, permalink, "index.html"])
    end
  end

  defp validate_config(module, raw_config) do
    if function_exported?(module, :config, 1) do
      module.config(raw_config)
    else
      {:ok, raw_config}
    end
  end

  defp extensions_for(modules, type) do
    extensions =
      for mod <- modules, Code.ensure_loaded?(mod), function_exported?(mod, type, 1) do
        mod
      end

    Enum.sort_by(extensions, & &1.__tableau_extension_priority__())
  end

  defp run_extensions(extensions, type, token) do
    for module <- extensions, reduce: token do
      token ->
        raw_config =
          Map.merge(
            %{enabled: Tableau.Extension.enabled?(module)},
            :tableau |> Application.get_env(module, %{}) |> Map.new()
          )

        if raw_config[:enabled] do
          {:ok, config} = validate_config(module, raw_config)

          {:ok, key} = Tableau.Extension.key(module)

          token = put_in(token.extensions[key], %{config: config})

          case apply(module, type, [token]) do
            {:ok, token} ->
              token

            :error ->
              Logger.error("#{inspect(module)} failed to run")
              token
          end
        else
          token
        end
    end
  end
end