krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

v0.21.0: 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.