Documentation
¶
Overview ¶
Package paginate computes correct pagination math and hands back template-ready data. It renders nothing itself: you get page numbers, offsets, an ellipsis window, opaque cursors, and an RFC 8288 Link header, and you render them however you like.
Nothing in this package returns an error for bad page input. Out-of-range, garbage, and overflowing values are clamped to a valid page. A pager should never be the reason a request fails.
Offset pagination ¶
New turns a total item count, a page size, and a requested page into a Page whose fields drop straight into an html/template. ParsePage makes untrusted query strings safe to feed it. Page.Window produces the numbered pager with ellipses; Slice and SliceSeq cut the visible items out of a slice or a Go 1.23 iterator.
Cursor pagination ¶
EncodeCursor and DecodeCursor move an opaque, tamper-tolerant cursor through a query string; NewCursorPage builds the next/prev tokens from a window you just fetched. Offset and cursor modes share vocabulary but not a forced abstraction — pick one per endpoint.
Versioning ¶
This module follows semantic versioning. The exported surface is frozen for the v1 line: no exported name, signature, or documented behavior will change without a major-version bump (a v2 module path). New functionality arrives as additions.
Index ¶
- Constants
- func EncodeCursor(c Cursor) string
- func LinkHeader(base *url.URL, p Page, param string) string
- func ParsePage(s string) int
- func Partition[T any](items []T, keep func(T) bool) (pinned, rest []T)
- func Slice[T any](items []T, p Page) []T
- func SliceSeq[T any](seq iter.Seq[T], p Page) []T
- type Cursor
- type CursorPage
- type Direction
- type Page
- type WindowItem
Constants ¶
const DefaultPerPage = 20
DefaultPerPage is the page size New falls back to when it is given a perPage of zero or less.
Variables ¶
This section is empty.
Functions ¶
func EncodeCursor ¶
EncodeCursor renders c as an opaque, URL-safe token (base64url, unpadded). The encoding is not a security boundary — it is tamper-tolerant, not tamper-proof; see DecodeCursor.
func LinkHeader ¶
LinkHeader builds an RFC 8288 Link header value advertising the first, prev, next, and last pages relative to base. Existing query parameters on base are preserved and re-escaped; only the page parameter (param, or "page" when param == "") is rewritten. The prev and next rels are omitted at the edges; first and last are always present when there is at least one page. Returns "" when TotalPages == 0 or base is nil.
The links inherit base's form. A server-side r.URL has no scheme or host, so they come out as relative references (RFC 8288 permits these):
w.Header().Set("Link", paginate.LinkHeader(r.URL, p, "page"))
// </items?page=1&sort=new>; rel="first", ...
For absolute links, set base.Scheme and base.Host first:
u := *r.URL
u.Scheme, u.Host = "https", r.Host
w.Header().Set("Link", paginate.LinkHeader(&u, p, "page"))
// <https://api.example.com/items?page=1&sort=new>; rel="first", ...
func ParsePage ¶
ParsePage turns untrusted query input into a page number that is always >= 1 and never panics. It trims surrounding whitespace and reads a base-10 integer:
- "", "abc", "1.5", "-7", "0" -> 1
- "007", " 42 " -> 7, 42
- a value larger than int can hold -> the maximum int, which New then clamps down to the last page ("give me the far end" reads better than "bounced to page 1")
Pair it with New:
p := paginate.New(total, 20, paginate.ParsePage(q.Get("page")))
func Partition ¶
Partition splits items into those matching keep and the rest, preserving order in both. It expresses the "pinned rows render outside pagination" pattern: pin the featured items, then paginate what is left. Pinned items never enter the total, so they never shift the page window.
pinned, rest := paginate.Partition(all, func(e Escrow) bool { return e.Featured })
p := paginate.New(len(rest), 20, page) // count excludes pinned
view := paginate.Slice(rest, p)
func Slice ¶
Slice returns the items visible on page p from an in-memory slice: items[p.Offset:][:p.Limit], clamped to len(items) so an over-range page (or a hand-built Page with odd bounds) yields an empty slice rather than a panic. The result is capped so appending to it cannot overwrite the next page's backing array.
p := paginate.New(len(all), 20, paginate.ParsePage(q)) view := paginate.Slice(all, p)
For a Go 1.23 iterator, materialize first with the stdlib and reuse this:
all := slices.Collect(seq) view := paginate.Slice(all, paginate.New(len(all), 20, page))
func SliceSeq ¶
SliceSeq pulls only page p's items from a Go 1.23 iterator, consuming at most p.Offset+p.Limit values and then stopping — it never materializes the tail of seq. Use it when the sequence is large and the total comes from elsewhere (for example a COUNT); use Slice when you already hold the whole slice.
view := paginate.SliceSeq(rows, p) // rows is an iter.Seq[Row]
Types ¶
type Cursor ¶
Cursor is the decoded form of an opaque pagination cursor: a row ID and a direction. The zero Cursor (ID 0, After) means "from the beginning".
func DecodeCursor ¶
DecodeCursor parses a token from EncodeCursor. It never errors and never panics: any malformed, truncated, or tampered input decodes to the zero Cursor with ok == false, which callers treat as "start from the beginning" — a bad cursor sends the user to the first page, never to an error page. ok is advisory (well-formed vs. fell-back); the zero Cursor is always safe to act on regardless.
type CursorPage ¶
type CursorPage struct {
PerPage int
HasPrev bool
HasNext bool
PrevCursor string // "" when !HasPrev; EncodeCursor({firstID, Before})
NextCursor string // "" when !HasNext; EncodeCursor({lastID, After})
}
CursorPage is the cursor-mode counterpart of Page: the tokens to advance in each direction. A cursor is emitted only when that direction has more rows; otherwise the field is "".
func NewCursorPage ¶
func NewCursorPage(perPage int, firstID, lastID int64, hasPrev, hasNext bool) CursorPage
NewCursorPage builds a CursorPage from the window you just fetched. The caller owns the query (typically "fetch perPage+1 rows" to learn hasNext); this constructor owns only the cursor vocabulary. firstID and lastID are the boundary IDs of the returned rows. perPage <= 0 falls back to DefaultPerPage, matching New.
type Direction ¶
type Direction int8
Direction is the travel direction of a cursor relative to its anchor ID.
type Page ¶
type Page struct {
CurrentPage int // 1-based; the page this struct describes
PerPage int // items per page (>= 1)
TotalItems int // size of the full result set
TotalPages int // number of pages; 0 when TotalItems == 0
Offset int // items to skip: (CurrentPage-1) * PerPage
Limit int // items to take: == PerPage
HasPrev bool // CurrentPage > 1
HasNext bool // CurrentPage < TotalPages
PrevPage int // CurrentPage-1, clamped to >= 1
NextPage int // CurrentPage+1, clamped to <= max(1, TotalPages)
}
Page is the computed state of one page over a result set of TotalItems. Every field is exported and needs no method to render, so a Page drops straight into an html/template.
Invariants after New:
- 1 <= CurrentPage <= max(1, TotalPages)
- Offset == (CurrentPage-1) * PerPage, and 0 <= Offset
- Limit == PerPage
- PrevPage and NextPage are always themselves valid page numbers, even when HasPrev/HasNext is false (they clamp to the edge, never to 0 or TotalPages+1), so a template that forgets the guard still links safely.
- When TotalItems == 0: TotalPages == 0, CurrentPage == 1, both Has* false. This is the one case where CurrentPage > TotalPages; see New.
func New ¶
New computes the Page for requestedPage over total items at perPage each. It never fails; it clamps:
- perPage <= 0 -> DefaultPerPage
- requestedPage < 1 -> 1
- requestedPage > last -> last (last = max(1, TotalPages))
- total <= 0 -> empty Page (TotalPages 0, CurrentPage 1)
perPage is not clamped on the high side: it is a server-chosen size, not user input. If you expose perPage to callers, validate it yourself.
To 404 on an out-of-range page instead of clamping, compare before and after:
p := paginate.New(total, 20, requested)
if requested != p.CurrentPage {
http.NotFound(w, r)
return
}
func (Page) AppendWindow ¶
func (p Page) AppendWindow(dst []WindowItem, radius int) []WindowItem
AppendWindow appends p's window (see Window) to dst and returns the extended slice, growing dst only if its capacity is exceeded. Reusing one buffer across calls makes windowing zero-allocation:
buf = p.AppendWindow(buf[:0], 2)
func (Page) Window ¶
func (p Page) Window(radius int) []WindowItem
Window returns the pager slots for p with the given radius: page 1, the last page, and every page within radius of CurrentPage, with gap markers filling the runs between. It allocates exactly one slice — the result — and nothing else. For a zero-allocation hot path, use AppendWindow.
Laws, true for every total, page, and radius (enforced by property tests):
- Page 1 and the last page are always present (when TotalPages >= 1).
- Every page in [CurrentPage-radius, CurrentPage+radius] ∩ [1, last] is present.
- Output is ascending, deduplicated, and contains no two adjacent Gaps.
- A gap of exactly one page is never elided to "…": if pages 1 and 3 show, page 2 renders as a number, not an ellipsis. Ellipses appear only for runs of two or more hidden pages.
- radius 0 gives {1, …, current, …, last}; a single-page set gives {1}; TotalPages 0 gives an empty slice.
Worked examples:
total 23, perPage 5, page 3, radius 1 -> 1 2 3 4 5 total 100, perPage 10, page 5, radius 1 -> 1 … 4 5 6 … 10
In the first, the ends (1, 5) sit next to the radius band (2..4) with no run longer than one page, so no ellipsis appears. In the second, pages 2..3 and 7..9 are each hidden runs of two or more, so each collapses to a single "…".
type WindowItem ¶
type WindowItem struct {
Page int // the page number; 0 when Gap is true
Gap bool // true -> render an ellipsis, not a link
}
WindowItem is one slot in a rendered pager: either a page number to link, or a gap (…) standing in for a run of hidden pages.
Directories
¶
| Path | Synopsis |
|---|---|
|
examples
|
|
|
fetchjson
command
Command fetchjson is a JSON pagination demo: a /api/items endpoint that returns the page as JSON and advertises neighbors via an RFC 8288 Link header, plus a tiny vanilla fetch() client at / that consumes it.
|
Command fetchjson is a JSON pagination demo: a /api/items endpoint that returns the page as JSON and advertises neighbors via an RFC 8288 Link header, plus a tiny vanilla fetch() client at / that consumes it. |
|
htmx
command
Command htmx is the same server-rendered pager as plainlinks, but the links swap in a fragment via htmx instead of reloading the page.
|
Command htmx is the same server-rendered pager as plainlinks, but the links swap in a fragment via htmx instead of reloading the page. |
|
plainlinks
command
Command plainlinks is a server-rendered pagination demo using plain ?page=N anchors and html/template — no JavaScript at all.
|
Command plainlinks is a server-rendered pagination demo using plain ?page=N anchors and html/template — no JavaScript at all. |