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
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(-) @@ -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 | @@ -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/=, @@ -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. | @@ -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, @@ -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()) @@ -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 @@ -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"); +}