krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
v0.20.2: docs/guide/03-collections.org · raw
1#+TITLE: Collections
2#+DESCRIPTION: Generated pages — blog indexes, tag pages, pagination and RSS feeds.
3#+LEDE: The one kind of output that is not a translation of some input.
4
5A blog index exists because a set of posts exists, not because someone wrote
6=index.org=. A =[[collections]]= block declares one: a source directory in, an output
7file out, through a template.
8
9Keeping it declarative means an RSS feed is the same mechanism with an XML template
10rather than a second feature.
11
12* A blog index
13
14#+BEGIN_SRC toml
15[[collections]]
16source = "blog" # directory to list; empty means every page
17output = "blog/index.html" # where to write it
18template = "list.html" # template file name
19title = "Blog"
20sort = "date" # date | title | path
21order = "desc" # desc | asc
22nav = true # put this page in the site nav
23#+END_SRC
24
25The template receives the collection's entries as =pages=, already sorted, plus the usual
26=site=, =nav= and =root=:
27
28#+BEGIN_SRC html
29{% extends "base.html" %}
30{% block content %}
31<ul>
32 {% for post in pages %}
33 <li>
34 <time datetime="{{ post.date_iso }}">{{ post.date_iso }}</time>
35 <a href="{{ root }}{{ post.url }}">{{ post.title }}</a>
36 <p>{{ post.excerpt | truncate(180) }}</p>
37 </li>
38 {% endfor %}
39</ul>
40{% endblock %}
41#+END_SRC
42
43** Sorting
44
45=sort= is =date= (default), =title= or =path=; =order= is =desc= (default) or =asc=.
46
47Date sorting uses =page.date_iso=, the =YYYY-MM-DD= extracted from =#+DATE:= whatever org
48syntax it was written in — =[2025-09-05 Fri 10:21:00]=, =<2024-05-01 Wed>= or a bare
49=2024-05-01= all work.
50
51*Pages with no parseable date sort last in either direction*, so an undated draft never
52leads a dated archive.
53
54** Grouping a listing by year
55
56An archive usually wants year headings, and that is a *template* decision rather than a
57config one — the entries are already in the right order, they just need breaking up.
58=page.year= exists for exactly this, because minijinja's =groupby= takes an attribute name
59and cannot slice a date itself:
60
61#+BEGIN_SRC html
62<ul class="post-list">
63{% for year, posts in pages | groupby("year") | reverse %}
64 <li class="post-list-year">{{ year if year else "undated" }}</li>
65 {% for entry in posts %}
66 <li><time datetime="{{ entry.date_iso }}">{{ entry.date_iso }}</time>
67 <a href="{{ root }}{{ entry.url }}">{{ entry.title }}</a></li>
68 {% endfor %}
69{% endfor %}
70</ul>
71#+END_SRC
72
73=groupby= sorts its groups ascending, so =| reverse= puts the newest year first — matching
74the =order = "desc"= the entries themselves already use, and leaving undated pages in a
75group of their own at the end.
76
77Name that group in the template rather than with =groupby='s =default== argument, which
78covers an attribute that is *missing* and not one that is null — an undated page has a
79=year=, and it is =none=.
80
81** Full-content feeds
82
83A feed usually carries whole posts, and a subscriber handed excerpts instead has lost
84something. =include_content= gives the template each entry's rendered HTML as
85=entry.content=:
86
87#+BEGIN_SRC toml
88[[collections]]
89source = "blog"
90output = "feed.xml"
91template = "feed.xml"
92include_content = true
93#+END_SRC
94
95#+BEGIN_SRC html
96<description><![CDATA[{{ post.content | safe }}]]></description>
97#+END_SRC
98
99Off by default, because it costs a render of every listed page each time the listing is
100rebuilt. That cost is only paid when the listing is *not* cached, and the listing's cache
101key covers its entries' content — so a body edit reaches the feed, and an unchanged site
102pays nothing.
103
104Everywhere else =entry.content= is =none=, since carrying every page's body in every
105listing context would be most of a site's memory for nothing.
106
107** nav = true
108
109The listing page joins the site navigation. This is how a section landing page — =/blog/=,
110=/notes/= — gets into a nav built from top-level pages, and it points at the right thing:
111the section, not any one post in it.
112
113* Tag pages
114
115Add =group_by= and the collection emits one page /per group/ instead of one page total,
116plus an optional index of the groups:
117
118#+BEGIN_SRC toml
119[[collections]]
120source = "blog"
121group_by = "tags" # "tags", or any #+KEYWORD: name
122output = "tags/{tag}.html" # {tag} becomes each group's slug
123template = "tag.html"
124title = "Tagged: {tag}"
125index_output = "tags/index.html"
126index_template = "tags.html"
127index_title = "Tags"
128nav = true # adds the *index*, not every tag
129#+END_SRC
130
131A group page receives its own posts as =pages= and itself as =group=:
132
133#+BEGIN_SRC html
134<h1>{{ group.name }} ({{ group.count }})</h1>
135{% for post in pages %}<a href="{{ root }}{{ post.url }}">{{ post.title }}</a>{% endfor %}
136#+END_SRC
137
138The index receives =groups=, sorted by name:
139
140#+BEGIN_SRC html
141<ul>{% for tag in groups %}
142 <li><a href="{{ root }}{{ tag.url }}">{{ tag.name }}</a> ({{ tag.count }})</li>
143{% endfor %}</ul>
144#+END_SRC
145
146** Grouping by anything
147
148=group_by = "tags"= is multi-valued: a post appears under every tag it carries. Any other
149value names a single-valued =#+KEYWORD:=, so =group_by = "category"= buckets pages by
150=#+CATEGORY:= with no extra machinery.
151
152** Two tags that would collide are an error
153
154=web_dev= and =web@dev= both slugify to =web-dev=, so one page would silently overwrite
155the other. That is a build error naming both values.
156
157* Pagination
158
159#+BEGIN_SRC toml
160[[collections]]
161source = "blog"
162output = "blog/index.html"
163paginate = 10
164paginate_output = "blog/page/{n}.html" # {n} is the 1-based page number
165#+END_SRC
166
167*Page 1 stays at =output=*, so a section's canonical URL never moves as its page count
168changes. Only pages 2..N are named by =paginate_output=.
169
170The template gets a =paginator=:
171
172#+BEGIN_SRC html
173{% if paginator and paginator.total > 1 %}
174<nav>
175 {% if paginator.prev_url %}<a href="{{ paginator.prev_url }}">Newer</a>{% endif %}
176 {% for pg in paginator.pages %}
177 <a href="{{ pg.url }}"{% if pg.current %} aria-current="page"{% endif %}>{{ pg.number }}</a>
178 {% endfor %}
179 {% if paginator.next_url %}<a href="{{ paginator.next_url }}">Older</a>{% endif %}
180</nav>
181{% endif %}
182#+END_SRC
183
184| Field | Meaning |
185|-------+---------|
186| =current=, =total= | This page's number, and how many there are. |
187| =per_page=, =total_entries= | As configured, and across the whole listing. |
188| =prev_url=, =next_url= | =none= at the ends. |
189| =first_url=, =last_url= | Always present. |
190| =pages= | =[{number, url, current}]= for a numbered strip. |
191
192Every URL is relative to the page carrying it, so links work from page 1
193(=page/2.html=) and from page 5 (=../index.html=, =6.html=) without the template knowing
194where it sits. An unpaginated collection has no =paginator= at all, so
195={% if paginator %}= is a reliable test in a shared template.
196
197Grouping and pagination compose: each group paginates independently, which is why
198=paginate_output= needs ={tag}= as well as ={n}= on a grouped collection.
199
200* An RSS feed
201
202A feed is a listing page with an XML template. Templates load by full filename and any
203extension, so:
204
205#+BEGIN_SRC toml
206[[collections]]
207source = "blog"
208output = "feed.xml"
209template = "feed.xml"
210title = "Feed"
211#+END_SRC
212
213#+BEGIN_SRC html
214<?xml version="1.0" encoding="utf-8"?>
215<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
216<channel>
217 <title>{{ site.title }}</title>
218 <link>{{ "index.html" | absolute }}</link>
219 <atom:link href="{{ page.url | absolute }}" rel="self" type="application/rss+xml"/>
220 {% for post in pages %}
221 <item>
222 <title>{{ post.title }}</title>
223 <link>{{ post.url | absolute }}</link>
224 <guid isPermaLink="true">{{ post.url | absolute }}</guid>
225 <pubDate>{{ post.date_iso | rfc822 }}</pubDate>
226 </item>
227 {% endfor %}
228</channel>
229</rss>
230#+END_SRC
231
232This needs =site.base_url=, because a feed with relative links is invalid everywhere it
233is read. =orgo init= writes this template and leaves the collection commented out
234until there is a base URL to make absolute links from.
235
236* Every setting
237
238| Key | Default | Meaning |
239|-----+---------+---------|
240| =source= | ="" | Directory to list. Empty means every page. |
241| =output= | ="index.html"= | Where to write. Needs ={tag}= when grouped. |
242| =template= | ="list.html"= | Template file name. |
243| =title= | ="Index"= | ={{ page.title }}=. ={tag}= is substituted when grouped. |
244| =group_by= | ="" | ="tags"=, or a =#+KEYWORD:= name. Empty means one page. |
245| =index_output= | ="" | Where to write the group index. Empty means none. |
246| =index_template= | ="tags.html"= | Template for the group index. |
247| =index_title= | ="Tags"= | Title for the group index. |
248| =sort= | ="date"= | =date=, =title= or =path=. |
249| =order= | ="desc"= | =desc= or =asc=. |
250| =paginate= | =0= | Entries per page. =0= means no pagination. |
251| =paginate_output= | ="" | Where pages 2..N go. Needs ={n}=. |
252| =nav= | =false= | Add this page — or its index, when grouped — to the nav. |
253
254* Incremental behaviour
255
256A listing page is cached on the entries it lists, so:
257
258- Adding a post re-renders that post, its section index, its tag pages and the tag index
259 whose counts changed. Nothing else.
260- Editing a post's *body* changes no listing metadata, so the index is not touched at
261 all.
262- Retitling a post does re-render the listings that display the title.
263
264A tag page depends on its own posts and not on the other groups, which is why =groups= is
265given to the index and not to every group page: a page that can see every group would
266depend on every group, and one new post would re-render every tag page.
267
268When a collection shrinks below a page boundary, the pages that no longer exist are
269deleted rather than left serving stale content.