krz/orgo

Lightning fast org-mode static site generator.

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

f7cc7db7435951c4501d5499505374f79603dcfa

verified · cmc

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

v0.9: pagination for large listings

A collection with `paginate` set splits its entries across numbered pages. Page 1 stays
at `output`, so a section's canonical URL never moves as its page count changes; only
pages 2..N are named by `paginate_output`, which needs an {n} placeholder.

Templates get a `paginator`: current, total, per_page, total_entries, prev_url,
next_url, first_url, last_url, and a `pages` list of {number, url, current} for a
numbered strip. Every URL is computed relative to the page carrying it, because pages
1 and 2..N sit at different depths — page 1 links forward to "page/2.html" while page 5
links back to "../index.html" and sideways to "6.html", and the template does not have
to know where it is. An unpaginated collection has no paginator at all, so
{% if paginator %} is a reliable test in a template shared between both.

Pagination composes with grouping rather than special-casing it: each group paginates
independently, which is why `paginate_output` needs {tag} as well as {n} on a grouped
collection. Implementation is a post-processing step over the listing that was going to
be emitted anyway, so grouped, ungrouped and feed collections all inherit it.

Edge cases that would otherwise be quiet:
- An empty collection still emits page 1. A section that exists but has nothing in it
  should be a page saying so, not a 404.
- When the entry count shrinks, pages that no longer exist are deleted rather than left
  serving stale posts. The existing stale-output cleanup covers this because listings
  record themselves in the manifest by output path.
- `paginate` without `paginate_output`, `paginate_output` without {n}, grouped
  pagination without {tag}, and `paginate_output` with `paginate = 0` are all rejected
  with a message naming what is missing.

Verified on the 179-file corpus: 169 posts at 20/page produce 9 pages with 20/20/…/9
entries, correct prev/next across depths, an unchanged rebuild rendering zero of 203
pages, and all 182 incumbent URLs still reproduced.
 Cargo.lock      |   2 +-
 Cargo.toml      |   2 +-
 README.md       |  42 +++++++++-
 src/config.rs   |  41 ++++++++++
 src/site.rs     | 130 ++++++++++++++++++++++++++----
 src/template.rs |  46 +++++++++++
 tests/config.rs | 239 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 7 files changed, 483 insertions(+), 19 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 7d20934..d249110 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -569,7 +569,7 @@ dependencies = [
 
 [[package]]
 name = "org-ssg"
-version = "0.8.0"
+version = "0.9.0"
 dependencies = [
  "anyhow",
  "blake3",
diff --git a/Cargo.toml b/Cargo.toml
index a846e27..ece3df6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "org-ssg"
-version = "0.8.0"
+version = "0.9.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 eeb8e85..c2ea9d3 100644
--- a/README.md
+++ b/README.md
@@ -120,6 +120,45 @@ 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.
 
+#### Pagination
+
+Set `paginate` to split a long listing across numbered pages:
+
+```toml
+[[collections]]
+source = "blog"
+output = "blog/index.html"
+paginate = 10
+paginate_output = "blog/page/{n}.html"   # {n} is the 1-based page number
+```
+
+Page 1 stays at `output`, so a section's canonical URL never moves as its page count
+changes; only pages 2..N are named by `paginate_output`. The template gets a `paginator`:
+
+```jinja
+{% if paginator and paginator.total > 1 %}
+<nav>
+  {% if paginator.prev_url %}<a href="{{ paginator.prev_url }}">Newer</a>{% endif %}
+  {% for pg in paginator.pages %}
+    <a href="{{ pg.url }}"{% if pg.current %} aria-current="page"{% endif %}>{{ pg.number }}</a>
+  {% endfor %}
+  {% if paginator.next_url %}<a href="{{ paginator.next_url }}">Older</a>{% endif %}
+</nav>
+{% endif %}
+```
+
+`paginator` carries `current`, `total`, `per_page`, `total_entries`, `prev_url`,
+`next_url`, `first_url`, `last_url`, and `pages`. Every URL is relative to the page
+carrying it, so links work from page 1 (`page/2.html`) and from page 5 (`../index.html`,
+`6.html`) without the template knowing where it sits. An unpaginated collection has no
+`paginator` at all, so `{% if paginator %}` is a reliable test in a shared template.
+
+Grouping and pagination compose: each group paginates independently, which is why
+`paginate_output` needs `{tag}` as well as `{n}` on a grouped collection. An empty
+collection still emits page 1 — a section that exists but has nothing in it should say so
+rather than 404. When the entry count shrinks, pages that no longer exist are deleted
+instead of being left serving stale posts.
+
 #### Tag pages
 
 Add `group_by` and the collection emits one page *per group* instead of one page total,
@@ -238,6 +277,7 @@ all-of-org. Phase 0 checked this line against a real 179-file corpus and found i
 | **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** |
+| **11** | **Pagination: numbered pages with a `paginator` context, composing with grouping** | **done** |
 
 ### v0.2 in / out
 
@@ -501,7 +541,7 @@ PARSE/RESOLVE/RENDER), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserro
 
 ```
 cargo build
-cargo test                                                # 107 tests
+cargo test                                                # 115 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 03a8dae..e18d7a1 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -70,6 +70,15 @@ pub struct Collection {
     pub index_title: String,
     pub sort: SortKey,
     pub order: SortOrder,
+    /// Entries per page. `0` means no pagination — the whole collection on one page.
+    ///
+    /// Page 1 stays at `output`, so the canonical URL of a section never moves when the
+    /// number of pages changes. Pages 2 and up go to `paginate_output`.
+    pub paginate: usize,
+    /// Where pages 2..N are written. Must contain `{n}`, the 1-based page number, and
+    /// `{tag}` as well when the collection is grouped — otherwise page 2 of one group
+    /// would overwrite page 2 of another.
+    pub paginate_output: Utf8PathBuf,
     /// Add this listing page to the site navigation. This is how a section landing page
     /// — `/blog/`, `/notes/` — gets into a nav built from top-level pages.
     pub nav: bool,
@@ -88,6 +97,8 @@ impl Default for Collection {
             index_title: "Tags".to_string(),
             sort: SortKey::default(),
             order: SortOrder::default(),
+            paginate: 0,
+            paginate_output: Utf8PathBuf::new(),
             nav: false,
         }
     }
@@ -95,6 +106,8 @@ impl Default for Collection {
 
 /// The `{tag}` placeholder in a grouped collection's `output` and `title`.
 pub const GROUP_PLACEHOLDER: &str = "{tag}";
+/// The page-number placeholder in `paginate_output`.
+pub const PAGE_PLACEHOLDER: &str = "{n}";
 
 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "kebab-case")]
@@ -302,6 +315,32 @@ impl Config {
                     collection.index_output
                 );
             }
+            if collection.paginate > 0 {
+                let pattern = collection.paginate_output.as_str();
+                if pattern.is_empty() {
+                    anyhow::bail!(
+                        "collection output {} sets `paginate` but no `paginate_output`;                          pages 2 and up need somewhere to go, e.g. \"blog/page/{PAGE_PLACEHOLDER}.html\"",
+                        collection.output
+                    );
+                }
+                if !pattern.contains(PAGE_PLACEHOLDER) {
+                    anyhow::bail!(
+                        "collection `paginate_output` {pattern} has no {PAGE_PLACEHOLDER},                          so every page after the first would overwrite the same file"
+                    );
+                }
+                if grouped && !pattern.contains(GROUP_PLACEHOLDER) {
+                    anyhow::bail!(
+                        "collection `paginate_output` {pattern} groups by \"{}\" but has no                          {GROUP_PLACEHOLDER}, so page 2 of one group would overwrite page 2                          of another",
+                        collection.group_by
+                    );
+                }
+            }
+            if collection.paginate == 0 && !collection.paginate_output.as_str().is_empty() {
+                anyhow::bail!(
+                    "collection sets `paginate_output` {} but `paginate` is 0, so it would                      never be used; set `paginate` to a page size",
+                    collection.paginate_output
+                );
+            }
             for path in [&collection.output, &collection.index_output] {
                 if path.as_str().is_empty() || path.as_str().contains(GROUP_PLACEHOLDER) {
                     continue;
@@ -376,6 +415,8 @@ title = "Blog"
 sort = "date"              # date | title | path
 order = "desc"             # desc | asc
 nav = true                 # put this listing page in the site nav
+# paginate = 10            # entries per page; page 1 stays at `output`
+# paginate_output = "blog/page/{n}.html"   # where pages 2..N go; needs {n}
 
 # 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`.
diff --git a/src/site.rs b/src/site.rs
index d7938d4..c819848 100644
--- a/src/site.rs
+++ b/src/site.rs
@@ -28,7 +28,10 @@ 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::{GroupContext, NavItem, PageContext, RenderContext, SiteContext, Templater};
+use crate::template::{
+    GroupContext, NavItem, PageContext, Paginator, PaginatorPage, 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
@@ -117,6 +120,91 @@ struct Listing {
     /// Every group of the owning collection. The content of a group index, and context
     /// for a group page.
     groups: Vec<GroupContext>,
+    /// Set when this is one page of a paginated listing.
+    paginator: Option<Paginator>,
+}
+
+/// Split one listing's entries across numbered pages, appending each as its own
+/// [`Listing`].
+///
+/// Page 1 keeps `output`, so a section's canonical URL never moves as its page count
+/// changes — only pages 2..N are named by `paginate_output`. An empty listing still
+/// emits page 1, because a section that exists but has nothing in it should be a page
+/// saying so rather than a 404.
+fn push_paginated(
+    listings: &mut Vec<Listing>,
+    collection: &config::Collection,
+    output: Utf8PathBuf,
+    title: String,
+    entries: Vec<PageContext>,
+    group: Option<GroupContext>,
+    groups: Vec<GroupContext>,
+) {
+    let per_page = collection.paginate;
+    if per_page == 0 {
+        listings.push(Listing {
+            output,
+            template: collection.template.clone(),
+            title,
+            entries,
+            group,
+            groups,
+            paginator: None,
+        });
+        return;
+    }
+
+    let total_entries = entries.len();
+    let total = entries.len().div_ceil(per_page).max(1);
+    let slug = group.as_ref().map(|g| g.slug.clone()).unwrap_or_default();
+    let page_output = |n: usize| -> Utf8PathBuf {
+        if n == 1 {
+            return output.clone();
+        }
+        Utf8PathBuf::from(
+            collection
+                .paginate_output
+                .as_str()
+                .replace(config::GROUP_PLACEHOLDER, &slug)
+                .replace(config::PAGE_PLACEHOLDER, &n.to_string()),
+        )
+    };
+    let outputs: Vec<Utf8PathBuf> = (1..=total).map(page_output).collect();
+
+    for (idx, chunk) in entries.chunks(per_page).chain(
+        // `chunks` yields nothing for an empty slice; page 1 still has to exist.
+        std::iter::once(&[][..]).take(usize::from(total_entries == 0)),
+    ) .enumerate()
+    {
+        let current = idx + 1;
+        let here = &outputs[idx];
+        let url_to = |n: usize| output_url(here, &outputs[n - 1], None);
+        listings.push(Listing {
+            output: here.clone(),
+            template: collection.template.clone(),
+            title: title.clone(),
+            entries: chunk.to_vec(),
+            group: group.clone(),
+            groups: groups.clone(),
+            paginator: Some(Paginator {
+                current,
+                total,
+                per_page,
+                total_entries,
+                prev_url: (current > 1).then(|| url_to(current - 1)),
+                next_url: (current < total).then(|| url_to(current + 1)),
+                first_url: url_to(1),
+                last_url: url_to(total),
+                pages: (1..=total)
+                    .map(|n| PaginatorPage {
+                        number: n,
+                        url: url_to(n),
+                        current: n == current,
+                    })
+                    .collect(),
+            }),
+        });
+    }
 }
 
 /// The `YYYY-MM-DD` inside an org date, if there is one. Org dates arrive as
@@ -173,14 +261,15 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
         }
 
         if collection.group_by.is_empty() {
-            listings.push(Listing {
-                output: collection.output.clone(),
-                template: collection.template.clone(),
-                title: collection.title.clone(),
+            push_paginated(
+                &mut listings,
+                collection,
+                collection.output.clone(),
+                collection.title.clone(),
                 entries,
-                group: None,
-                groups: Vec::new(),
-            });
+                None,
+                Vec::new(),
+            );
             continue;
         }
 
@@ -237,21 +326,22 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
 
         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
+                push_paginated(
+                    &mut listings,
+                    collection,
+                    Utf8PathBuf::from(&group.url),
+                    collection
                         .title
                         .replace(config::GROUP_PLACEHOLDER, &group.name),
-                    entries: members.get(&group.name).cloned().unwrap_or_default(),
-                    group: Some(group.clone()),
+                    members.get(&group.name).cloned().unwrap_or_default(),
+                    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(),
-                });
+                    Vec::new(),
+                );
             }
         }
         if !collection.index_output.as_str().is_empty() {
@@ -262,6 +352,7 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
                 entries: Vec::new(),
                 group: None,
                 groups: groups.clone(),
+                paginator: None,
             });
         }
     }
@@ -335,6 +426,12 @@ fn listing_entries_hash(listing: &Listing) -> Hash {
                 .iter()
                 .map(|g| (g.url.clone(), format!("{}\u{0}{}", g.name, g.count))),
         )
+        .chain(listing.paginator.iter().map(|p| {
+            (
+                format!("{}/{}", p.current, p.total),
+                format!("{:?}|{:?}", p.prev_url, p.next_url),
+            )
+        }))
         .chain([(listing.title.clone(), listing.template.clone())])
         .collect();
     // Entry *order* is meaningful in a listing, so this hashes the sorted-by-us sequence
@@ -804,6 +901,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
             ctx.pages = Some(&listing.entries);
             ctx.group = listing.group.as_ref();
             ctx.groups = &listing.groups;
+            ctx.paginator = listing.paginator.as_ref();
             let html = templater
                 .render(&listing.template, &ctx)
                 .with_context(|| {
diff --git a/src/template.rs b/src/template.rs
index abb7d71..fe8b5c4 100644
--- a/src/template.rs
+++ b/src/template.rs
@@ -202,6 +202,7 @@ impl Templater {
             pages => ctx.pages,
             group => ctx.group,
             groups => ctx.groups,
+            paginator => ctx.paginator,
         })
         .map_err(|e| TemplateError::Render(render_error_detail(e)))
     }
@@ -226,6 +227,37 @@ pub struct GroupContext {
     pub count: usize,
 }
 
+/// One page of a paginated listing, exposed to templates as `paginator`.
+///
+/// Every URL here is relative to the page being rendered, so a template can emit them
+/// directly however deep the page sits.
+#[derive(Debug, Clone, Serialize)]
+pub struct Paginator {
+    /// 1-based number of this page.
+    pub current: usize,
+    /// How many pages the listing splits into.
+    pub total: usize,
+    /// Entries per page, as configured.
+    pub per_page: usize,
+    /// Entries across the whole listing, not just this page.
+    pub total_entries: usize,
+    pub prev_url: Option<String>,
+    pub next_url: Option<String>,
+    pub first_url: String,
+    pub last_url: String,
+    /// Every page, for a numbered strip.
+    pub pages: Vec<PaginatorPage>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct PaginatorPage {
+    pub number: usize,
+    pub url: String,
+    /// True for the page currently being rendered, so a template can mark it without
+    /// comparing numbers itself.
+    pub current: bool,
+}
+
 /// 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> {
@@ -246,6 +278,8 @@ pub struct RenderContext<'a> {
     /// 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],
+    /// Present only on a page of a paginated listing.
+    pub paginator: Option<&'a Paginator>,
 }
 
 impl<'a> RenderContext<'a> {
@@ -267,6 +301,7 @@ impl<'a> RenderContext<'a> {
             pages: None,
             group: None,
             groups: &[],
+            paginator: None,
         }
     }
 }
@@ -339,6 +374,17 @@ pub const STARTER_LIST_TEMPLATE: &str = r#"<!DOCTYPE html>
 </li>
 {%- endfor %}
 </ul>
+{%- if paginator and paginator.total > 1 %}
+<nav class="pagination">
+{%- if paginator.prev_url %}
+<a rel="prev" href="{{ paginator.prev_url }}">Newer</a>
+{%- endif %}
+<span>Page {{ paginator.current }} of {{ paginator.total }}</span>
+{%- if paginator.next_url %}
+<a rel="next" href="{{ paginator.next_url }}">Older</a>
+{%- endif %}
+</nav>
+{%- endif %}
 </main>
 </body>
 </html>
diff --git a/tests/config.rs b/tests/config.rs
index 17f3a37..d49f073 100644
--- a/tests/config.rs
+++ b/tests/config.rs
@@ -1058,3 +1058,242 @@ fn tags_that_collide_in_a_url_are_rejected() {
     let message = format!("{err:#}");
     assert!(message.contains("web_dev") && message.contains("web@dev"), "{message}");
 }
+
+// ---------------------------------------------------------------------------
+// Pagination
+// ---------------------------------------------------------------------------
+
+/// A blog of `count` dated posts with a paginating collection over them.
+fn write_paginated_blog(src: &Utf8PathBuf, count: usize, 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 i in 0..count {
+        std::fs::write(
+            src.join(format!("blog/p{i:02}.org")),
+            format!(
+                "#+TITLE: Post {i:02}\n#+DATE: 2024-01-{:02}\n\nBody.\n",
+                i + 1
+            ),
+        )
+        .unwrap();
+    }
+    std::fs::write(
+        src.join("templates/list.html"),
+        "<html><body><h1>{{ page.title }}</h1>\
+         <ul>{% for p in pages %}<li>{{ p.title }}</li>{% endfor %}</ul>\
+         {% if paginator %}<p>page {{ paginator.current }}/{{ paginator.total }} \
+         of {{ paginator.total_entries }}</p>\
+         {% if paginator.prev_url %}<a id=\"prev\" href=\"{{ paginator.prev_url }}\">p</a>{% endif %}\
+         {% if paginator.next_url %}<a id=\"next\" href=\"{{ paginator.next_url }}\">n</a>{% endif %}\
+         <nav>{% for pg in paginator.pages %}<a href=\"{{ pg.url }}\"{% if pg.current %} \
+         class=\"here\"{% endif %}>{{ pg.number }}</a>{% endfor %}</nav>{% endif %}</body></html>",
+    )
+    .unwrap();
+    std::fs::write(
+        src.join("org-ssg.toml"),
+        format!(
+            "[[collections]]\nsource = \"blog\"\noutput = \"blog/index.html\"\n\
+             template = \"list.html\"\ntitle = \"Blog\"\n{extra}"
+        ),
+    )
+    .unwrap();
+}
+
+/// Page 1 keeps the collection's `output`, so a section's canonical URL never moves as
+/// its page count changes.
+#[test]
+fn pagination_splits_entries_and_keeps_page_one_canonical() {
+    let root = tmpdir("paginate");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_paginated_blog(&src, 7, "paginate = 3\npaginate_output = \"blog/page/{n}.html\"\n");
+    let out = root.join("out");
+    build(&src, &out);
+
+    assert!(out.join("blog/index.html").exists(), "page 1 is the canonical URL");
+    for n in [2, 3] {
+        assert!(out.join(format!("blog/page/{n}.html")).exists(), "page {n} exists");
+    }
+    assert!(!out.join("blog/page/4.html").exists(), "7 entries at 3/page is 3 pages");
+    assert!(!out.join("blog/page/1.html").exists(), "page 1 is not duplicated");
+
+    // Newest first, so page 1 holds posts 06, 05, 04.
+    let first = page(&out, "blog/index.html");
+    assert!(first.contains("page 1/3 of 7"), "paginator counts:\n{first}");
+    assert!(first.contains("Post 06") && first.contains("Post 04"));
+    assert!(!first.contains("Post 03"), "page 1 holds only its own slice:\n{first}");
+
+    let last = page(&out, "blog/page/3.html");
+    assert!(last.contains("Post 00"), "the remainder lands on the last page:\n{last}");
+    assert_eq!(last.matches("<li>").count(), 1, "7 = 3 + 3 + 1");
+}
+
+/// Paginator URLs have to be relative to the page carrying them, and pages 2..N sit at a
+/// different depth than page 1.
+#[test]
+fn paginator_urls_resolve_from_each_pages_own_depth() {
+    let root = tmpdir("pageurls");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_paginated_blog(&src, 7, "paginate = 3\npaginate_output = \"blog/page/{n}.html\"\n");
+    let out = root.join("out");
+    build(&src, &out);
+
+    let first = page(&out, "blog/index.html");
+    assert!(first.contains("id=\"next\" href=\"page/2.html\""), "down a level:\n{first}");
+    assert!(!first.contains("id=\"prev\""), "page 1 has no previous");
+
+    let middle = page(&out, "blog/page/2.html");
+    assert!(middle.contains("id=\"prev\" href=\"../index.html\""), "back up:\n{middle}");
+    assert!(middle.contains("id=\"next\" href=\"3.html\""), "sideways:\n{middle}");
+
+    let last = page(&out, "blog/page/3.html");
+    assert!(!last.contains("id=\"next\""), "the last page has no next:\n{last}");
+}
+
+/// The numbered strip marks the page it is on, so a template does not compare numbers.
+#[test]
+fn the_paginator_exposes_a_numbered_page_list() {
+    let root = tmpdir("pagenums");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_paginated_blog(&src, 7, "paginate = 3\npaginate_output = \"blog/page/{n}.html\"\n");
+    let out = root.join("out");
+    build(&src, &out);
+
+    let second = page(&out, "blog/page/2.html");
+    assert!(second.contains(">1</a>") && second.contains(">3</a>"), "all pages listed");
+    assert!(
+        second.contains("class=\"here\">2</a>"),
+        "the current page is marked:\n{second}"
+    );
+}
+
+/// An unpaginated collection must not grow a paginator, so `{% if paginator %}` is a
+/// reliable test in a shared template.
+#[test]
+fn an_unpaginated_collection_has_no_paginator() {
+    let root = tmpdir("nopaginator");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_paginated_blog(&src, 4, "");
+    let out = root.join("out");
+    build(&src, &out);
+
+    let listing = page(&out, "blog/index.html");
+    assert!(!listing.contains("page 1/"), "no paginator block:\n{listing}");
+    assert_eq!(listing.matches("<li>").count(), 4, "everything on one page");
+}
+
+/// A section with nothing in it should be a page saying so, not a 404.
+#[test]
+fn an_empty_paginated_collection_still_emits_page_one() {
+    let root = tmpdir("pageempty");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_paginated_blog(&src, 0, "paginate = 3\npaginate_output = \"blog/page/{n}.html\"\n");
+    let out = root.join("out");
+    build(&src, &out);
+
+    let listing = page(&out, "blog/index.html");
+    assert!(listing.contains("page 1/1 of 0"), "one empty page:\n{listing}");
+    assert!(!out.join("blog/page/2.html").exists());
+}
+
+/// Grouped and paginated together: each group paginates independently, which is why
+/// `paginate_output` needs both placeholders.
+#[test]
+fn groups_paginate_independently() {
+    let root = tmpdir("pagegroups");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_paginated_blog(&src, 0, "");
+    for (name, tag, n) in [("a", "rust", 0), ("b", "rust", 1), ("c", "rust", 2), ("d", "web", 3)] {
+        std::fs::write(
+            src.join(format!("blog/{name}.org")),
+            format!("#+TITLE: Post {name}\n#+DATE: 2024-01-0{}\n#+FILETAGS: :{tag}:\n\nBody.\n", n + 1),
+        )
+        .unwrap();
+    }
+    std::fs::write(
+        src.join("org-ssg.toml"),
+        "[[collections]]\nsource = \"blog\"\ngroup_by = \"tags\"\n\
+         output = \"tags/{tag}.html\"\ntemplate = \"list.html\"\ntitle = \"{tag}\"\n\
+         paginate = 2\npaginate_output = \"tags/{tag}/page/{n}.html\"\n",
+    )
+    .unwrap();
+    let out = root.join("out");
+    build(&src, &out);
+
+    assert!(out.join("tags/rust.html").exists(), "3 rust posts, page 1");
+    assert!(out.join("tags/rust/page/2.html").exists(), "3 rust posts at 2/page needs page 2");
+    assert!(out.join("tags/web.html").exists(), "1 web post");
+    assert!(
+        !out.join("tags/web/page/2.html").exists(),
+        "one post needs no second page — groups paginate independently"
+    );
+}
+
+/// A `paginate_output` without `{n}` would have every page overwrite one file; without
+/// `{tag}` on a grouped collection, page 2 of one group would overwrite page 2 of
+/// another.
+#[test]
+fn pagination_placeholders_are_validated() {
+    let root = tmpdir("pagevalidate");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+
+    let cases = [
+        ("paginate = 3\n", "paginate_output"),
+        ("paginate = 3\npaginate_output = \"blog/more.html\"\n", "{n}"),
+        ("paginate_output = \"blog/page/{n}.html\"\n", "paginate"),
+    ];
+    for (extra, expect) in cases {
+        write_paginated_blog(&src, 4, extra);
+        let err = build_site(&src, &root.join("out"), &BuildOptions::default())
+            .expect_err("invalid pagination config must fail");
+        let message = format!("{err:#}");
+        assert!(message.contains(expect), "expected {expect:?} in: {message}");
+    }
+
+    // Grouped without {tag} in the page pattern.
+    std::fs::write(
+        src.join("org-ssg.toml"),
+        "[[collections]]\nsource = \"blog\"\ngroup_by = \"tags\"\n\
+         output = \"tags/{tag}.html\"\ntemplate = \"list.html\"\n\
+         paginate = 2\npaginate_output = \"tags/page/{n}.html\"\n",
+    )
+    .unwrap();
+    let err = build_site(&src, &root.join("out2"), &BuildOptions::default())
+        .expect_err("grouped pagination without {tag} must fail");
+    assert!(format!("{err:#}").contains("{tag}"), "{err:#}");
+}
+
+/// Adding a post shifts every entry across page boundaries, so all pages of that
+/// collection change — but nothing else does. And when the count shrinks, the pages that
+/// no longer exist have to be deleted rather than left serving stale content.
+#[test]
+fn page_count_changes_add_and_remove_page_files() {
+    let root = tmpdir("pageshrink");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_paginated_blog(&src, 7, "paginate = 3\npaginate_output = \"blog/page/{n}.html\"\n");
+    let out = root.join("out");
+    build(&src, &out);
+    assert!(build(&src, &out).rendered.is_empty(), "unchanged rebuild renders nothing");
+    assert!(out.join("blog/page/3.html").exists());
+
+    // Drop below two pages' worth.
+    for i in 2..7 {
+        std::fs::remove_file(src.join(format!("blog/p{i:02}.org"))).unwrap();
+    }
+    build(&src, &out);
+
+    assert!(
+        !out.join("blog/page/2.html").exists() && !out.join("blog/page/3.html").exists(),
+        "pages that no longer exist are deleted, not left serving stale posts"
+    );
+    let first = page(&out, "blog/index.html");
+    assert!(first.contains("page 1/1 of 2"), "the paginator reflects the new size:\n{first}");
+}