krz/orgo

Lightning fast org-mode static site generator.

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

ec5b3807fe43db7771aa3c13621470d7e17936e3

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T05:21:36Z

v0.8: tag pages and tag indexes (grouped collections)

One collection emitting many pages — the shape the listing feature could not express.
Adding `group_by` to a collection splits it into groups and emits a page per group, plus
an optional index of the groups themselves.

    [[collections]]
    source = "blog"
    group_by = "tags"
    output = "tags/{tag}.html"
    index_output = "tags/index.html"

`group_by = "tags"` is multi-valued: a post appears under every tag it carries. Any other
value names a single-valued #+KEYWORD:, so group_by = "category" buckets by #+CATEGORY:
with no extra machinery. A group page gets its own posts as `pages` and itself as
`group` (name/slug/url/count); the index gets `groups`, sorted by name so it reads
alphabetically rather than in discovery order. A grouped collection contributes its
*index* to the nav — a nav listing every tag is the same mistake as a nav listing every
page.

The interesting decision was what a group page is allowed to see. Passing `groups` to
every tag page would support a tag cloud, but it also makes every tag page depend on
every group, so one new post re-rendered all of them — measured at 6 pages instead of 4
on a three-tag site, scaling with tag count. `groups` now goes to the index only, and a
tag page depends on its own posts. Adding a post tagged `rust` re-renders that post, its
section index, tags/rust.html, and the tag index whose counts changed. Four pages,
whatever the tag count.

Config is validated where a mistake would otherwise be silent: an `output` without
{tag} would have every group overwrite one file; two tags whose slugs collide
(`web_dev` and `web@dev` both become `web-dev`) would have one page overwrite the other;
`index_output` without `group_by` indexes nothing.

RenderContext replaces the positional arguments the template call had grown — nine and
counting — so adding template data no longer means touching every call site.

Milestone: with blog, garden and tag collections configured, org-ssg reproduces all 182
URLs of the weblorg-built incumbent, up from 179 two commits ago. An unchanged rebuild
of those 195 pages renders zero.
 Cargo.lock      |   2 +-
 Cargo.toml      |   2 +-
 README.md       |  47 ++++++++++-
 src/config.rs   |  76 +++++++++++++++--
 src/main.rs     |  11 ++-
 src/site.rs     | 190 ++++++++++++++++++++++++++++++++++++-------
 src/template.rs | 150 +++++++++++++++++++++++++---------
 tests/config.rs | 246 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 8 files changed, 645 insertions(+), 79 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 89b31bc..7d20934 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -569,7 +569,7 @@ dependencies = [
 
 [[package]]
 name = "org-ssg"
-version = "0.7.0"
+version = "0.8.0"
 dependencies = [
  "anyhow",
  "blake3",
diff --git a/Cargo.toml b/Cargo.toml
index 1969193..a846e27 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "org-ssg"
-version = "0.7.0"
+version = "0.8.0"
 edition = "2021"
 description = "Org-mode static site generator that renders the org element tree straight to HTML"
 license = "MIT"
diff --git a/README.md b/README.md
index 95b1124..eeb8e85 100644
--- a/README.md
+++ b/README.md
@@ -120,6 +120,47 @@ written in — `[2025-09-05 Fri 10:21:00]`, `<2024-05-01 Wed>` or bare `2024-05-
 also the sort key; pages without a parseable date sort last, so an undated draft never
 leads a dated archive.
 
+#### Tag pages
+
+Add `group_by` and the collection emits one page *per group* instead of one page total,
+plus an optional index of the groups:
+
+```toml
+[[collections]]
+source = "blog"
+group_by = "tags"             # "tags", or any #+KEYWORD: name to group by its value
+output = "tags/{tag}.html"    # {tag} is replaced by each group's slug
+template = "tag.html"
+title = "Tagged: {tag}"
+index_output = "tags/index.html"   # the tag index
+index_template = "tags.html"
+index_title = "Tags"
+nav = true                    # adds the *index*, not every tag
+```
+
+A group page receives its own posts as `pages` and itself as `group`
+(`.name`, `.slug`, `.url`, `.count`). The index receives `groups` — every group, sorted
+by name:
+
+```jinja
+<ul>{% for tag in groups %}
+  <li><a href="{{ root }}{{ tag.url }}">{{ tag.name }}</a> ({{ tag.count }})</li>
+{% endfor %}</ul>
+```
+
+`group_by = "tags"` is multi-valued: a post appears under every tag it carries. Any other
+value names a single-valued `#+KEYWORD:`, so `group_by = "category"` buckets by
+`#+CATEGORY:`.
+
+Two tags that would produce the same URL (`web_dev` and `web@dev` both slugify to
+`web-dev`) are a build error rather than one page silently overwriting the other.
+
+A tag page depends on its own posts and nothing else, so adding a post tagged `rust`
+re-renders that post, its section index, `tags/rust.html`, and the tag index whose counts
+changed — four pages, not one per tag. That precision is why `groups` is given to the
+index and not to every group page: a page that can see every group depends on every
+group.
+
 **A feed is a listing page with an XML template**, not a separate feature — templates are
 loaded by full filename and any extension, so `output = "feed.xml"` with
 `template = "feed.xml"` is all it takes.
@@ -196,6 +237,7 @@ all-of-org. Phase 0 checked this line against a real 179-file corpus and found i
 | **7** | **Hardening: rayon parallelism, error locations in parse diagnostics** | **done** |
 | **8** | **General use: config file, user templates, nav modes, `init` scaffold, safe discovery** | **done** |
 | **9** | **Generated listing pages: `[[collections]]`, sorted indexes, feeds via XML templates** | **done** |
+| **10** | **Grouped collections: one page per tag plus a tag index — full parity with the incumbent** | **done** |
 
 ### v0.2 in / out
 
@@ -302,7 +344,8 @@ Emacs does.
 The audit runs against any corpus — point it at your own notes before trusting this tool
 with them. The numbers below come from a 179-file site published today by weblorg, a
 wrapper around org's own HTML exporter, which makes it both a realistic workload and a
-directly comparable incumbent.
+directly comparable incumbent. With collections configured, org-ssg now reproduces
+**all 182 of that site's URLs**.
 
 ```
 cargo run -- audit <src-dir>   # what does this corpus use, and is it in scope?
@@ -458,7 +501,7 @@ PARSE/RESOLVE/RENDER), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserro
 
 ```
 cargo build
-cargo test                                                # 99 tests
+cargo test                                                # 107 tests
 cargo run -- init my-site                                 # scaffold a new site
 cargo run -- build fixtures/minimal.org -o minimal.html   # single file
 cargo run -- build fixtures/site -o _site                 # whole site (incremental)
diff --git a/src/config.rs b/src/config.rs
index b97e9ba..03a8dae 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -53,6 +53,21 @@ pub struct Collection {
     pub template: String,
     /// Title for the generated page, available to the template as `page.title`.
     pub title: String,
+    /// Split the collection into groups and emit one page per group.
+    ///
+    /// `"tags"` groups by `#+FILETAGS:`, where a page belongs to every tag it carries.
+    /// Any other value names a `#+KEYWORD:` and groups by its value, so `"category"`
+    /// buckets pages by `#+CATEGORY:`. Empty means one page for the whole collection.
+    ///
+    /// When set, `output` and `title` may contain `{tag}`, replaced by the group — and
+    /// `output` must, or every group would write to the same file.
+    pub group_by: String,
+    /// Where to write a page listing the groups themselves — a tag index. Empty means
+    /// no such page. Only meaningful with `group_by`.
+    pub index_output: Utf8PathBuf,
+    /// Template for the group-index page. It receives `groups` rather than `pages`.
+    pub index_template: String,
+    pub index_title: String,
     pub sort: SortKey,
     pub order: SortOrder,
     /// Add this listing page to the site navigation. This is how a section landing page
@@ -67,6 +82,10 @@ impl Default for Collection {
             output: Utf8PathBuf::from("index.html"),
             template: "list.html".to_string(),
             title: "Index".to_string(),
+            group_by: String::new(),
+            index_output: Utf8PathBuf::new(),
+            index_template: "tags.html".to_string(),
+            index_title: "Tags".to_string(),
             sort: SortKey::default(),
             order: SortOrder::default(),
             nav: false,
@@ -74,6 +93,9 @@ impl Default for Collection {
     }
 }
 
+/// The `{tag}` placeholder in a grouped collection's `output` and `title`.
+pub const GROUP_PLACEHOLDER: &str = "{tag}";
+
 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "kebab-case")]
 pub enum SortKey {
@@ -251,16 +273,47 @@ impl Config {
         }
         let mut seen: Vec<&Utf8PathBuf> = Vec::new();
         for collection in &self.collections {
-            if collection.output.as_str().is_empty() {
-                anyhow::bail!("a collection has an empty `output`; it needs a file to write");
+            let grouped = !collection.group_by.is_empty();
+            if collection.output.as_str().is_empty() && collection.index_output.as_str().is_empty()
+            {
+                anyhow::bail!("a collection has no `output`; it needs a file to write");
+            }
+            if grouped
+                && !collection.output.as_str().is_empty()
+                && !collection.output.as_str().contains(GROUP_PLACEHOLDER)
+            {
+                anyhow::bail!(
+                    "collection output {} groups by \"{}\" but has no {GROUP_PLACEHOLDER} in \
+                     its path, so every group would overwrite the same file",
+                    collection.output,
+                    collection.group_by
+                );
             }
-            if seen.contains(&&collection.output) {
+            if !grouped && collection.output.as_str().contains(GROUP_PLACEHOLDER) {
                 anyhow::bail!(
-                    "two collections both write to {}; give them different `output` paths",
+                    "collection output {} uses {GROUP_PLACEHOLDER} but sets no `group_by`",
                     collection.output
                 );
             }
-            seen.push(&collection.output);
+            if !grouped && !collection.index_output.as_str().is_empty() {
+                anyhow::bail!(
+                    "collection writes an `index_output` of {} but sets no `group_by`; \
+                     there are no groups to index",
+                    collection.index_output
+                );
+            }
+            for path in [&collection.output, &collection.index_output] {
+                if path.as_str().is_empty() || path.as_str().contains(GROUP_PLACEHOLDER) {
+                    continue;
+                }
+                if seen.contains(&path) {
+                    anyhow::bail!(
+                        "two collections both write to {path}; give them different \
+                         `output` paths"
+                    );
+                }
+                seen.push(path);
+            }
         }
         if !self.site.base_url.is_empty() && self.site.base_url.ends_with('/') {
             anyhow::bail!(
@@ -323,4 +376,17 @@ title = "Blog"
 sort = "date"              # date | title | path
 order = "desc"             # desc | asc
 nav = true                 # put this listing page in the site nav
+
+# One page per tag, plus an index of all tags. `{tag}` in `output`/`title` is replaced
+# by each tag; the index gets `groups` instead of `pages`.
+[[collections]]
+source = "blog"
+group_by = "tags"            # "tags", or any #+KEYWORD: name to group by its value
+output = "tags/{tag}.html"
+template = "list.html"
+title = "Tagged: {tag}"
+index_output = "tags/index.html"
+index_template = "tags.html"
+index_title = "Tags"
+nav = true                   # adds the tag *index*, not every tag
 "#;
diff --git a/src/main.rs b/src/main.rs
index dfd119d..d522622 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -11,7 +11,7 @@ use org_ssg::config::Config;
 use org_ssg::render::{self, render, Html, SyntectHighlighter};
 use org_ssg::resolve::ResolvedDoc;
 use org_ssg::site::{build_site, BuildOptions, SYNTAX_STYLESHEET};
-use org_ssg::template::{PageContext, SiteContext, Templater};
+use org_ssg::template::{PageContext, RenderContext, SiteContext, Templater};
 
 #[derive(Parser)]
 #[command(name = "org-ssg", version, about = "Org-mode static site generator")]
@@ -132,7 +132,7 @@ fn main() -> Result<()> {
 /// in a directory that has content is safe and additive rather than destructive.
 fn init(dir: &Utf8Path) -> Result<()> {
     use org_ssg::config::{CONFIG_FILE, STARTER_CONFIG};
-    use org_ssg::template::{starter_template, STARTER_LIST_TEMPLATE};
+    use org_ssg::template::{starter_template, STARTER_LIST_TEMPLATE, STARTER_TAGS_TEMPLATE};
 
     fs::create_dir_all(dir).with_context(|| format!("creating {dir}"))?;
     fs::create_dir_all(dir.join("templates")).with_context(|| format!("creating {dir}/templates"))?;
@@ -165,10 +165,11 @@ fn init(dir: &Utf8Path) -> Result<()> {
         "in org-ssg.toml, newest first.\n",
     );
 
-    let files: [(Utf8PathBuf, &str); 5] = [
+    let files: [(Utf8PathBuf, &str); 6] = [
         (dir.join(CONFIG_FILE), STARTER_CONFIG),
         (dir.join("templates/base.html"), starter_template()),
         (dir.join("templates/list.html"), STARTER_LIST_TEMPLATE),
+        (dir.join("templates/tags.html"), STARTER_TAGS_TEMPLATE),
         (dir.join("index.org"), index),
         (dir.join("blog/first-post.org"), post),
     ];
@@ -295,8 +296,10 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
         tags: Vec::new(),
         keywords: Default::default(),
     };
+    let mut ctx = RenderContext::new(&site, &page_ctx, &[], SYNTAX_STYLESHEET, "");
+    ctx.body = &fragment;
     let page = templater
-        .render_page(&site, &page_ctx, &fragment, &[], SYNTAX_STYLESHEET, "", None)
+        .render_page(&ctx)
         .with_context(|| format!("templating {input}"))?;
     fs::write(output, page).with_context(|| format!("writing output file {output}"))?;
 
diff --git a/src/site.rs b/src/site.rs
index bf47934..d7938d4 100644
--- a/src/site.rs
+++ b/src/site.rs
@@ -9,7 +9,7 @@
 //! `--no-cache` forces a full rebuild; the cache is never a correctness dependency, so a
 //! full rebuild and an incremental rebuild produce byte-identical output.
 
-use std::collections::HashSet;
+use std::collections::{HashMap, HashSet};
 use std::fs;
 
 use anyhow::{Context, Result};
@@ -28,8 +28,8 @@ use crate::parser::parse;
 use crate::render::{self, render_with, Html, RenderOptions, SyntectHighlighter};
 use crate::resolve::resolve;
 use crate::config::{self, Config, NavMode, SortKey, SortOrder};
-use crate::template::{NavItem, PageContext, SiteContext, Templater};
-use crate::util::{output_path, output_url, relative_root};
+use crate::template::{GroupContext, NavItem, PageContext, RenderContext, SiteContext, Templater};
+use crate::util::{output_path, output_url, relative_root, slugify};
 
 /// A fully built page: source and output paths (relative to their roots) and its
 /// final templated HTML.
@@ -105,13 +105,18 @@ struct PagePrep {
     context: PageContext,
 }
 
-/// A generated listing page, resolved against the pages it lists.
+/// A generated page, resolved against the pages it lists.
 struct Listing {
     output: Utf8PathBuf,
     template: String,
     title: String,
-    /// The pages it lists, already sorted.
+    /// The pages it lists, already sorted. Empty for a group index, which lists groups.
     entries: Vec<PageContext>,
+    /// The group this page is for, when it belongs to a grouped collection.
+    group: Option<GroupContext>,
+    /// Every group of the owning collection. The content of a group index, and context
+    /// for a group page.
+    groups: Vec<GroupContext>,
 }
 
 /// The `YYYY-MM-DD` inside an org date, if there is one. Org dates arrive as
@@ -167,15 +172,102 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
             entries.reverse();
         }
 
-        listings.push(Listing {
-            output: collection.output.clone(),
-            template: collection.template.clone(),
-            title: collection.title.clone(),
-            entries,
-        });
+        if collection.group_by.is_empty() {
+            listings.push(Listing {
+                output: collection.output.clone(),
+                template: collection.template.clone(),
+                title: collection.title.clone(),
+                entries,
+                group: None,
+                groups: Vec::new(),
+            });
+            continue;
+        }
+
+        // Grouped: one page per distinct term. `entries` is already sorted, and grouping
+        // preserves that order within each group.
+        let mut terms: Vec<String> = Vec::new();
+        let mut members: HashMap<String, Vec<PageContext>> = HashMap::new();
+        for entry in &entries {
+            for term in group_terms(entry, &collection.group_by) {
+                if !members.contains_key(&term) {
+                    terms.push(term.clone());
+                }
+                members.entry(term).or_default().push(entry.clone());
+            }
+        }
+        // Terms are discovered in page order, which is arbitrary from a reader's point of
+        // view; sort so a tag index reads alphabetically and hashes deterministically.
+        terms.sort();
+
+        let mut groups: Vec<GroupContext> = Vec::new();
+        let mut slugs: HashMap<String, String> = HashMap::new();
+        for term in &terms {
+            let slug = slugify(term);
+            if slug.is_empty() {
+                anyhow::bail!(
+                    "the {} value {term:?} has no URL-safe form; it cannot name a page",
+                    collection.group_by
+                );
+            }
+            // `C++` and `C  ++` both slugify to `c`, and one would silently overwrite the
+            // other's page.
+            if let Some(other) = slugs.insert(slug.clone(), term.clone()) {
+                anyhow::bail!(
+                    "the {} values {other:?} and {term:?} both become {slug:?} in a URL; \
+                     rename one so their pages do not collide",
+                    collection.group_by
+                );
+            }
+            groups.push(GroupContext {
+                name: term.clone(),
+                slug: slug.clone(),
+                url: if collection.output.as_str().is_empty() {
+                    String::new()
+                } else {
+                    collection
+                        .output
+                        .as_str()
+                        .replace(config::GROUP_PLACEHOLDER, &slug)
+                }
+                .to_string(),
+                count: members.get(term).map(Vec::len).unwrap_or(0),
+            });
+        }
+
+        if !collection.output.as_str().is_empty() {
+            for group in &groups {
+                listings.push(Listing {
+                    output: Utf8PathBuf::from(&group.url),
+                    template: collection.template.clone(),
+                    title: collection
+                        .title
+                        .replace(config::GROUP_PLACEHOLDER, &group.name),
+                    entries: members.get(&group.name).cloned().unwrap_or_default(),
+                    group: Some(group.clone()),
+                    // Deliberately not the whole group list. A page that can see every
+                    // group depends on every group, so one new post would re-render every
+                    // tag page — cost that scales with tag count, to support a tag cloud
+                    // nobody has asked for. A tag page depends on its own posts, and the
+                    // group index is where the group list belongs.
+                    groups: Vec::new(),
+                });
+            }
+        }
+        if !collection.index_output.as_str().is_empty() {
+            listings.push(Listing {
+                output: collection.index_output.clone(),
+                template: collection.index_template.clone(),
+                title: collection.index_title.clone(),
+                entries: Vec::new(),
+                group: None,
+                groups: groups.clone(),
+            });
+        }
     }
 
-    // A listing page writing over a real page would silently replace it.
+    // A generated page writing over a real page would silently replace it. Group pages
+    // make this easy to hit by accident, since their paths come from content.
     for listing in &listings {
         if let Some(clash) = preps.iter().find(|p| p.output == listing.output) {
             anyhow::bail!(
@@ -185,9 +277,41 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
             );
         }
     }
+    let mut claimed: HashMap<&Utf8PathBuf, ()> = HashMap::new();
+    for listing in &listings {
+        if claimed.insert(&listing.output, ()).is_some() {
+            anyhow::bail!("two generated pages both write to {}", listing.output);
+        }
+    }
     Ok(listings)
 }
 
+/// The `(output, title)` a collection contributes to the nav. A grouped collection
+/// offers its index; an ungrouped one offers its single page.
+fn nav_target(collection: &config::Collection) -> (Utf8PathBuf, String) {
+    if !collection.group_by.is_empty() {
+        return (
+            collection.index_output.clone(),
+            collection.index_title.clone(),
+        );
+    }
+    (collection.output.clone(), collection.title.clone())
+}
+
+/// The group terms a page belongs to. `tags` is multi-valued — a page appears under
+/// every tag it carries — while any other key names a single-valued `#+KEYWORD:`.
+fn group_terms(page: &PageContext, group_by: &str) -> Vec<String> {
+    if group_by.eq_ignore_ascii_case("tags") {
+        return page.tags.clone();
+    }
+    page.keywords
+        .get(&group_by.to_lowercase())
+        .map(|v| v.trim())
+        .filter(|v| !v.is_empty())
+        .map(|v| vec![v.to_string()])
+        .unwrap_or_default()
+}
+
 /// Everything a listing template can see about its entries, hashed. This is the listing
 /// page's whole dependency: if none of these change, its output cannot have changed.
 fn listing_entries_hash(listing: &Listing) -> Hash {
@@ -203,6 +327,14 @@ fn listing_entries_hash(listing: &Listing) -> Hash {
                 ),
             ]
         })
+        // A group index has no entries at all — its content *is* the group list, so the
+        // groups have to be in the hash or a tag index would never notice a new tag.
+        .chain(
+            listing
+                .groups
+                .iter()
+                .map(|g| (g.url.clone(), format!("{}\u{0}{}", g.name, g.count))),
+        )
         .chain([(listing.title.clone(), listing.template.clone())])
         .collect();
     // Entry *order* is meaningful in a listing, so this hashes the sorted-by-us sequence
@@ -354,9 +486,11 @@ fn prepare_pages(
         .map(|(_, out, title)| (out.clone(), title.clone()))
         .collect();
     // A listing page is exactly what a section's nav entry should point at — `/blog/`
-    // rather than any one post — so collections can opt into the nav directly.
-    for collection in config.collections.iter().filter(|c| c.nav) {
-        entries.push((collection.output.clone(), collection.title.clone()));
+    // rather than any one post — so collections can opt into the nav directly. For a
+    // grouped collection that means its *index*: a nav listing every tag is the same
+    // mistake as a nav listing every page.
+    for (output, title) in config.collections.iter().filter(|c| c.nav).map(nav_target) {
+        entries.push((output, title));
     }
 
     // RESOLVE reads the shared symbol table and writes only into its own page's output,
@@ -466,8 +600,11 @@ fn render_page(
     // Relative to the *output* path, since `#+SLUG:` can move a page between depths.
     let root = relative_root(&p.output);
     let stylesheet = format!("{root}{SYNTAX_STYLESHEET}");
+    let mut ctx = RenderContext::new(site, &p.context, &p.nav, &stylesheet, &root);
+    ctx.body = &fragment;
+    ctx.pages = pages;
     templater
-        .render_page(site, &p.context, &fragment, &p.nav, &stylesheet, &root, pages)
+        .render_page(&ctx)
         .with_context(|| format!("templating {}", p.source))
 }
 
@@ -529,7 +666,8 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
                 cfg.collections
                     .iter()
                     .filter(|c| c.nav)
-                    .map(|c| (c.output.to_string(), c.title.clone())),
+                    .map(nav_target)
+                    .map(|(out, title)| (out.to_string(), title)),
             )
             .collect()
     };
@@ -659,17 +797,15 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
                 fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?;
             }
             let root = relative_root(&listing.output);
+            let stylesheet = format!("{root}{SYNTAX_STYLESHEET}");
+            let nav = listing_nav(&preps, &listing.output);
+            let page_ctx = listing_context(listing);
+            let mut ctx = RenderContext::new(&site, &page_ctx, &nav, &stylesheet, &root);
+            ctx.pages = Some(&listing.entries);
+            ctx.group = listing.group.as_ref();
+            ctx.groups = &listing.groups;
             let html = templater
-                .render_named(
-                    &listing.template,
-                    &site,
-                    &listing_context(listing),
-                    "",
-                    &listing_nav(&preps, &listing.output),
-                    &format!("{root}{SYNTAX_STYLESHEET}"),
-                    &root,
-                    Some(&listing.entries),
-                )
+                .render(&listing.template, &ctx)
                 .with_context(|| {
                     format!(
                         "rendering collection {} with template {} (available: {})",
diff --git a/src/template.rs b/src/template.rs
index 18f2fb8..abb7d71 100644
--- a/src/template.rs
+++ b/src/template.rs
@@ -184,56 +184,128 @@ impl Templater {
         self.sources.iter().map(|(n, _)| n.as_str()).collect()
     }
 
-    /// fragment + page metadata → full page, through the base layout.
-    ///
-    /// `stylesheet` and `root` are URLs relative to *this* page, so a template works the
-    /// same at any directory depth.
-    #[allow(clippy::too_many_arguments)]
-    pub fn render_page(
-        &self,
-        site: &SiteContext,
-        page: &PageContext,
-        body: &str,
-        nav: &[NavItem],
-        stylesheet: &str,
-        root: &str,
-        pages: Option<&[PageContext]>,
-    ) -> Result<String, TemplateError> {
-        self.render_named(BASE_TEMPLATE_NAME, site, page, body, nav, stylesheet, root, pages)
-    }
-
-    /// Render through a named template. Generated listing pages use this to reach their
-    /// own layout; the context is identical to a normal page's, so a listing template can
+    /// Render through a named template. Generated pages use this to reach their own
+    /// layout; the context is identical to a normal page's, so a listing template can
     /// `{% extends "base.html" %}` and inherit the site's chrome for free.
-    #[allow(clippy::too_many_arguments)]
-    pub fn render_named(
-        &self,
-        template: &str,
-        site: &SiteContext,
-        page: &PageContext,
-        body: &str,
-        nav: &[NavItem],
-        stylesheet: &str,
-        root: &str,
-        pages: Option<&[PageContext]>,
-    ) -> Result<String, TemplateError> {
+    pub fn render(&self, template: &str, ctx: &RenderContext) -> Result<String, TemplateError> {
         let tmpl = self
             .env
             .get_template(template)
             .map_err(|e| TemplateError::Render(e.to_string()))?;
         tmpl.render(context! {
-            site => site,
-            page => page,
-            body => body,
-            nav => nav,
-            stylesheet => stylesheet,
-            root => root,
-            pages => pages,
+            site => ctx.site,
+            page => ctx.page,
+            body => ctx.body,
+            nav => ctx.nav,
+            stylesheet => ctx.stylesheet,
+            root => ctx.root,
+            pages => ctx.pages,
+            group => ctx.group,
+            groups => ctx.groups,
         })
         .map_err(|e| TemplateError::Render(render_error_detail(e)))
     }
+
+    /// Render through the site's base layout.
+    pub fn render_page(&self, ctx: &RenderContext) -> Result<String, TemplateError> {
+        self.render(BASE_TEMPLATE_NAME, ctx)
+    }
+}
+
+/// One group of a grouped collection — a tag, or a `#+CATEGORY:` value.
+#[derive(Debug, Clone, Serialize)]
+pub struct GroupContext {
+    /// The term as written, e.g. `Rust Lang`.
+    pub name: String,
+    /// URL-safe form used in the output path, e.g. `rust-lang`.
+    pub slug: String,
+    /// Output path of this group's page, relative to the site root. Empty when the
+    /// collection emits no per-group pages.
+    pub url: String,
+    /// How many pages carry this term.
+    pub count: usize,
+}
+
+/// Everything a template can see. A struct rather than a dozen positional arguments,
+/// because the list grows every time templates learn something new.
+pub struct RenderContext<'a> {
+    pub site: &'a SiteContext,
+    pub page: &'a PageContext,
+    /// Rendered page HTML. Empty for generated pages, which build their body from
+    /// `pages`/`groups` instead.
+    pub body: &'a str,
+    pub nav: &'a [NavItem],
+    /// URL of the syntax stylesheet, relative to this page.
+    pub stylesheet: &'a str,
+    /// `../`-prefix back to the site root from this page.
+    pub root: &'a str,
+    /// The pages this listing shows, or every page when `expose_page_list` is on.
+    pub pages: Option<&'a [PageContext]>,
+    /// The group this page is for, on a grouped collection's per-group page.
+    pub group: Option<&'a GroupContext>,
+    /// Every group of a grouped collection — the group index's content. Empty on a
+    /// per-group page, which depends on its own entries and not on the other groups.
+    pub groups: &'a [GroupContext],
+}
+
+impl<'a> RenderContext<'a> {
+    /// A context with only the universally-present parts filled in.
+    pub fn new(
+        site: &'a SiteContext,
+        page: &'a PageContext,
+        nav: &'a [NavItem],
+        stylesheet: &'a str,
+        root: &'a str,
+    ) -> Self {
+        RenderContext {
+            site,
+            page,
+            body: "",
+            nav,
+            stylesheet,
+            root,
+            pages: None,
+            group: None,
+            groups: &[],
+        }
+    }
 }
 
+/// The starter tag-index template written by `org-ssg init`: shows how `groups` is
+/// iterated, and how a group page is linked.
+pub const STARTER_TAGS_TEMPLATE: &str = r#"<!DOCTYPE html>
+<html lang="{{ site.language }}">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{ page.title }} &middot; {{ site.title }}</title>
+{%- if stylesheet %}
+<link rel="stylesheet" href="{{ stylesheet }}">
+{%- endif %}
+</head>
+<body>
+<header>
+<a class="site-title" href="{{ root }}index.html">{{ site.title }}</a>
+{%- if nav %}
+<nav>
+{%- for item in nav %}
+<a href="{{ item.url }}">{{ item.title }}</a>
+{%- endfor %}
+</nav>
+{%- endif %}
+</header>
+<main>
+<h1>{{ page.title }}</h1>
+<ul class="tag-list">
+{%- for tag in groups %}
+<li><a href="{{ root }}{{ tag.url }}">{{ tag.name }}</a> ({{ tag.count }})</li>
+{%- endfor %}
+</ul>
+</main>
+</body>
+</html>
+"#;
+
 /// The starter listing template written by `org-ssg init`: a blog index, showing how a
 /// collection's `pages` are iterated.
 pub const STARTER_LIST_TEMPLATE: &str = r#"<!DOCTYPE html>
diff --git a/tests/config.rs b/tests/config.rs
index fb189e3..17f3a37 100644
--- a/tests/config.rs
+++ b/tests/config.rs
@@ -812,3 +812,249 @@ fn a_missing_collection_template_names_the_ones_that_exist() {
     assert!(message.contains("nope.html"), "names the missing one: {message}");
     assert!(message.contains("list.html"), "lists what is available: {message}");
 }
+
+// ---------------------------------------------------------------------------
+// Grouped collections: tag pages and the tag index
+// ---------------------------------------------------------------------------
+
+/// Posts carrying tags, a per-tag template, a tag-index template, and a grouped
+/// collection over them.
+fn write_tagged_blog(src: &Utf8PathBuf, extra: &str) {
+    std::fs::create_dir_all(src.join("blog")).unwrap();
+    std::fs::create_dir_all(src.join("templates")).unwrap();
+    std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nWelcome.\n").unwrap();
+    for (name, title, date, tags) in [
+        ("a", "Post A", "2024-01-01", ":rust:web:"),
+        ("b", "Post B", "2024-02-02", ":rust:"),
+        ("c", "Post C", "2024-03-03", ":emacs:"),
+        ("d", "Post D", "2024-04-04", ""),
+    ] {
+        let filetags = if tags.is_empty() {
+            String::new()
+        } else {
+            format!("#+FILETAGS: {tags}\n")
+        };
+        std::fs::write(
+            src.join(format!("blog/{name}.org")),
+            format!("#+TITLE: {title}\n#+DATE: {date}\n{filetags}\nBody.\n"),
+        )
+        .unwrap();
+    }
+    std::fs::write(
+        src.join("templates/tag.html"),
+        "<html><body><h1>{{ page.title }}</h1><p>slug={{ group.slug }} count={{ group.count }}</p>\
+         <ul>{% for p in pages %}<li>{{ p.title }}</li>{% endfor %}</ul></body></html>",
+    )
+    .unwrap();
+    std::fs::write(
+        src.join("templates/tags.html"),
+        "<html><body><h1>{{ page.title }}</h1><ul>\
+         {% for g in groups %}<li>{{ g.name }}={{ g.count }}@{{ root }}{{ g.url }}</li>\
+         {% endfor %}</ul></body></html>",
+    )
+    .unwrap();
+    std::fs::write(
+        src.join("org-ssg.toml"),
+        format!(
+            "[[collections]]\nsource = \"blog\"\ngroup_by = \"tags\"\n\
+             output = \"tags/{{tag}}.html\"\ntemplate = \"tag.html\"\ntitle = \"Tagged: {{tag}}\"\n\
+             index_output = \"tags/index.html\"\nindex_template = \"tags.html\"\n\
+             index_title = \"All tags\"\n{extra}"
+        ),
+    )
+    .unwrap();
+}
+
+/// One collection, many outputs — the shape the earlier listing feature could not express.
+#[test]
+fn a_grouped_collection_emits_one_page_per_tag() {
+    let root = tmpdir("tags");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_tagged_blog(&src, "");
+    let out = root.join("out");
+    build(&src, &out);
+
+    for (tag, expected) in [("rust", vec!["Post A", "Post B"]), ("emacs", vec!["Post C"])] {
+        let html = page(&out, &format!("tags/{tag}.html"));
+        for title in &expected {
+            assert!(html.contains(title), "{tag} lists {title}:\n{html}");
+        }
+        assert!(
+            html.contains(&format!("count={}", expected.len())),
+            "{tag} knows its own size:\n{html}"
+        );
+    }
+    assert!(
+        !out.join("tags/.html").exists(),
+        "an untagged post creates no empty group"
+    );
+    assert!(
+        !page(&out, "tags/rust.html").contains("Post C"),
+        "a tag page lists only its own posts"
+    );
+}
+
+/// The index lists the groups themselves, not the pages.
+#[test]
+fn the_tag_index_lists_every_tag_with_counts() {
+    let root = tmpdir("tagindex");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_tagged_blog(&src, "");
+    let out = root.join("out");
+    build(&src, &out);
+
+    let index = page(&out, "tags/index.html");
+    assert!(index.contains("All tags"), "uses index_title:\n{index}");
+    assert!(index.contains("rust=2@../tags/rust.html"), "counts and links:\n{index}");
+    assert!(index.contains("emacs=1@"), "every tag appears:\n{index}");
+    assert!(index.contains("web=1@"), "every tag appears:\n{index}");
+    // Alphabetical, so the index reads predictably rather than in discovery order.
+    let pos = |t: &str| index.find(t).unwrap();
+    assert!(pos("emacs") < pos("rust") && pos("rust") < pos("web"), "sorted:\n{index}");
+}
+
+/// A tag page depends on its own posts. Adding a post tagged `rust` must not re-render
+/// the `emacs` page — invalidation that scales with tag count would undo the point.
+#[test]
+fn adding_a_tagged_post_rebuilds_only_the_affected_pages() {
+    let root = tmpdir("tagsinc");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_tagged_blog(&src, "");
+    let out = root.join("out");
+    build(&src, &out);
+    assert!(build(&src, &out).rendered.is_empty(), "unchanged rebuild renders nothing");
+
+    std::fs::write(
+        src.join("blog/e.org"),
+        "#+TITLE: Post E\n#+DATE: 2024-05-05\n#+FILETAGS: :rust:\n\nBody.\n",
+    )
+    .unwrap();
+    let report = build(&src, &out);
+
+    let mut rendered = report.rendered.clone();
+    rendered.sort();
+    assert_eq!(
+        rendered,
+        vec![
+            Utf8PathBuf::from("blog/e.html"),
+            Utf8PathBuf::from("tags/index.html"),
+            Utf8PathBuf::from("tags/rust.html"),
+        ],
+        "the post, its tag page, and the index whose counts changed — nothing else"
+    );
+    assert!(page(&out, "tags/rust.html").contains("Post E"));
+}
+
+/// A new tag has to produce a new page and reach the index.
+#[test]
+fn a_new_tag_creates_its_page_and_joins_the_index() {
+    let root = tmpdir("newtag");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_tagged_blog(&src, "");
+    let out = root.join("out");
+    build(&src, &out);
+    assert!(!out.join("tags/zig.html").exists());
+
+    std::fs::write(
+        src.join("blog/f.org"),
+        "#+TITLE: Post F\n#+DATE: 2024-06-06\n#+FILETAGS: :zig:\n\nBody.\n",
+    )
+    .unwrap();
+    build(&src, &out);
+
+    assert!(out.join("tags/zig.html").exists(), "the new tag gets a page");
+    assert!(
+        page(&out, "tags/index.html").contains("zig=1@"),
+        "and the index knows about it"
+    );
+}
+
+/// Grouping by any `#+KEYWORD:`, not just tags — same mechanism, single-valued.
+#[test]
+fn a_collection_can_group_by_any_keyword() {
+    let root = tmpdir("groupkw");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_tagged_blog(&src, "");
+    std::fs::write(
+        src.join("blog/a.org"),
+        "#+TITLE: Post A\n#+DATE: 2024-01-01\n#+CATEGORY: Notes\n\nBody.\n",
+    )
+    .unwrap();
+    std::fs::write(
+        src.join("org-ssg.toml"),
+        "[[collections]]\nsource = \"blog\"\ngroup_by = \"category\"\n\
+         output = \"cat/{tag}.html\"\ntemplate = \"tag.html\"\ntitle = \"{tag}\"\n",
+    )
+    .unwrap();
+    let out = root.join("out");
+    build(&src, &out);
+
+    assert!(out.join("cat/notes.html").exists(), "grouped by #+CATEGORY:");
+    assert!(page(&out, "cat/notes.html").contains("Post A"));
+}
+
+/// A grouped collection puts its *index* in the nav. A nav listing every tag is the same
+/// mistake as a nav listing every page.
+#[test]
+fn a_grouped_collection_contributes_its_index_to_the_nav() {
+    let root = tmpdir("tagnav");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_tagged_blog(&src, "nav = true\n");
+    let out = root.join("out");
+    build(&src, &out);
+
+    let nav = nav_of(&page(&out, "index.html"));
+    assert!(nav.contains("tags/index.html"), "the index is in the nav:\n{nav}");
+    assert!(!nav.contains("tags/rust.html"), "individual tags are not:\n{nav}");
+}
+
+/// An output path with no `{tag}` would have every group overwrite one file — a config
+/// that looks reasonable and silently produces one page instead of many.
+#[test]
+fn grouping_without_a_placeholder_is_rejected() {
+    let root = tmpdir("noplaceholder");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_tagged_blog(&src, "");
+    std::fs::write(
+        src.join("org-ssg.toml"),
+        "[[collections]]\nsource = \"blog\"\ngroup_by = \"tags\"\n\
+         output = \"tags/all.html\"\ntemplate = \"tag.html\"\n",
+    )
+    .unwrap();
+
+    let err = build_site(&src, &root.join("out"), &BuildOptions::default())
+        .expect_err("grouping without {tag} must fail");
+    assert!(format!("{err:#}").contains("{tag}"), "explains what is missing: {err:#}");
+}
+
+/// Two tags that differ only in punctuation slugify to the same path, and one page would
+/// silently overwrite the other.
+#[test]
+fn tags_that_collide_in_a_url_are_rejected() {
+    let root = tmpdir("tagcollide");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_tagged_blog(&src, "");
+    std::fs::write(
+        src.join("blog/a.org"),
+        "#+TITLE: Post A\n#+DATE: 2024-01-01\n#+FILETAGS: :web_dev:\n\nBody.\n",
+    )
+    .unwrap();
+    std::fs::write(
+        src.join("blog/b.org"),
+        "#+TITLE: Post B\n#+DATE: 2024-02-02\n#+FILETAGS: :web@dev:\n\nBody.\n",
+    )
+    .unwrap();
+
+    let err = build_site(&src, &root.join("out"), &BuildOptions::default())
+        .expect_err("colliding tag slugs must fail");
+    let message = format!("{err:#}");
+    assert!(message.contains("web_dev") && message.contains("web@dev"), "{message}");
+}