Commit f7cc7db743
Verified · cmc
Layout: unified · split
Cargo.lock +1 −1
| @@ -569,7 +569,7 @@ dependencies = [ | |||
| 569 | 569 | ||
| 570 | [[package]] | 570 | [[package]] |
| 571 | name = "org-ssg" | 571 | name = "org-ssg" |
| 572 | version = "0.8.0" | 572 | version = "0.9.0" |
| 573 | dependencies = [ | 573 | dependencies = [ |
| 574 | "anyhow", | 574 | "anyhow", |
| 575 | "blake3", | 575 | "blake3", |
Cargo.toml +1 −1
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "org-ssg" | 2 | name = "org-ssg" |
| 3 | version = "0.8.0" | 3 | version = "0.9.0" |
| 4 | edition = "2021" | 4 | edition = "2021" |
| 5 | description = "Org-mode static site generator that renders the org element tree straight to HTML" | 5 | description = "Org-mode static site generator that renders the org element tree straight to HTML" |
| 6 | license = "MIT" | 6 | license = "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- | |||
| 120 | also the sort key; pages without a parseable date sort last, so an undated draft never | 120 | also the sort key; pages without a parseable date sort last, so an undated draft never |
| 121 | leads a dated archive. | 121 | leads a dated archive. |
| 122 | 122 | ||
| 123 | #### Pagination | ||
| 124 | |||
| 125 | Set `paginate` to split a long listing across numbered pages: | ||
| 126 | |||
| 127 | ```toml | ||
| 128 | [[collections]] | ||
| 129 | source = "blog" | ||
| 130 | output = "blog/index.html" | ||
| 131 | paginate = 10 | ||
| 132 | paginate_output = "blog/page/{n}.html" # {n} is the 1-based page number | ||
| 133 | ``` | ||
| 134 | |||
| 135 | Page 1 stays at `output`, so a section's canonical URL never moves as its page count | ||
| 136 | changes; 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 | ||
| 152 | carrying 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 | |||
| 156 | Grouping 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 | ||
| 158 | collection still emits page 1 — a section that exists but has nothing in it should say so | ||
| 159 | rather than 404. When the entry count shrinks, pages that no longer exist are deleted | ||
| 160 | instead of being left serving stale posts. | ||
| 161 | |||
| 123 | #### Tag pages | 162 | #### Tag pages |
| 124 | 163 | ||
| 125 | Add `group_by` and the collection emits one page *per group* instead of one page total, | 164 | 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 | |||
| 238 | | **8** | **General use: config file, user templates, nav modes, `init` scaffold, safe discovery** | **done** | | 277 | | **8** | **General use: config file, user templates, nav modes, `init` scaffold, safe discovery** | **done** | |
| 239 | | **9** | **Generated listing pages: `[[collections]]`, sorted indexes, feeds via XML templates** | **done** | | 278 | | **9** | **Generated listing pages: `[[collections]]`, sorted indexes, feeds via XML templates** | **done** | |
| 240 | | **10** | **Grouped collections: one page per tag plus a tag index — full parity with the incumbent** | **done** | | 279 | | **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** | | ||
| 241 | 281 | ||
| 242 | ### v0.2 in / out | 282 | ### v0.2 in / out |
| 243 | 283 | ||
| @@ -501,7 +541,7 @@ PARSE/RESOLVE/RENDER), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserro | |||
| 501 | 541 | ||
| 502 | ``` | 542 | ``` |
| 503 | cargo build | 543 | cargo build |
| 504 | cargo test # 107 tests | 544 | cargo test # 115 tests |
| 505 | cargo run -- init my-site # scaffold a new site | 545 | cargo run -- init my-site # scaffold a new site |
| 506 | cargo run -- build fixtures/minimal.org -o minimal.html # single file | 546 | cargo run -- build fixtures/minimal.org -o minimal.html # single file |
| 507 | cargo run -- build fixtures/site -o _site # whole site (incremental) | 547 | cargo run -- build fixtures/site -o _site # whole site (incremental) |
src/config.rs +41
| @@ -70,6 +70,15 @@ pub struct Collection { | |||
| 70 | pub index_title: String, | 70 | pub index_title: String, |
| 71 | pub sort: SortKey, | 71 | pub sort: SortKey, |
| 72 | pub order: SortOrder, | 72 | 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, | ||
| 73 | /// Add this listing page to the site navigation. This is how a section landing page | 82 | /// Add this listing page to the site navigation. This is how a section landing page |
| 74 | /// — `/blog/`, `/notes/` — gets into a nav built from top-level pages. | 83 | /// — `/blog/`, `/notes/` — gets into a nav built from top-level pages. |
| 75 | pub nav: bool, | 84 | pub nav: bool, |
| @@ -88,6 +97,8 @@ impl Default for Collection { | |||
| 88 | index_title: "Tags".to_string(), | 97 | index_title: "Tags".to_string(), |
| 89 | sort: SortKey::default(), | 98 | sort: SortKey::default(), |
| 90 | order: SortOrder::default(), | 99 | order: SortOrder::default(), |
| 100 | paginate: 0, | ||
| 101 | paginate_output: Utf8PathBuf::new(), | ||
| 91 | nav: false, | 102 | nav: false, |
| 92 | } | 103 | } |
| 93 | } | 104 | } |
| @@ -95,6 +106,8 @@ impl Default for Collection { | |||
| 95 | 106 | ||
| 96 | /// The `{tag}` placeholder in a grouped collection's `output` and `title`. | 107 | /// The `{tag}` placeholder in a grouped collection's `output` and `title`. |
| 97 | pub const GROUP_PLACEHOLDER: &str = "{tag}"; | 108 | pub const GROUP_PLACEHOLDER: &str = "{tag}"; |
| 109 | /// The page-number placeholder in `paginate_output`. | ||
| 110 | pub const PAGE_PLACEHOLDER: &str = "{n}"; | ||
| 98 | 111 | ||
| 99 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] | 112 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 100 | #[serde(rename_all = "kebab-case")] | 113 | #[serde(rename_all = "kebab-case")] |
| @@ -302,6 +315,32 @@ impl Config { | |||
| 302 | collection.index_output | 315 | collection.index_output |
| 303 | ); | 316 | ); |
| 304 | } | 317 | } |
| 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 | } | ||
| 305 | for path in [&collection.output, &collection.index_output] { | 344 | for path in [&collection.output, &collection.index_output] { |
| 306 | if path.as_str().is_empty() || path.as_str().contains(GROUP_PLACEHOLDER) { | 345 | if path.as_str().is_empty() || path.as_str().contains(GROUP_PLACEHOLDER) { |
| 307 | continue; | 346 | continue; |
| @@ -376,6 +415,8 @@ title = "Blog" | |||
| 376 | sort = "date" # date | title | path | 415 | sort = "date" # date | title | path |
| 377 | order = "desc" # desc | asc | 416 | order = "desc" # desc | asc |
| 378 | nav = true # put this listing page in the site nav | 417 | nav = 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} | ||
| 379 | 420 | ||
| 380 | # One page per tag, plus an index of all tags. `{tag}` in `output`/`title` is replaced | 421 | # One page per tag, plus an index of all tags. `{tag}` in `output`/`title` is replaced |
| 381 | # by each tag; the index gets `groups` instead of `pages`. | 422 | # by each tag; the index gets `groups` instead of `pages`. |
src/site.rs +114 −16
| @@ -28,7 +28,10 @@ use crate::parser::parse; | |||
| 28 | use crate::render::{self, render_with, Html, RenderOptions, SyntectHighlighter}; | 28 | use crate::render::{self, render_with, Html, RenderOptions, SyntectHighlighter}; |
| 29 | use crate::resolve::resolve; | 29 | use crate::resolve::resolve; |
| 30 | use crate::config::{self, Config, NavMode, SortKey, SortOrder}; | 30 | use crate::config::{self, Config, NavMode, SortKey, SortOrder}; |
| 31 | use crate::template::{GroupContext, NavItem, PageContext, RenderContext, SiteContext, Templater}; | 31 | use crate::template::{ |
| 32 | GroupContext, NavItem, PageContext, Paginator, PaginatorPage, RenderContext, SiteContext, | ||
| 33 | Templater, | ||
| 34 | }; | ||
| 32 | use crate::util::{output_path, output_url, relative_root, slugify}; | 35 | use crate::util::{output_path, output_url, relative_root, slugify}; |
| 33 | 36 | ||
| 34 | /// A fully built page: source and output paths (relative to their roots) and its | 37 | /// A fully built page: source and output paths (relative to their roots) and its |
| @@ -117,6 +120,91 @@ struct Listing { | |||
| 117 | /// Every group of the owning collection. The content of a group index, and context | 120 | /// Every group of the owning collection. The content of a group index, and context |
| 118 | /// for a group page. | 121 | /// for a group page. |
| 119 | groups: Vec<GroupContext>, | 122 | 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. | ||
| 134 | fn 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 | } | ||
| 120 | } | 208 | } |
| 121 | 209 | ||
| 122 | /// The `YYYY-MM-DD` inside an org date, if there is one. Org dates arrive as | 210 | /// 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>> { | |||
| 173 | } | 261 | } |
| 174 | 262 | ||
| 175 | if collection.group_by.is_empty() { | 263 | if collection.group_by.is_empty() { |
| 176 | listings.push(Listing { | 264 | push_paginated( |
| 177 | output: collection.output.clone(), | 265 | &mut listings, |
| 178 | template: collection.template.clone(), | 266 | collection, |
| 179 | title: collection.title.clone(), | 267 | collection.output.clone(), |
| 268 | collection.title.clone(), | ||
| 180 | entries, | 269 | entries, |
| 181 | group: None, | 270 | None, |
| 182 | groups: Vec::new(), | 271 | Vec::new(), |
| 183 | }); | 272 | ); |
| 184 | continue; | 273 | continue; |
| 185 | } | 274 | } |
| 186 | 275 | ||
| @@ -237,21 +326,22 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> { | |||
| 237 | 326 | ||
| 238 | if !collection.output.as_str().is_empty() { | 327 | if !collection.output.as_str().is_empty() { |
| 239 | for group in &groups { | 328 | for group in &groups { |
| 240 | listings.push(Listing { | 329 | push_paginated( |
| 241 | output: Utf8PathBuf::from(&group.url), | 330 | &mut listings, |
| 242 | template: collection.template.clone(), | 331 | collection, |
| 243 | title: collection | 332 | Utf8PathBuf::from(&group.url), |
| 333 | collection | ||
| 244 | .title | 334 | .title |
| 245 | .replace(config::GROUP_PLACEHOLDER, &group.name), | 335 | .replace(config::GROUP_PLACEHOLDER, &group.name), |
| 246 | entries: members.get(&group.name).cloned().unwrap_or_default(), | 336 | members.get(&group.name).cloned().unwrap_or_default(), |
| 247 | group: Some(group.clone()), | 337 | Some(group.clone()), |
| 248 | // Deliberately not the whole group list. A page that can see every | 338 | // Deliberately not the whole group list. A page that can see every |
| 249 | // group depends on every group, so one new post would re-render every | 339 | // group depends on every group, so one new post would re-render every |
| 250 | // tag page — cost that scales with tag count, to support a tag cloud | 340 | // tag page — cost that scales with tag count, to support a tag cloud |
| 251 | // nobody has asked for. A tag page depends on its own posts, and the | 341 | // nobody has asked for. A tag page depends on its own posts, and the |
| 252 | // group index is where the group list belongs. | 342 | // group index is where the group list belongs. |
| 253 | groups: Vec::new(), | 343 | Vec::new(), |
| 254 | }); | 344 | ); |
| 255 | } | 345 | } |
| 256 | } | 346 | } |
| 257 | if !collection.index_output.as_str().is_empty() { | 347 | if !collection.index_output.as_str().is_empty() { |
| @@ -262,6 +352,7 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> { | |||
| 262 | entries: Vec::new(), | 352 | entries: Vec::new(), |
| 263 | group: None, | 353 | group: None, |
| 264 | groups: groups.clone(), | 354 | groups: groups.clone(), |
| 355 | paginator: None, | ||
| 265 | }); | 356 | }); |
| 266 | } | 357 | } |
| 267 | } | 358 | } |
| @@ -335,6 +426,12 @@ fn listing_entries_hash(listing: &Listing) -> Hash { | |||
| 335 | .iter() | 426 | .iter() |
| 336 | .map(|g| (g.url.clone(), format!("{}\u{0}{}", g.name, g.count))), | 427 | .map(|g| (g.url.clone(), format!("{}\u{0}{}", g.name, g.count))), |
| 337 | ) | 428 | ) |
| 429 | .chain(listing.paginator.iter().map(|p| { | ||
| 430 | ( | ||
| 431 | format!("{}/{}", p.current, p.total), | ||
| 432 | format!("{:?}|{:?}", p.prev_url, p.next_url), | ||
| 433 | ) | ||
| 434 | })) | ||
| 338 | .chain([(listing.title.clone(), listing.template.clone())]) | 435 | .chain([(listing.title.clone(), listing.template.clone())]) |
| 339 | .collect(); | 436 | .collect(); |
| 340 | // Entry *order* is meaningful in a listing, so this hashes the sorted-by-us sequence | 437 | // 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 | |||
| 804 | ctx.pages = Some(&listing.entries); | 901 | ctx.pages = Some(&listing.entries); |
| 805 | ctx.group = listing.group.as_ref(); | 902 | ctx.group = listing.group.as_ref(); |
| 806 | ctx.groups = &listing.groups; | 903 | ctx.groups = &listing.groups; |
| 904 | ctx.paginator = listing.paginator.as_ref(); | ||
| 807 | let html = templater | 905 | let html = templater |
| 808 | .render(&listing.template, &ctx) | 906 | .render(&listing.template, &ctx) |
| 809 | .with_context(|| { | 907 | .with_context(|| { |
src/template.rs +46
| @@ -202,6 +202,7 @@ impl Templater { | |||
| 202 | pages => ctx.pages, | 202 | pages => ctx.pages, |
| 203 | group => ctx.group, | 203 | group => ctx.group, |
| 204 | groups => ctx.groups, | 204 | groups => ctx.groups, |
| 205 | paginator => ctx.paginator, | ||
| 205 | }) | 206 | }) |
| 206 | .map_err(|e| TemplateError::Render(render_error_detail(e))) | 207 | .map_err(|e| TemplateError::Render(render_error_detail(e))) |
| 207 | } | 208 | } |
| @@ -226,6 +227,37 @@ pub struct GroupContext { | |||
| 226 | pub count: usize, | 227 | pub count: usize, |
| 227 | } | 228 | } |
| 228 | 229 | ||
| 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)] | ||
| 235 | pub 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)] | ||
| 253 | pub 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 | |||
| 229 | /// Everything a template can see. A struct rather than a dozen positional arguments, | 261 | /// Everything a template can see. A struct rather than a dozen positional arguments, |
| 230 | /// because the list grows every time templates learn something new. | 262 | /// because the list grows every time templates learn something new. |
| 231 | pub struct RenderContext<'a> { | 263 | pub struct RenderContext<'a> { |
| @@ -246,6 +278,8 @@ pub struct RenderContext<'a> { | |||
| 246 | /// Every group of a grouped collection — the group index's content. Empty on a | 278 | /// Every group of a grouped collection — the group index's content. Empty on a |
| 247 | /// per-group page, which depends on its own entries and not on the other groups. | 279 | /// per-group page, which depends on its own entries and not on the other groups. |
| 248 | pub groups: &'a [GroupContext], | 280 | pub groups: &'a [GroupContext], |
| 281 | /// Present only on a page of a paginated listing. | ||
| 282 | pub paginator: Option<&'a Paginator>, | ||
| 249 | } | 283 | } |
| 250 | 284 | ||
| 251 | impl<'a> RenderContext<'a> { | 285 | impl<'a> RenderContext<'a> { |
| @@ -267,6 +301,7 @@ impl<'a> RenderContext<'a> { | |||
| 267 | pages: None, | 301 | pages: None, |
| 268 | group: None, | 302 | group: None, |
| 269 | groups: &[], | 303 | groups: &[], |
| 304 | paginator: None, | ||
| 270 | } | 305 | } |
| 271 | } | 306 | } |
| 272 | } | 307 | } |
| @@ -339,6 +374,17 @@ pub const STARTER_LIST_TEMPLATE: &str = r#"<!DOCTYPE html> | |||
| 339 | </li> | 374 | </li> |
| 340 | {%- endfor %} | 375 | {%- endfor %} |
| 341 | </ul> | 376 | </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 %} | ||
| 342 | </main> | 388 | </main> |
| 343 | </body> | 389 | </body> |
| 344 | </html> | 390 | </html> |
tests/config.rs +239
| @@ -1058,3 +1058,242 @@ fn tags_that_collide_in_a_url_are_rejected() { | |||
| 1058 | let message = format!("{err:#}"); | 1058 | let message = format!("{err:#}"); |
| 1059 | assert!(message.contains("web_dev") && message.contains("web@dev"), "{message}"); | 1059 | assert!(message.contains("web_dev") && message.contains("web@dev"), "{message}"); |
| 1060 | } | 1060 | } |
| 1061 | |||
| 1062 | // --------------------------------------------------------------------------- | ||
| 1063 | // Pagination | ||
| 1064 | // --------------------------------------------------------------------------- | ||
| 1065 | |||
| 1066 | /// A blog of `count` dated posts with a paginating collection over them. | ||
| 1067 | fn 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] | ||
| 1106 | fn 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] | ||
| 1135 | fn 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] | ||
| 1157 | fn 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] | ||
| 1176 | fn 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] | ||
| 1191 | fn 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] | ||
| 1207 | fn 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] | ||
| 1242 | fn 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] | ||
| 1277 | fn 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 | } | ||