defmodule Astral.Pagination do
@moduledoc """
Static pagination helpers for generated Astral routes.
`pages/2` splits an in-memory collection into page structs and generates
canonical URLs from an `Astral.Route.Pattern` compatible pattern:
Astral.Pagination.pages(entries,
pattern: "/blog/*page",
page_size: 10
)
The `*page` glob form omits the page parameter for the first page, producing
`/blog/`, `/blog/2/`, `/blog/3/`, and so on. A required `:page` parameter can
be used when page one should be explicit: `/blog/1/`, `/blog/2/`.
"""
alias Astral.Pagination.Page
alias Astral.Pagination.URLs
alias Astral.Route.Pattern
@default_page_size 10
@doc "Build static pagination pages for entries."
@spec pages(list(), keyword()) :: [Page.t()]
def pages(entries, opts) when is_list(entries) and is_list(opts) do
pattern = opts |> Keyword.fetch!(:pattern) |> Pattern.parse()
page_size = page_size!(Keyword.get(opts, :page_size, @default_page_size))
params = Keyword.get(opts, :params, %{})
trailing_slash? = Keyword.get(opts, :trailing_slash, true)
total_entries = length(entries)
total_pages = max(1, ceil_div(total_entries, page_size))
for page_number <- 1..total_pages do
Page.new(
entries: page_entries(entries, page_number, page_size),
page_number: page_number,
page_size: page_size,
total_pages: total_pages,
total_entries: total_entries,
urls: urls(pattern, params, page_number, total_pages, trailing_slash?)
)
end
end
@doc "Convert pagination pages into generated routes."
@spec routes([Page.t()], Astral.Config.t(), keyword()) :: [Astral.Route.t()]
def routes(pages, %Astral.Config{} = config, opts \\ []) when is_list(pages) do
assigns = Keyword.get(opts, :assigns, %{})
metadata = Keyword.get(opts, :metadata, %{})
Enum.map(pages, fn %Page{} = page ->
Astral.Route.new(page.urls.current, config,
content_type: Keyword.get(opts, :content_type, "text/html"),
kind: Keyword.get(opts, :kind, :pagination),
assigns: Map.merge(assigns, %{page: page}),
metadata: metadata
)
end)
end
defp page_entries(entries, page_number, page_size) do
Enum.slice(entries, (page_number - 1) * page_size, page_size)
end
defp urls(pattern, params, page_number, total_pages, trailing_slash?) do
URLs.new(
current: page_url(pattern, params, page_number, trailing_slash?),
previous: previous_url(pattern, params, page_number, trailing_slash?),
next: next_url(pattern, params, page_number, total_pages, trailing_slash?),
first: first_url(pattern, params, page_number, trailing_slash?),
last: last_url(pattern, params, page_number, total_pages, trailing_slash?)
)
end
defp previous_url(_pattern, _params, 1, _trailing_slash?), do: nil
defp previous_url(pattern, params, page_number, trailing_slash?) do
page_url(pattern, params, page_number - 1, trailing_slash?)
end
defp next_url(_pattern, _params, page_number, page_number, _trailing_slash?), do: nil
defp next_url(pattern, params, page_number, _total_pages, trailing_slash?) do
page_url(pattern, params, page_number + 1, trailing_slash?)
end
defp first_url(_pattern, _params, 1, _trailing_slash?), do: nil
defp first_url(pattern, params, _page_number, trailing_slash?) do
page_url(pattern, params, 1, trailing_slash?)
end
defp last_url(_pattern, _params, page_number, page_number, _trailing_slash?), do: nil
defp last_url(pattern, params, _page_number, total_pages, trailing_slash?) do
page_url(pattern, params, total_pages, trailing_slash?)
end
defp page_url(pattern, params, page_number, trailing_slash?) do
pattern
|> Pattern.generate(
Map.put(Pattern.normalize_params(params), "page", page_param(pattern, page_number))
)
|> maybe_trailing_slash(trailing_slash?)
end
defp page_param(%Pattern{parts: parts}, 1) do
if Enum.any?(parts, &match?({:glob, "page"}, &1)), do: nil, else: 1
end
defp page_param(_pattern, page_number), do: page_number
defp maybe_trailing_slash("/", _trailing_slash?), do: "/"
defp maybe_trailing_slash(path, false), do: path
defp maybe_trailing_slash(path, true) do
if Path.extname(path) == "" do
path <> "/"
else
path
end
end
defp page_size!(page_size) when is_integer(page_size) and page_size > 0, do: page_size
defp page_size!(page_size) do
raise ArgumentError, "page_size must be a positive integer, got: #{inspect(page_size)}"
end
defp ceil_div(0, _page_size), do: 0
defp ceil_div(total, page_size), do: div(total + page_size - 1, page_size)
end
defmodule Astral.Pagination.Page do
@moduledoc """
A single static pagination page.
The field names follow common Elixir pagination conventions from libraries
such as Scrivener while adding route URLs needed by static site generation.
"""
alias Astral.Pagination.URLs
@type t :: %__MODULE__{
entries: list(),
page_number: pos_integer(),
page_size: pos_integer(),
total_pages: pos_integer(),
total_entries: non_neg_integer(),
urls: URLs.t()
}
defstruct entries: [],
page_number: 1,
page_size: 10,
total_pages: 1,
total_entries: 0,
urls: nil
@doc "Build a pagination page struct."
@spec new(keyword()) :: t()
def new(opts) when is_list(opts) do
%__MODULE__{
entries: Keyword.fetch!(opts, :entries),
page_number: Keyword.fetch!(opts, :page_number),
page_size: Keyword.fetch!(opts, :page_size),
total_pages: Keyword.fetch!(opts, :total_pages),
total_entries: Keyword.fetch!(opts, :total_entries),
urls: Keyword.fetch!(opts, :urls)
}
end
end
defmodule Astral.Pagination.URLs do
@moduledoc """
Navigation URLs for a static pagination page.
"""
@type t :: %__MODULE__{
current: String.t(),
previous: String.t() | nil,
next: String.t() | nil,
first: String.t() | nil,
last: String.t() | nil
}
defstruct current: nil,
previous: nil,
next: nil,
first: nil,
last: nil
@doc "Build pagination navigation URLs."
@spec new(keyword()) :: t()
def new(opts) when is_list(opts) do
%__MODULE__{
current: Keyword.fetch!(opts, :current),
previous: Keyword.fetch!(opts, :previous),
next: Keyword.fetch!(opts, :next),
first: Keyword.fetch!(opts, :first),
last: Keyword.fetch!(opts, :last)
}
end
end