lib/serum/post_list.ex

defmodule Serum.PostList do
  @moduledoc """
  Defines a struct representing a list of blog posts.

  ## Fields

  * `tag`: Specifies by which tag the posts are filtered. Can be `nil`
  * `current_page`: Number of current page
  * `max_page`: Number of the last page
  * `title`: Title of the list
  * `posts`: A list of `Post` structs
  * `url`: Absolute URL of this list page in the website
  * `prev_url`: Absolute URL of the previous list page. Can be `nil` if this is
    the first page
  * `next_url`: Absolute URL of the next list page. Can be `nil` if this is
    the last page
  * `output`: Destination path
  """

  alias Serum.Fragment
  alias Serum.Plugin
  alias Serum.Project
  alias Serum.Renderer
  alias Serum.Result
  alias Serum.Tag
  alias Serum.Template.Storage, as: TS

  @type t :: %__MODULE__{
          tag: maybe_tag(),
          current_page: pos_integer(),
          max_page: pos_integer(),
          title: binary(),
          posts: [map()],
          url: binary(),
          prev_url: binary() | nil,
          next_url: binary() | nil,
          output: binary(),
          extras: %{optional(binary()) => binary()}
        }

  @type maybe_tag :: Tag.t() | nil

  defstruct [
    :tag,
    :current_page,
    :max_page,
    :title,
    :posts,
    :url,
    :prev_url,
    :next_url,
    :output,
    :extras
  ]

  @spec generate(maybe_tag(), [map()], Project.t()) :: Result.t([t()])
  def generate(tag, posts, %Project{} = proj) do
    paginate? = proj.pagination
    num_posts = proj.posts_per_page

    paginated_posts =
      posts
      |> make_chunks(paginate?, num_posts)
      |> Enum.with_index(1)

    max_page = length(paginated_posts)
    list_dir = (tag && Path.join(proj.tags_path, tag.name)) || proj.posts_path

    lists =
      Enum.map(paginated_posts, fn {posts, page} ->
        supplemental =
          if page == 1,
            do: [],
            else: [prev: Path.join([proj.base_url, list_dir, format_name(page - 1)])]

        supplemental =
          if page == max_page,
            do: supplemental,
            else:
              Keyword.put(
                supplemental,
                :next,
                Path.join([proj.base_url, list_dir, format_name(page + 1)])
              )

        %__MODULE__{
          tag: tag,
          current_page: page,
          max_page: max_page,
          title: list_title(tag, proj),
          posts: posts,
          url: Path.join([proj.base_url, list_dir, format_name(page)]),
          output: Path.join([proj.dest, list_dir, format_name(page)]),
          extras: %{supplemental: supplemental}
        }
      end)

    [first | rest] = put_adjacent_urls([nil | lists], [])

    first_dup = %__MODULE__{
      first
      | url: Path.join([proj.base_url, list_dir, "index.html"]),
        output: Path.join([proj.dest, list_dir, "index.html"])
    }

    [first_dup, first | rest]
    |> Enum.map(&Plugin.processed_list/1)
    |> Result.aggregate_values(:generate_lists)
  end

  @spec format_name(pos_integer(), String.t(), String.t()) :: String.t()
  defp format_name(number, prefix \\ "page-", suffix \\ ".html"),
    do: "#{prefix}#{number}#{suffix}"

  @spec compact(t()) :: map()
  def compact(%__MODULE__{} = list) do
    list
    |> Map.drop(~w(__struct__ output)a)
    |> Map.put(:type, :list)
  end

  @spec put_adjacent_urls([nil | t()], [t()]) :: [t()]
  defp put_adjacent_urls(lists, acc)
  defp put_adjacent_urls([_last], acc), do: Enum.reverse(acc)

  defp put_adjacent_urls([prev, curr | rest], acc) do
    next = List.first(rest)

    updated_curr = %__MODULE__{
      curr
      | prev_url: prev && prev.url,
        next_url: next && next.url
    }

    put_adjacent_urls([curr | rest], [updated_curr | acc])
  end

  @spec make_chunks([map()], boolean(), pos_integer()) :: [[map()]]
  defp make_chunks(posts, paginate?, num_posts)
  defp make_chunks([], _, _), do: [[]]
  defp make_chunks(posts, false, _), do: [posts]

  defp make_chunks(posts, true, num_posts) do
    Enum.chunk_every(posts, num_posts)
  end

  @spec list_title(maybe_tag(), Project.t()) :: binary()
  defp list_title(tag, proj)
  defp list_title(nil, proj), do: proj.list_title_all

  defp list_title(%Tag{name: tag_name}, proj) do
    proj.list_title_tag
    |> :io_lib.format([tag_name])
    |> IO.iodata_to_binary()
  end

  @spec to_fragment(t()) :: Result.t(Fragment.t())
  def to_fragment(post_list) do
    metadata = compact(post_list)
    template = TS.get("list", :template)
    bindings = [page: metadata]

    case Renderer.render_fragment(template, bindings) do
      {:ok, html} -> Fragment.new(nil, post_list.output, metadata, html)
      {:error, _} = error -> error
    end
  end

  defimpl Fragment.Source do
    alias Serum.PostList
    alias Serum.Result

    @spec to_fragment(PostList.t()) :: Result.t(Fragment.t())
    def to_fragment(fragment) do
      PostList.to_fragment(fragment)
    end
  end
end