krz/orgo

Lightning fast org-mode static site generator. fast go org-mode static-site-generator

Commit f7cc7db743

f7cc7db7435951c4501d5499505374f79603dcfa

parent: ec5b3807fe

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-11 05:27 UTC

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.

Layout: unified · split

Cargo.lock +1 −1
@@ -569,7 +569,7 @@ dependencies = [
569569
570570[[package]]
571571name = "org-ssg"
572version = "0.8.0"
572version = "0.9.0"
573573dependencies = [
574574 "anyhow",
575575 "blake3",
Cargo.toml +1 −1
@@ -1,6 +1,6 @@
11[package]
22name = "org-ssg"
3version = "0.8.0"
3version = "0.9.0"
44edition = "2021"
55description = "Org-mode static site generator that renders the org element tree straight to HTML"
66license = "MIT"
README.md +41 −1
@@ -120,6 +120,45 @@ written in — `[2025-09-05 Fri 10:21:00]`, `<2024-05-01 Wed>` or bare `2024-05-
120120also the sort key; pages without a parseable date sort last, so an undated draft never
121121leads a dated archive.
122122
123#### Pagination
124
125Set `paginate` to split a long listing across numbered pages:
126
127```toml
128[[collections]]
129source = "blog"
130output = "blog/index.html"
131paginate = 10
132paginate_output = "blog/page/{n}.html" # {n} is the 1-based page number
133```
134
135Page 1 stays at `output`, so a section's canonical URL never moves as its page count
136changes; only pages 2..N are named by `paginate_output`. The template gets a `paginator`:
137
138```jinja
139{% if paginator and paginator.total > 1 %}
140<nav>
141 {% if paginator.prev_url %}<a href="{{ paginator.prev_url }}">Newer</a>{% endif %}
142 {% for pg in paginator.pages %}
143 <a href="{{ pg.url }}"{% if pg.current %} aria-current="page"{% endif %}>{{ pg.number }}</a>
144 {% endfor %}
145 {% if paginator.next_url %}<a href="{{ paginator.next_url }}">Older</a>{% endif %}
146</nav>
147{% endif %}
148```
149
150`paginator` carries `current`, `total`, `per_page`, `total_entries`, `prev_url`,
151`next_url`, `first_url`, `last_url`, and `pages`. Every URL is relative to the page
152carrying it, so links work from page 1 (`page/2.html`) and from page 5 (`../index.html`,
153`6.html`) without the template knowing where it sits. An unpaginated collection has no
154`paginator` at all, so `{% if paginator %}` is a reliable test in a shared template.
155
156Grouping and pagination compose: each group paginates independently, which is why
157`paginate_output` needs `{tag}` as well as `{n}` on a grouped collection. An empty
158collection still emits page 1 — a section that exists but has nothing in it should say so
159rather than 404. When the entry count shrinks, pages that no longer exist are deleted
160instead of being left serving stale posts.
161
123162#### Tag pages
124163
125164Add `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
238277| **8** | **General use: config file, user templates, nav modes, `init` scaffold, safe discovery** | **done** |
239278| **9** | **Generated listing pages: `[[collections]]`, sorted indexes, feeds via XML templates** | **done** |
240279| **10** | **Grouped collections: one page per tag plus a tag index — full parity with the incumbent** | **done** |
280| **11** | **Pagination: numbered pages with a `paginator` context, composing with grouping** | **done** |
241281
242282### v0.2 in / out
243283
@@ -501,7 +541,7 @@ PARSE/RESOLVE/RENDER), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserro
501541
502542```
503543cargo build
504cargo test # 107 tests
544cargo test # 115 tests
505545cargo run -- init my-site # scaffold a new site
506546cargo run -- build fixtures/minimal.org -o minimal.html # single file
507547cargo run -- build fixtures/site -o _site # whole site (incremental)
src/config.rs +41
@@ -70,6 +70,15 @@ pub struct Collection {
7070 pub index_title: String,
7171 pub sort: SortKey,
7272 pub order: SortOrder,
73 /// Entries per page. `0` means no pagination — the whole collection on one page.
74 ///
75 /// Page 1 stays at `output`, so the canonical URL of a section never moves when the
76 /// number of pages changes. Pages 2 and up go to `paginate_output`.
77 pub paginate: usize,
78 /// Where pages 2..N are written. Must contain `{n}`, the 1-based page number, and
79 /// `{tag}` as well when the collection is grouped — otherwise page 2 of one group
80 /// would overwrite page 2 of another.
81 pub paginate_output: Utf8PathBuf,
7382 /// Add this listing page to the site navigation. This is how a section landing page
7483 /// — `/blog/`, `/notes/` — gets into a nav built from top-level pages.
7584 pub nav: bool,
@@ -88,6 +97,8 @@ impl Default for Collection {
8897 index_title: "Tags".to_string(),
8998 sort: SortKey::default(),
9099 order: SortOrder::default(),
100 paginate: 0,
101 paginate_output: Utf8PathBuf::new(),
91102 nav: false,
92103 }
93104 }
@@ -95,6 +106,8 @@ impl Default for Collection {
95106
96107/// The `{tag}` placeholder in a grouped collection's `output` and `title`.
97108pub const GROUP_PLACEHOLDER: &str = "{tag}";
109/// The page-number placeholder in `paginate_output`.
110pub const PAGE_PLACEHOLDER: &str = "{n}";
98111
99112#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
100113#[serde(rename_all = "kebab-case")]
@@ -302,6 +315,32 @@ impl Config {
302315 collection.index_output
303316 );
304317 }
318 if collection.paginate > 0 {
319 let pattern = collection.paginate_output.as_str();
320 if pattern.is_empty() {
321 anyhow::bail!(
322 "collection output {} sets `paginate` but no `paginate_output`; pages 2 and up need somewhere to go, e.g. \"blog/page/{PAGE_PLACEHOLDER}.html\"",
323 collection.output
324 );
325 }
326 if !pattern.contains(PAGE_PLACEHOLDER) {
327 anyhow::bail!(
328 "collection `paginate_output` {pattern} has no {PAGE_PLACEHOLDER}, so every page after the first would overwrite the same file"
329 );
330 }
331 if grouped && !pattern.contains(GROUP_PLACEHOLDER) {
332 anyhow::bail!(
333 "collection `paginate_output` {pattern} groups by \"{}\" but has no {GROUP_PLACEHOLDER}, so page 2 of one group would overwrite page 2 of another",
334 collection.group_by
335 );
336 }
337 }
338 if collection.paginate == 0 && !collection.paginate_output.as_str().is_empty() {
339 anyhow::bail!(
340 "collection sets `paginate_output` {} but `paginate` is 0, so it would never be used; set `paginate` to a page size",
341 collection.paginate_output
342 );
343 }
305344 for path in [&collection.output, &collection.index_output] {
306345 if path.as_str().is_empty() || path.as_str().contains(GROUP_PLACEHOLDER) {
307346 continue;
@@ -376,6 +415,8 @@ title = "Blog"
376415sort = "date" # date | title | path
377416order = "desc" # desc | asc
378417nav = true # put this listing page in the site nav
418# paginate = 10 # entries per page; page 1 stays at `output`
419# paginate_output = "blog/page/{n}.html" # where pages 2..N go; needs {n}
379420
380421# One page per tag, plus an index of all tags. `{tag}` in `output`/`title` is replaced
381422# by each tag; the index gets `groups` instead of `pages`.
src/site.rs +114 −16
@@ -28,7 +28,10 @@ use crate::parser::parse;
2828use crate::render::{self, render_with, Html, RenderOptions, SyntectHighlighter};
2929use crate::resolve::resolve;
3030use crate::config::{self, Config, NavMode, SortKey, SortOrder};
31use crate::template::{GroupContext, NavItem, PageContext, RenderContext, SiteContext, Templater};
31use crate::template::{
32 GroupContext, NavItem, PageContext, Paginator, PaginatorPage, RenderContext, SiteContext,
33 Templater,
34};
3235use crate::util::{output_path, output_url, relative_root, slugify};
3336
3437/// A fully built page: source and output paths (relative to their roots) and its
@@ -117,6 +120,91 @@ struct Listing {
117120 /// Every group of the owning collection. The content of a group index, and context
118121 /// for a group page.
119122 groups: Vec<GroupContext>,
123 /// Set when this is one page of a paginated listing.
124 paginator: Option<Paginator>,
125}
126
127/// Split one listing's entries across numbered pages, appending each as its own
128/// [`Listing`].
129///
130/// Page 1 keeps `output`, so a section's canonical URL never moves as its page count
131/// changes — only pages 2..N are named by `paginate_output`. An empty listing still
132/// emits page 1, because a section that exists but has nothing in it should be a page
133/// saying so rather than a 404.
134fn push_paginated(
135 listings: &mut Vec<Listing>,
136 collection: &config::Collection,
137 output: Utf8PathBuf,
138 title: String,
139 entries: Vec<PageContext>,
140 group: Option<GroupContext>,
141 groups: Vec<GroupContext>,
142) {
143 let per_page = collection.paginate;
144 if per_page == 0 {
145 listings.push(Listing {
146 output,
147 template: collection.template.clone(),
148 title,
149 entries,
150 group,
151 groups,
152 paginator: None,
153 });
154 return;
155 }
156
157 let total_entries = entries.len();
158 let total = entries.len().div_ceil(per_page).max(1);
159 let slug = group.as_ref().map(|g| g.slug.clone()).unwrap_or_default();
160 let page_output = |n: usize| -> Utf8PathBuf {
161 if n == 1 {
162 return output.clone();
163 }
164 Utf8PathBuf::from(
165 collection
166 .paginate_output
167 .as_str()
168 .replace(config::GROUP_PLACEHOLDER, &slug)
169 .replace(config::PAGE_PLACEHOLDER, &n.to_string()),
170 )
171 };
172 let outputs: Vec<Utf8PathBuf> = (1..=total).map(page_output).collect();
173
174 for (idx, chunk) in entries.chunks(per_page).chain(
175 // `chunks` yields nothing for an empty slice; page 1 still has to exist.
176 std::iter::once(&[][..]).take(usize::from(total_entries == 0)),
177 ) .enumerate()
178 {
179 let current = idx + 1;
180 let here = &outputs[idx];
181 let url_to = |n: usize| output_url(here, &outputs[n - 1], None);
182 listings.push(Listing {
183 output: here.clone(),
184 template: collection.template.clone(),
185 title: title.clone(),
186 entries: chunk.to_vec(),
187 group: group.clone(),
188 groups: groups.clone(),
189 paginator: Some(Paginator {
190 current,
191 total,
192 per_page,
193 total_entries,
194 prev_url: (current > 1).then(|| url_to(current - 1)),
195 next_url: (current < total).then(|| url_to(current + 1)),
196 first_url: url_to(1),
197 last_url: url_to(total),
198 pages: (1..=total)
199 .map(|n| PaginatorPage {
200 number: n,
201 url: url_to(n),
202 current: n == current,
203 })
204 .collect(),
205 }),
206 });
207 }
120208}
121209
122210/// 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>> {
173261 }
174262
175263 if collection.group_by.is_empty() {
176 listings.push(Listing {
177 output: collection.output.clone(),
178 template: collection.template.clone(),
179 title: collection.title.clone(),
264 push_paginated(
265 &mut listings,
266 collection,
267 collection.output.clone(),
268 collection.title.clone(),
180269 entries,
181 group: None,
182 groups: Vec::new(),
183 });
270 None,
271 Vec::new(),
272 );
184273 continue;
185274 }
186275
@@ -237,21 +326,22 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
237326
238327 if !collection.output.as_str().is_empty() {
239328 for group in &groups {
240 listings.push(Listing {
241 output: Utf8PathBuf::from(&group.url),
242 template: collection.template.clone(),
243 title: collection
329 push_paginated(
330 &mut listings,
331 collection,
332 Utf8PathBuf::from(&group.url),
333 collection
244334 .title
245335 .replace(config::GROUP_PLACEHOLDER, &group.name),
246 entries: members.get(&group.name).cloned().unwrap_or_default(),
247 group: Some(group.clone()),
336 members.get(&group.name).cloned().unwrap_or_default(),
337 Some(group.clone()),
248338 // Deliberately not the whole group list. A page that can see every
249339 // group depends on every group, so one new post would re-render every
250340 // tag page — cost that scales with tag count, to support a tag cloud
251341 // nobody has asked for. A tag page depends on its own posts, and the
252342 // group index is where the group list belongs.
253 groups: Vec::new(),
254 });
343 Vec::new(),
344 );
255345 }
256346 }
257347 if !collection.index_output.as_str().is_empty() {
@@ -262,6 +352,7 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
262352 entries: Vec::new(),
263353 group: None,
264354 groups: groups.clone(),
355 paginator: None,
265356 });
266357 }
267358 }
@@ -335,6 +426,12 @@ fn listing_entries_hash(listing: &Listing) -> Hash {
335426 .iter()
336427 .map(|g| (g.url.clone(), format!("{}\u{0}{}", g.name, g.count))),
337428 )
429 .chain(listing.paginator.iter().map(|p| {
430 (
431 format!("{}/{}", p.current, p.total),
432 format!("{:?}|{:?}", p.prev_url, p.next_url),
433 )
434 }))
338435 .chain([(listing.title.clone(), listing.template.clone())])
339436 .collect();
340437 // 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
804901 ctx.pages = Some(&listing.entries);
805902 ctx.group = listing.group.as_ref();
806903 ctx.groups = &listing.groups;
904 ctx.paginator = listing.paginator.as_ref();
807905 let html = templater
808906 .render(&listing.template, &ctx)
809907 .with_context(|| {
src/template.rs +46
@@ -202,6 +202,7 @@ impl Templater {
202202 pages => ctx.pages,
203203 group => ctx.group,
204204 groups => ctx.groups,
205 paginator => ctx.paginator,
205206 })
206207 .map_err(|e| TemplateError::Render(render_error_detail(e)))
207208 }
@@ -226,6 +227,37 @@ pub struct GroupContext {
226227 pub count: usize,
227228}
228229
230/// One page of a paginated listing, exposed to templates as `paginator`.
231///
232/// Every URL here is relative to the page being rendered, so a template can emit them
233/// directly however deep the page sits.
234#[derive(Debug, Clone, Serialize)]
235pub struct Paginator {
236 /// 1-based number of this page.
237 pub current: usize,
238 /// How many pages the listing splits into.
239 pub total: usize,
240 /// Entries per page, as configured.
241 pub per_page: usize,
242 /// Entries across the whole listing, not just this page.
243 pub total_entries: usize,
244 pub prev_url: Option<String>,
245 pub next_url: Option<String>,
246 pub first_url: String,
247 pub last_url: String,
248 /// Every page, for a numbered strip.
249 pub pages: Vec<PaginatorPage>,
250}
251
252#[derive(Debug, Clone, Serialize)]
253pub struct PaginatorPage {
254 pub number: usize,
255 pub url: String,
256 /// True for the page currently being rendered, so a template can mark it without
257 /// comparing numbers itself.
258 pub current: bool,
259}
260
229261/// Everything a template can see. A struct rather than a dozen positional arguments,
230262/// because the list grows every time templates learn something new.
231263pub struct RenderContext<'a> {
@@ -246,6 +278,8 @@ pub struct RenderContext<'a> {
246278 /// Every group of a grouped collection — the group index's content. Empty on a
247279 /// per-group page, which depends on its own entries and not on the other groups.
248280 pub groups: &'a [GroupContext],
281 /// Present only on a page of a paginated listing.
282 pub paginator: Option<&'a Paginator>,
249283}
250284
251285impl<'a> RenderContext<'a> {
@@ -267,6 +301,7 @@ impl<'a> RenderContext<'a> {
267301 pages: None,
268302 group: None,
269303 groups: &[],
304 paginator: None,
270305 }
271306 }
272307}
@@ -339,6 +374,17 @@ pub const STARTER_LIST_TEMPLATE: &str = r#"<!DOCTYPE html>
339374</li>
340375{%- endfor %}
341376</ul>
377{%- if paginator and paginator.total > 1 %}
378<nav class="pagination">
379{%- if paginator.prev_url %}
380<a rel="prev" href="{{ paginator.prev_url }}">Newer</a>
381{%- endif %}
382<span>Page {{ paginator.current }} of {{ paginator.total }}</span>
383{%- if paginator.next_url %}
384<a rel="next" href="{{ paginator.next_url }}">Older</a>
385{%- endif %}
386</nav>
387{%- endif %}
342388</main>
343389</body>
344390</html>
tests/config.rs +239
@@ -1058,3 +1058,242 @@ fn tags_that_collide_in_a_url_are_rejected() {
10581058 let message = format!("{err:#}");
10591059 assert!(message.contains("web_dev") && message.contains("web@dev"), "{message}");
10601060}
1061
1062// ---------------------------------------------------------------------------
1063// Pagination
1064// ---------------------------------------------------------------------------
1065
1066/// A blog of `count` dated posts with a paginating collection over them.
1067fn write_paginated_blog(src: &Utf8PathBuf, count: usize, extra: &str) {
1068 std::fs::create_dir_all(src.join("blog")).unwrap();
1069 std::fs::create_dir_all(src.join("templates")).unwrap();
1070 std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nWelcome.\n").unwrap();
1071 for i in 0..count {
1072 std::fs::write(
1073 src.join(format!("blog/p{i:02}.org")),
1074 format!(
1075 "#+TITLE: Post {i:02}\n#+DATE: 2024-01-{:02}\n\nBody.\n",
1076 i + 1
1077 ),
1078 )
1079 .unwrap();
1080 }
1081 std::fs::write(
1082 src.join("templates/list.html"),
1083 "<html><body><h1>{{ page.title }}</h1>\
1084 <ul>{% for p in pages %}<li>{{ p.title }}</li>{% endfor %}</ul>\
1085 {% if paginator %}<p>page {{ paginator.current }}/{{ paginator.total }} \
1086 of {{ paginator.total_entries }}</p>\
1087 {% if paginator.prev_url %}<a id=\"prev\" href=\"{{ paginator.prev_url }}\">p</a>{% endif %}\
1088 {% if paginator.next_url %}<a id=\"next\" href=\"{{ paginator.next_url }}\">n</a>{% endif %}\
1089 <nav>{% for pg in paginator.pages %}<a href=\"{{ pg.url }}\"{% if pg.current %} \
1090 class=\"here\"{% endif %}>{{ pg.number }}</a>{% endfor %}</nav>{% endif %}</body></html>",
1091 )
1092 .unwrap();
1093 std::fs::write(
1094 src.join("org-ssg.toml"),
1095 format!(
1096 "[[collections]]\nsource = \"blog\"\noutput = \"blog/index.html\"\n\
1097 template = \"list.html\"\ntitle = \"Blog\"\n{extra}"
1098 ),
1099 )
1100 .unwrap();
1101}
1102
1103/// Page 1 keeps the collection's `output`, so a section's canonical URL never moves as
1104/// its page count changes.
1105#[test]
1106fn pagination_splits_entries_and_keeps_page_one_canonical() {
1107 let root = tmpdir("paginate");
1108 let src = root.join("src");
1109 std::fs::create_dir_all(&src).unwrap();
1110 write_paginated_blog(&src, 7, "paginate = 3\npaginate_output = \"blog/page/{n}.html\"\n");
1111 let out = root.join("out");
1112 build(&src, &out);
1113
1114 assert!(out.join("blog/index.html").exists(), "page 1 is the canonical URL");
1115 for n in [2, 3] {
1116 assert!(out.join(format!("blog/page/{n}.html")).exists(), "page {n} exists");
1117 }
1118 assert!(!out.join("blog/page/4.html").exists(), "7 entries at 3/page is 3 pages");
1119 assert!(!out.join("blog/page/1.html").exists(), "page 1 is not duplicated");
1120
1121 // Newest first, so page 1 holds posts 06, 05, 04.
1122 let first = page(&out, "blog/index.html");
1123 assert!(first.contains("page 1/3 of 7"), "paginator counts:\n{first}");
1124 assert!(first.contains("Post 06") && first.contains("Post 04"));
1125 assert!(!first.contains("Post 03"), "page 1 holds only its own slice:\n{first}");
1126
1127 let last = page(&out, "blog/page/3.html");
1128 assert!(last.contains("Post 00"), "the remainder lands on the last page:\n{last}");
1129 assert_eq!(last.matches("<li>").count(), 1, "7 = 3 + 3 + 1");
1130}
1131
1132/// Paginator URLs have to be relative to the page carrying them, and pages 2..N sit at a
1133/// different depth than page 1.
1134#[test]
1135fn paginator_urls_resolve_from_each_pages_own_depth() {
1136 let root = tmpdir("pageurls");
1137 let src = root.join("src");
1138 std::fs::create_dir_all(&src).unwrap();
1139 write_paginated_blog(&src, 7, "paginate = 3\npaginate_output = \"blog/page/{n}.html\"\n");
1140 let out = root.join("out");
1141 build(&src, &out);
1142
1143 let first = page(&out, "blog/index.html");
1144 assert!(first.contains("id=\"next\" href=\"page/2.html\""), "down a level:\n{first}");
1145 assert!(!first.contains("id=\"prev\""), "page 1 has no previous");
1146
1147 let middle = page(&out, "blog/page/2.html");
1148 assert!(middle.contains("id=\"prev\" href=\"../index.html\""), "back up:\n{middle}");
1149 assert!(middle.contains("id=\"next\" href=\"3.html\""), "sideways:\n{middle}");
1150
1151 let last = page(&out, "blog/page/3.html");
1152 assert!(!last.contains("id=\"next\""), "the last page has no next:\n{last}");
1153}
1154
1155/// The numbered strip marks the page it is on, so a template does not compare numbers.
1156#[test]
1157fn the_paginator_exposes_a_numbered_page_list() {
1158 let root = tmpdir("pagenums");
1159 let src = root.join("src");
1160 std::fs::create_dir_all(&src).unwrap();
1161 write_paginated_blog(&src, 7, "paginate = 3\npaginate_output = \"blog/page/{n}.html\"\n");
1162 let out = root.join("out");
1163 build(&src, &out);
1164
1165 let second = page(&out, "blog/page/2.html");
1166 assert!(second.contains(">1</a>") && second.contains(">3</a>"), "all pages listed");
1167 assert!(
1168 second.contains("class=\"here\">2</a>"),
1169 "the current page is marked:\n{second}"
1170 );
1171}
1172
1173/// An unpaginated collection must not grow a paginator, so `{% if paginator %}` is a
1174/// reliable test in a shared template.
1175#[test]
1176fn an_unpaginated_collection_has_no_paginator() {
1177 let root = tmpdir("nopaginator");
1178 let src = root.join("src");
1179 std::fs::create_dir_all(&src).unwrap();
1180 write_paginated_blog(&src, 4, "");
1181 let out = root.join("out");
1182 build(&src, &out);
1183
1184 let listing = page(&out, "blog/index.html");
1185 assert!(!listing.contains("page 1/"), "no paginator block:\n{listing}");
1186 assert_eq!(listing.matches("<li>").count(), 4, "everything on one page");
1187}
1188
1189/// A section with nothing in it should be a page saying so, not a 404.
1190#[test]
1191fn an_empty_paginated_collection_still_emits_page_one() {
1192 let root = tmpdir("pageempty");
1193 let src = root.join("src");
1194 std::fs::create_dir_all(&src).unwrap();
1195 write_paginated_blog(&src, 0, "paginate = 3\npaginate_output = \"blog/page/{n}.html\"\n");
1196 let out = root.join("out");
1197 build(&src, &out);
1198
1199 let listing = page(&out, "blog/index.html");
1200 assert!(listing.contains("page 1/1 of 0"), "one empty page:\n{listing}");
1201 assert!(!out.join("blog/page/2.html").exists());
1202}
1203
1204/// Grouped and paginated together: each group paginates independently, which is why
1205/// `paginate_output` needs both placeholders.
1206#[test]
1207fn groups_paginate_independently() {
1208 let root = tmpdir("pagegroups");
1209 let src = root.join("src");
1210 std::fs::create_dir_all(&src).unwrap();
1211 write_paginated_blog(&src, 0, "");
1212 for (name, tag, n) in [("a", "rust", 0), ("b", "rust", 1), ("c", "rust", 2), ("d", "web", 3)] {
1213 std::fs::write(
1214 src.join(format!("blog/{name}.org")),
1215 format!("#+TITLE: Post {name}\n#+DATE: 2024-01-0{}\n#+FILETAGS: :{tag}:\n\nBody.\n", n + 1),
1216 )
1217 .unwrap();
1218 }
1219 std::fs::write(
1220 src.join("org-ssg.toml"),
1221 "[[collections]]\nsource = \"blog\"\ngroup_by = \"tags\"\n\
1222 output = \"tags/{tag}.html\"\ntemplate = \"list.html\"\ntitle = \"{tag}\"\n\
1223 paginate = 2\npaginate_output = \"tags/{tag}/page/{n}.html\"\n",
1224 )
1225 .unwrap();
1226 let out = root.join("out");
1227 build(&src, &out);
1228
1229 assert!(out.join("tags/rust.html").exists(), "3 rust posts, page 1");
1230 assert!(out.join("tags/rust/page/2.html").exists(), "3 rust posts at 2/page needs page 2");
1231 assert!(out.join("tags/web.html").exists(), "1 web post");
1232 assert!(
1233 !out.join("tags/web/page/2.html").exists(),
1234 "one post needs no second page — groups paginate independently"
1235 );
1236}
1237
1238/// A `paginate_output` without `{n}` would have every page overwrite one file; without
1239/// `{tag}` on a grouped collection, page 2 of one group would overwrite page 2 of
1240/// another.
1241#[test]
1242fn pagination_placeholders_are_validated() {
1243 let root = tmpdir("pagevalidate");
1244 let src = root.join("src");
1245 std::fs::create_dir_all(&src).unwrap();
1246
1247 let cases = [
1248 ("paginate = 3\n", "paginate_output"),
1249 ("paginate = 3\npaginate_output = \"blog/more.html\"\n", "{n}"),
1250 ("paginate_output = \"blog/page/{n}.html\"\n", "paginate"),
1251 ];
1252 for (extra, expect) in cases {
1253 write_paginated_blog(&src, 4, extra);
1254 let err = build_site(&src, &root.join("out"), &BuildOptions::default())
1255 .expect_err("invalid pagination config must fail");
1256 let message = format!("{err:#}");
1257 assert!(message.contains(expect), "expected {expect:?} in: {message}");
1258 }
1259
1260 // Grouped without {tag} in the page pattern.
1261 std::fs::write(
1262 src.join("org-ssg.toml"),
1263 "[[collections]]\nsource = \"blog\"\ngroup_by = \"tags\"\n\
1264 output = \"tags/{tag}.html\"\ntemplate = \"list.html\"\n\
1265 paginate = 2\npaginate_output = \"tags/page/{n}.html\"\n",
1266 )
1267 .unwrap();
1268 let err = build_site(&src, &root.join("out2"), &BuildOptions::default())
1269 .expect_err("grouped pagination without {tag} must fail");
1270 assert!(format!("{err:#}").contains("{tag}"), "{err:#}");
1271}
1272
1273/// Adding a post shifts every entry across page boundaries, so all pages of that
1274/// collection change — but nothing else does. And when the count shrinks, the pages that
1275/// no longer exist have to be deleted rather than left serving stale content.
1276#[test]
1277fn page_count_changes_add_and_remove_page_files() {
1278 let root = tmpdir("pageshrink");
1279 let src = root.join("src");
1280 std::fs::create_dir_all(&src).unwrap();
1281 write_paginated_blog(&src, 7, "paginate = 3\npaginate_output = \"blog/page/{n}.html\"\n");
1282 let out = root.join("out");
1283 build(&src, &out);
1284 assert!(build(&src, &out).rendered.is_empty(), "unchanged rebuild renders nothing");
1285 assert!(out.join("blog/page/3.html").exists());
1286
1287 // Drop below two pages' worth.
1288 for i in 2..7 {
1289 std::fs::remove_file(src.join(format!("blog/p{i:02}.org"))).unwrap();
1290 }
1291 build(&src, &out);
1292
1293 assert!(
1294 !out.join("blog/page/2.html").exists() && !out.join("blog/page/3.html").exists(),
1295 "pages that no longer exist are deleted, not left serving stale posts"
1296 );
1297 let first = page(&out, "blog/index.html");
1298 assert!(first.contains("page 1/1 of 2"), "the paginator reflects the new size:\n{first}");
1299}