krz/orgo

Lightning fast org-mode static site generator. fast go org-mode static-site-generator

Commit 8bd684becd

8bd684becd9816c9b1351d850e1a0355b61a4af7

parent: b6270e3bf7

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-11 19:28 UTC

Give listings a year to group by

An archive wants year headings, and the entries a listing template receives are
already in the right order — they just need breaking up. That is a template
decision, not a config one, so the engine's part is only to supply something to
group on: minijinja's `groupby` takes an attribute name and cannot slice a date
string itself, and `date_iso` groups per day.

`page.year` is that attribute. The recipe, documented in the collections guide
and covered by a test:

    {% for year, posts in pages | groupby("year") | reverse %}
      <li>{{ year if year else "undated" }}</li>

`| reverse` because groupby sorts ascending while a blog reads newest first. The
label is written out rather than passed as groupby's `default=`, which covers an
attribute that is missing and not one that is null — an undated page has a
`year`, and it is none.

Layout: unified · split

README.md +1 −1
@@ -83,7 +83,7 @@ receive:
83| Variable | What it is | 83| Variable | What it is |
84|---|---| 84|---|---|
85| `body` | the rendered page HTML — use `{{ body \| safe }}` | 85| `body` | the rendered page HTML — use `{{ body \| safe }}` |
86| `page` | `.title`, `.url`, `.source`, `.date`, `.date_iso`, `.tags`, `.excerpt`, `.word_count`, `.reading_time`, `.toc`, `.keywords` | 86| `page` | `.title`, `.url`, `.source`, `.date`, `.date_iso`, `.year`, `.tags`, `.excerpt`, `.word_count`, `.reading_time`, `.toc`, `.keywords` |
87| `site` | `.title`, `.base_url`, `.description`, `.language` | 87| `site` | `.title`, `.base_url`, `.description`, `.language` |
88| `nav` | list of `{title, url}`, relative to this page | 88| `nav` | list of `{title, url}`, relative to this page |
89| `root` | `../`-prefix back to the site root from this page | 89| `root` | `../`-prefix back to the site root from this page |
docs/guide/03-collections.org +27
@@ -51,6 +51,33 @@ syntax it was written in — =[2025-09-05 Fri 10:21:00]=, =<2024-05-01 Wed>= or
51*Pages with no parseable date sort last in either direction*, so an undated draft never 51*Pages with no parseable date sort last in either direction*, so an undated draft never
52leads a dated archive. 52leads a dated archive.
53 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
54** nav = true 81** nav = true
55 82
56The listing page joins the site navigation. This is how a section landing page — =/blog/=, 83The listing page joins the site navigation. This is how a section landing page — =/blog/=,
docs/guide/04-templates.org +1
@@ -95,6 +95,7 @@ Empty on generated pages, which build their content from =pages= or =groups= ins
95| =source= | Source path relative to the source root, e.g. =blog/post.org=. | 95| =source= | Source path relative to the source root, e.g. =blog/post.org=. |
96| =date= | =#+DATE:= verbatim, in whatever org syntax was written. | 96| =date= | =#+DATE:= verbatim, in whatever org syntax was written. |
97| =date_iso= | The =YYYY-MM-DD= inside it, or =none=. | 97| =date_iso= | The =YYYY-MM-DD= inside it, or =none=. |
98| =year= | The year from that date, for grouping a listing. |
98| =tags= | =#+FILETAGS:=, split. | 99| =tags= | =#+FILETAGS:=, split. |
99| =excerpt= | =#+DESCRIPTION:=, or the first paragraph. | 100| =excerpt= | =#+DESCRIPTION:=, or the first paragraph. |
100| =word_count= | Words of prose, excluding code blocks. | 101| =word_count= | Words of prose, excluding code blocks. |
src/main.rs +1
@@ -314,6 +314,7 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
314 source: input.to_string(), 314 source: input.to_string(),
315 date: None, 315 date: None,
316 date_iso: None, 316 date_iso: None,
317 year: None,
317 tags: Vec::new(), 318 tags: Vec::new(),
318 excerpt: String::new(), 319 excerpt: String::new(),
319 word_count: 0, 320 word_count: 0,
src/site.rs +5
@@ -479,6 +479,7 @@ fn listing_context(listing: &Listing) -> PageContext {
479 source: String::new(), 479 source: String::new(),
480 date: None, 480 date: None,
481 date_iso: None, 481 date_iso: None,
482 year: None,
482 tags: Vec::new(), 483 tags: Vec::new(),
483 excerpt: String::new(), 484 excerpt: String::new(),
484 word_count: 0, 485 word_count: 0,
@@ -1266,6 +1267,10 @@ fn page_context(doc: &Document, output: &Utf8Path, config: &Config) -> PageConte
1266 url: output.to_string(), 1267 url: output.to_string(),
1267 source: doc.source_path.to_string(), 1268 source: doc.source_path.to_string(),
1268 date_iso: keyword("DATE").as_deref().and_then(iso_date), 1269 date_iso: keyword("DATE").as_deref().and_then(iso_date),
1270 year: keyword("DATE")
1271 .as_deref()
1272 .and_then(iso_date)
1273 .map(|d| d[..4].to_string()),
1269 date: keyword("DATE"), 1274 date: keyword("DATE"),
1270 excerpt: keyword("DESCRIPTION") 1275 excerpt: keyword("DESCRIPTION")
1271 .filter(|d| !d.trim().is_empty()) 1276 .filter(|d| !d.trim().is_empty())
src/template.rs +3
@@ -50,6 +50,9 @@ pub struct PageContext {
50 /// shapes (`[2025-09-05 Fri 10:21:00]`, `<2024-05-01>`, `2024-05-01`), and a listing 50 /// shapes (`[2025-09-05 Fri 10:21:00]`, `<2024-05-01>`, `2024-05-01`), and a listing
51 /// wants one it can sort and print. `None` when the date is free text like "someday". 51 /// wants one it can sort and print. `None` when the date is free text like "someday".
52 pub date_iso: Option<String>, 52 pub date_iso: Option<String>,
53 /// The year from `date_iso`, so a listing can group by it with minijinja's
54 /// `groupby` filter — which takes an attribute name and cannot slice a date itself.
55 pub year: Option<String>,
53 /// `#+FILETAGS:` split on `:`. 56 /// `#+FILETAGS:` split on `:`.
54 pub tags: Vec<String>, 57 pub tags: Vec<String>,
55 /// A short summary for listings: `#+DESCRIPTION:` when the page sets one, otherwise 58 /// A short summary for listings: `#+DESCRIPTION:` when the page sets one, otherwise
tests/config.rs +36
@@ -2128,3 +2128,39 @@ fn a_pages_rule_without_a_template_is_rejected() {
2128 let err = config.validate().expect_err("empty template must fail"); 2128 let err = config.validate().expect_err("empty template must fail");
2129 assert!(format!("{err:#}").contains("blog"), "names it: {err:#}"); 2129 assert!(format!("{err:#}").contains("blog"), "names it: {err:#}");
2130} 2130}
2131
2132/// An archive wants year headings, and that is a template decision — but grouping by year
2133/// needs a year to group on, which a `YYYY-MM-DD` string cannot supply to `groupby`.
2134#[test]
2135fn a_listing_can_group_its_entries_by_year() {
2136 let root = tmpdir("listyear");
2137 let src = root.join("src");
2138 std::fs::create_dir_all(&src).unwrap();
2139 write_blog(&src, "");
2140 std::fs::write(
2141 src.join("templates/list.html"),
2142 "<html><body><ul>\
2143 {% for year, posts in pages | groupby(\"year\") | reverse %}\
2144 <li class=\"year\">{{ year if year else \"undated\" }}</li>\
2145 {% for p in posts %}<li>{{ p.title }}</li>{% endfor %}\
2146 {% endfor %}</ul></body></html>",
2147 )
2148 .unwrap();
2149 // A post with no date must still appear, under the default group.
2150 std::fs::write(src.join("blog/undated.org"), "#+TITLE: Undated\n\nBody.\n").unwrap();
2151 let out = root.join("out");
2152 build(&src, &out);
2153
2154 let html = page(&out, "blog/index.html");
2155 let years: Vec<&str> = html
2156 .split("class=\"year\">")
2157 .skip(1)
2158 .map(|s| s.split('<').next().unwrap())
2159 .collect();
2160 assert_eq!(
2161 years,
2162 vec!["2025", "2024", "undated"],
2163 "newest year first, undated last:\n{html}"
2164 );
2165 assert!(html.contains("Undated"), "the undated post is still listed");
2166}