krz/orgo

Lightning fast org-mode static site generator.

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

8bd684becd9816c9b1351d850e1a0355b61a4af7

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T19:28:44Z

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.
 README.md                     |  2 +-
 docs/guide/03-collections.org | 27 +++++++++++++++++++++++++++
 docs/guide/04-templates.org   |  1 +
 src/main.rs                   |  1 +
 src/site.rs                   |  5 +++++
 src/template.rs               |  3 +++
 tests/config.rs               | 36 ++++++++++++++++++++++++++++++++++++
 7 files changed, 74 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index f477b14..b084636 100644
--- a/README.md
+++ b/README.md
@@ -83,7 +83,7 @@ receive:
 | Variable | What it is |
 |---|---|
 | `body` | the rendered page HTML — use `{{ body \| safe }}` |
-| `page` | `.title`, `.url`, `.source`, `.date`, `.date_iso`, `.tags`, `.excerpt`, `.word_count`, `.reading_time`, `.toc`, `.keywords` |
+| `page` | `.title`, `.url`, `.source`, `.date`, `.date_iso`, `.year`, `.tags`, `.excerpt`, `.word_count`, `.reading_time`, `.toc`, `.keywords` |
 | `site` | `.title`, `.base_url`, `.description`, `.language` |
 | `nav` | list of `{title, url}`, relative to this page |
 | `root` | `../`-prefix back to the site root from this page |
diff --git a/docs/guide/03-collections.org b/docs/guide/03-collections.org
index 714bb67..bf4bcc4 100644
--- a/docs/guide/03-collections.org
+++ b/docs/guide/03-collections.org
@@ -51,6 +51,33 @@ syntax it was written in — =[2025-09-05 Fri 10:21:00]=, =<2024-05-01 Wed>= or
 *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
+<ul class="post-list">
+{% for year, posts in pages | groupby("year") | reverse %}
+  <li class="post-list-year">{{ year if year else "undated" }}</li>
+  {% for entry in posts %}
+  <li><time datetime="{{ entry.date_iso }}">{{ entry.date_iso }}</time>
+      <a href="{{ root }}{{ entry.url }}">{{ entry.title }}</a></li>
+  {% endfor %}
+{% endfor %}
+</ul>
+#+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=.
+
 ** nav = true
 
 The listing page joins the site navigation. This is how a section landing page — =/blog/=,
diff --git a/docs/guide/04-templates.org b/docs/guide/04-templates.org
index 27ab507..f6ecc36 100644
--- a/docs/guide/04-templates.org
+++ b/docs/guide/04-templates.org
@@ -95,6 +95,7 @@ Empty on generated pages, which build their content from =pages= or =groups= ins
 | =source= | Source path relative to the source root, e.g. =blog/post.org=. |
 | =date= | =#+DATE:= verbatim, in whatever org syntax was written. |
 | =date_iso= | The =YYYY-MM-DD= inside it, or =none=. |
+| =year= | The year from that date, for grouping a listing. |
 | =tags= | =#+FILETAGS:=, split. |
 | =excerpt= | =#+DESCRIPTION:=, or the first paragraph. |
 | =word_count= | Words of prose, excluding code blocks. |
diff --git a/src/main.rs b/src/main.rs
index aeb4bff..2b2caed 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -314,6 +314,7 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
         source: input.to_string(),
         date: None,
         date_iso: None,
+        year: None,
         tags: Vec::new(),
         excerpt: String::new(),
         word_count: 0,
diff --git a/src/site.rs b/src/site.rs
index 020c409..514c5b4 100644
--- a/src/site.rs
+++ b/src/site.rs
@@ -479,6 +479,7 @@ fn listing_context(listing: &Listing) -> PageContext {
         source: String::new(),
         date: None,
         date_iso: None,
+        year: None,
         tags: Vec::new(),
         excerpt: String::new(),
         word_count: 0,
@@ -1266,6 +1267,10 @@ fn page_context(doc: &Document, output: &Utf8Path, config: &Config) -> PageConte
         url: output.to_string(),
         source: doc.source_path.to_string(),
         date_iso: keyword("DATE").as_deref().and_then(iso_date),
+        year: keyword("DATE")
+            .as_deref()
+            .and_then(iso_date)
+            .map(|d| d[..4].to_string()),
         date: keyword("DATE"),
         excerpt: keyword("DESCRIPTION")
             .filter(|d| !d.trim().is_empty())
diff --git a/src/template.rs b/src/template.rs
index 672abd3..e46a454 100644
--- a/src/template.rs
+++ b/src/template.rs
@@ -50,6 +50,9 @@ pub struct PageContext {
     /// shapes (`[2025-09-05 Fri 10:21:00]`, `<2024-05-01>`, `2024-05-01`), and a listing
     /// wants one it can sort and print. `None` when the date is free text like "someday".
     pub date_iso: Option<String>,
+    /// The year from `date_iso`, so a listing can group by it with minijinja's
+    /// `groupby` filter — which takes an attribute name and cannot slice a date itself.
+    pub year: Option<String>,
     /// `#+FILETAGS:` split on `:`.
     pub tags: Vec<String>,
     /// A short summary for listings: `#+DESCRIPTION:` when the page sets one, otherwise
diff --git a/tests/config.rs b/tests/config.rs
index b8ea988..7fcfb8f 100644
--- a/tests/config.rs
+++ b/tests/config.rs
@@ -2128,3 +2128,39 @@ fn a_pages_rule_without_a_template_is_rejected() {
     let err = config.validate().expect_err("empty template must fail");
     assert!(format!("{err:#}").contains("blog"), "names it: {err:#}");
 }
+
+/// An archive wants year headings, and that is a template decision — but grouping by year
+/// needs a year to group on, which a `YYYY-MM-DD` string cannot supply to `groupby`.
+#[test]
+fn a_listing_can_group_its_entries_by_year() {
+    let root = tmpdir("listyear");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_blog(&src, "");
+    std::fs::write(
+        src.join("templates/list.html"),
+        "<html><body><ul>\
+         {% for year, posts in pages | groupby(\"year\") | reverse %}\
+         <li class=\"year\">{{ year if year else \"undated\" }}</li>\
+         {% for p in posts %}<li>{{ p.title }}</li>{% endfor %}\
+         {% endfor %}</ul></body></html>",
+    )
+    .unwrap();
+    // A post with no date must still appear, under the default group.
+    std::fs::write(src.join("blog/undated.org"), "#+TITLE: Undated\n\nBody.\n").unwrap();
+    let out = root.join("out");
+    build(&src, &out);
+
+    let html = page(&out, "blog/index.html");
+    let years: Vec<&str> = html
+        .split("class=\"year\">")
+        .skip(1)
+        .map(|s| s.split('<').next().unwrap())
+        .collect();
+    assert_eq!(
+        years,
+        vec!["2025", "2024", "undated"],
+        "newest year first, undated last:\n{html}"
+    );
+    assert!(html.contains("Undated"), "the undated post is still listed");
+}