#+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 #+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.