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 |
| 52 | leads a dated archive. |
52 | leads a dated archive. |
| 53 | |
53 | |
| |
54 | ** Grouping a listing by year |
| |
55 | |
| |
56 | An archive usually wants year headings, and that is a *template* decision rather than a |
| |
57 | config 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 |
| |
59 | and 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 |
| |
74 | the =order = "desc"= the entries themselves already use, and leaving undated pages in a |
| |
75 | group of their own at the end. |
| |
76 | |
| |
77 | Name that group in the template rather than with =groupby='s =default== argument, which |
| |
78 | covers 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 | |
| 56 | The listing page joins the site navigation. This is how a section landing page — =/blog/=, |
83 | The 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] |
| |
2135 | fn 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 | } |