#+TITLE: Collections
#+DESCRIPTION: Generated pages — blog indexes, tag pages, pagination and RSS feeds.
#+LEDE: The one kind of output that is not a translation of some input.
A blog index exists because a set of posts exists, not because someone wrote
=index.org=. A =[[collections]]= block declares one: a source directory in, an output
file out, through a template.
Keeping it declarative means an RSS feed is the same mechanism with an XML template
rather than a second feature.
* A blog index
#+BEGIN_SRC toml
[[collections]]
source = "blog" # directory to list; empty means every page
output = "blog/index.html" # where to write it
template = "list.html" # template file name
title = "Blog"
sort = "date" # date | title | path
order = "desc" # desc | asc
nav = true # put this page in the site nav
#+END_SRC
The template receives the collection's entries as =pages=, already sorted, plus the usual
=site=, =nav= and =root=:
#+BEGIN_SRC html
{% extends "base.html" %}
{% block content %}
{% endblock %}
#+END_SRC
** Sorting
=sort= is =date= (default), =title= or =path=; =order= is =desc= (default) or =asc=.
Date sorting uses =page.date_iso=, the =YYYY-MM-DD= extracted from =#+DATE:= whatever org
syntax it was written in — =[2025-09-05 Fri 10:21:00]=, =<2024-05-01 Wed>= or a bare
=2024-05-01= all work.
*Pages with no parseable date sort last in either direction*, so an undated draft never
leads a dated archive.
** Grouping a listing by year
An archive usually wants year headings, and that is a *template* decision rather than a
config one — the entries are already in the right order, they just need breaking up.
=page.year= exists for exactly this, because minijinja's =groupby= takes an attribute name
and cannot slice a date itself:
#+BEGIN_SRC html
{% for year, posts in pages | groupby("year") | reverse %}
#+END_SRC
=groupby= sorts its groups ascending, so =| reverse= puts the newest year first — matching
the =order = "desc"= the entries themselves already use, and leaving undated pages in a
group of their own at the end.
Name that group in the template rather than with =groupby='s =default== argument, which
covers an attribute that is *missing* and not one that is null — an undated page has a
=year=, and it is =none=.
** Full-content feeds
A feed usually carries whole posts, and a subscriber handed excerpts instead has lost
something. =include_content= gives the template each entry's rendered HTML as
=entry.content=:
#+BEGIN_SRC toml
[[collections]]
source = "blog"
output = "feed.xml"
template = "feed.xml"
include_content = true
#+END_SRC
#+BEGIN_SRC html
#+END_SRC
Off by default, because it costs a render of every listed page each time the listing is
rebuilt. That cost is only paid when the listing is *not* cached, and the listing's cache
key covers its entries' content — so a body edit reaches the feed, and an unchanged site
pays nothing.
Everywhere else =entry.content= is =none=, since carrying every page's body in every
listing context would be most of a site's memory for nothing.
** nav = true
The listing page joins the site navigation. This is how a section landing page — =/blog/=,
=/notes/= — gets into a nav built from top-level pages, and it points at the right thing:
the section, not any one post in it.
* Tag pages
Add =group_by= and the collection emits one page /per group/ instead of one page total,
plus an optional index of the groups:
#+BEGIN_SRC toml
[[collections]]
source = "blog"
group_by = "tags" # "tags", or any #+KEYWORD: name
output = "tags/{tag}.html" # {tag} becomes each group's slug
template = "tag.html"
title = "Tagged: {tag}"
index_output = "tags/index.html"
index_template = "tags.html"
index_title = "Tags"
nav = true # adds the *index*, not every tag
#+END_SRC
A group page receives its own posts as =pages= and itself as =group=:
#+BEGIN_SRC html
{{ group.name }} ({{ group.count }})
{% for post in pages %}{{ post.title }}{% endfor %}
#+END_SRC
The index receives =groups=, sorted by name:
#+BEGIN_SRC html
#+END_SRC
** Grouping by anything
=group_by = "tags"= is multi-valued: a post appears under every tag it carries. Any other
value names a single-valued =#+KEYWORD:=, so =group_by = "category"= buckets pages by
=#+CATEGORY:= with no extra machinery.
** Two tags that would collide are an error
=web_dev= and =web@dev= both slugify to =web-dev=, so one page would silently overwrite
the other. That is a build error naming both values.
* Pagination
#+BEGIN_SRC toml
[[collections]]
source = "blog"
output = "blog/index.html"
paginate = 10
paginate_output = "blog/page/{n}.html" # {n} is the 1-based page number
#+END_SRC
*Page 1 stays at =output=*, so a section's canonical URL never moves as its page count
changes. Only pages 2..N are named by =paginate_output=.
The template gets a =paginator=:
#+BEGIN_SRC html
{% if paginator and paginator.total > 1 %}
{% endif %}
#+END_SRC
| Field | Meaning |
|-------+---------|
| =current=, =total= | This page's number, and how many there are. |
| =per_page=, =total_entries= | As configured, and across the whole listing. |
| =prev_url=, =next_url= | =none= at the ends. |
| =first_url=, =last_url= | Always present. |
| =pages= | =[{number, url, current}]= for a numbered strip. |
Every URL is relative to the page carrying it, so links work from page 1
(=page/2.html=) and from page 5 (=../index.html=, =6.html=) without the template knowing
where it sits. An unpaginated collection has no =paginator= at all, so
={% if paginator %}= is a reliable test in a shared template.
Grouping and pagination compose: each group paginates independently, which is why
=paginate_output= needs ={tag}= as well as ={n}= on a grouped collection.
* An RSS feed
A feed is a listing page with an XML template. Templates load by full filename and any
extension, so:
#+BEGIN_SRC toml
[[collections]]
source = "blog"
output = "feed.xml"
template = "feed.xml"
title = "Feed"
#+END_SRC
#+BEGIN_SRC html
{{ site.title }}
{{ "index.html" | absolute }}
{% for post in pages %}
{{ post.title }}
{{ post.url | absolute }}
{{ post.url | absolute }}{{ post.date_iso | rfc822 }}
{% endfor %}
#+END_SRC
This needs =site.base_url=, because a feed with relative links is invalid everywhere it
is read. =orgo init= writes this template and leaves the collection commented out
until there is a base URL to make absolute links from.
* Every setting
| Key | Default | Meaning |
|-----+---------+---------|
| =source= | ="" | Directory to list. Empty means every page. |
| =output= | ="index.html"= | Where to write. Needs ={tag}= when grouped. |
| =template= | ="list.html"= | Template file name. |
| =title= | ="Index"= | ={{ page.title }}=. ={tag}= is substituted when grouped. |
| =group_by= | ="" | ="tags"=, or a =#+KEYWORD:= name. Empty means one page. |
| =index_output= | ="" | Where to write the group index. Empty means none. |
| =index_template= | ="tags.html"= | Template for the group index. |
| =index_title= | ="Tags"= | Title for the group index. |
| =sort= | ="date"= | =date=, =title= or =path=. |
| =order= | ="desc"= | =desc= or =asc=. |
| =paginate= | =0= | Entries per page. =0= means no pagination. |
| =paginate_output= | ="" | Where pages 2..N go. Needs ={n}=. |
| =nav= | =false= | Add this page — or its index, when grouped — to the nav. |
* Incremental behaviour
A listing page is cached on the entries it lists, so:
- Adding a post re-renders that post, its section index, its tag pages and the tag index
whose counts changed. Nothing else.
- Editing a post's *body* changes no listing metadata, so the index is not touched at
all.
- Retitling a post does re-render the listings that display the title.
A tag page depends on its own posts and not on the other groups, which is why =groups= is
given to the index and not to every group page: a page that can see every group would
depend on every group, and one new post would re-render every tag page.
When a collection shrinks below a page boundary, the pages that no longer exist are
deleted rather than left serving stale content.