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
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(-) @@ -569,7 +569,7 @@ dependencies = [ [[package]] name = "org-ssg" -version = "0.8.0" +version = "0.9.0" dependencies = [ "anyhow", "blake3", @@ -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" @@ -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) @@ -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`. @@ -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(|| { @@ -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> @@ -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}"); +}