Skip to main content

ssg/plugins/
pagination.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Pagination plugin.
5//!
6//! Generates paginated index pages (`/page/2/`, `/page/3/`, etc.)
7//! from frontmatter sidecars when `paginate` is specified.
8
9use crate::error::{PathErrorExt, SsgError};
10use crate::plugin::{Plugin, PluginContext};
11use crate::plugins_group::listings::ListingConfig;
12use std::{
13    collections::{BTreeMap, HashMap},
14    fs,
15    path::{Path, PathBuf},
16};
17
18/// Default number of items per page.
19const DEFAULT_PER_PAGE: usize = 10;
20
21/// Page metadata for pagination.
22///
23/// The terms and language are here so a named listing can filter on them
24/// (#587). They are read in the same pass as the title and date: a
25/// listing that re-read every sidecar per page would do it once per page
26/// per listing, which on a ten-thousand-page corpus is the difference
27/// between a build and a coffee break.
28#[derive(Debug, Clone)]
29struct PageEntry {
30    title: String,
31    url: String,
32    date: String,
33    tags: Vec<String>,
34    categories: Vec<String>,
35    topics: Vec<String>,
36    language: Option<String>,
37}
38
39/// Plugin that generates paginated listing pages.
40///
41/// Runs in `after_compile`. Reads `.meta.json` sidecars, collects
42/// pages with dates, sorts by date descending, and generates
43/// `/page/N/index.html` files.
44#[derive(Debug, Clone, Copy)]
45pub struct PaginationPlugin {
46    per_page: usize,
47}
48
49impl Default for PaginationPlugin {
50    fn default() -> Self {
51        Self {
52            per_page: DEFAULT_PER_PAGE,
53        }
54    }
55}
56
57impl PaginationPlugin {
58    /// Creates a pagination plugin with a custom page size.
59    ///
60    /// # Examples
61    ///
62    /// ```rust
63    /// use ssg::pagination::PaginationPlugin;
64    /// use ssg::plugin::Plugin;
65    ///
66    /// let p = PaginationPlugin::with_per_page(25);
67    /// assert_eq!(p.name(), "pagination");
68    /// ```
69    #[must_use]
70    pub fn with_per_page(per_page: usize) -> Self {
71        Self {
72            per_page: per_page.max(1),
73        }
74    }
75}
76
77impl Plugin for PaginationPlugin {
78    fn name(&self) -> &'static str {
79        "pagination"
80    }
81
82    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
83        let sidecar_dir = ctx.build_dir.join(".meta");
84        if !sidecar_dir.exists() {
85            return Ok(());
86        }
87
88        let mut entries = collect_page_entries(&sidecar_dir)?;
89        if entries.is_empty() {
90            return Ok(());
91        }
92
93        // Date desc, then URL asc. The URL tiebreak makes this a total
94        // order: dates here are date-only strings, so a site publishing
95        // several pages on one day left them in whatever order the
96        // sidecar walk produced, and the paginated listings then differed
97        // between filesystems. URLs are unique per page.
98        entries.sort_by(|a, b| {
99            b.date.cmp(&a.date).then_with(|| a.url.cmp(&b.url))
100        });
101
102        // Named listings (#587). Each is a filtered view of the same
103        // entries, which were read once — a listing that re-read the
104        // sidecars would do so once per listing, per page.
105        let listings = ctx
106            .config
107            .as_ref()
108            .map_or::<&[ListingConfig], _>(&[], |c| c.listings.as_slice());
109        for listing in listings {
110            if listing.name.trim().is_empty() {
111                log::warn!("[listings] a listing with no name was skipped");
112                continue;
113            }
114            let selected: Vec<PageEntry> = entries
115                .iter()
116                .filter(|e| entry_matches(e, listing))
117                .cloned()
118                .collect();
119            if selected.is_empty() {
120                // A listing that matches nothing is usually a filter
121                // typo, and an empty directory is a worse way to find
122                // out than a line on the console.
123                log::warn!(
124                    "[listings] '{}' matched no pages; nothing written",
125                    listing.name
126                );
127                continue;
128            }
129            let pages = write_listing(
130                &ctx.site_dir,
131                listing,
132                &selected,
133                self.per_page,
134            )?;
135            let years = if listing.by_year {
136                write_year_archives(&ctx.site_dir, listing, &selected)?
137            } else {
138                0
139            };
140            log::info!(
141                "[listings] '{}': {} page(s), {} year archive(s), {} entries",
142                listing.name,
143                pages,
144                years,
145                selected.len()
146            );
147        }
148
149        let total_pages = entries.len().div_ceil(self.per_page);
150        if total_pages <= 1 {
151            return Ok(());
152        }
153
154        let page_dir = ctx.site_dir.join("page");
155        for page_num in 2..=total_pages {
156            let start = (page_num - 1) * self.per_page;
157            let end = (start + self.per_page).min(entries.len());
158            let page_entries = &entries[start..end];
159
160            write_pagination_page(
161                &page_dir,
162                page_num,
163                total_pages,
164                page_entries,
165            )?;
166        }
167
168        log::info!(
169            "[pagination] Generated {} page(s) ({} entries, {} per page)",
170            total_pages - 1,
171            entries.len(),
172            self.per_page
173        );
174        Ok(())
175    }
176}
177
178/// Whether `entry` belongs in `listing`.
179///
180/// Filters combine with AND, and each is skipped when absent, so a
181/// listing with none of them matches every dated page. Term matching is
182/// case-insensitive: `tag = "Rust"` and `tags: "rust"` are the same tag
183/// to a reader, and a listing that silently missed half its pages over
184/// capitalisation would be a poor way to find that out.
185fn entry_matches(entry: &PageEntry, listing: &ListingConfig) -> bool {
186    let has = |terms: &[String], want: &Option<String>| -> bool {
187        want.as_ref()
188            .is_none_or(|w| terms.iter().any(|t| t.eq_ignore_ascii_case(w)))
189    };
190
191    has(&entry.tags, &listing.tag)
192        && has(&entry.categories, &listing.category)
193        && has(&entry.topics, &listing.topic)
194        && listing.language.as_ref().is_none_or(|want| {
195            entry
196                .language
197                .as_ref()
198                .is_some_and(|l| l.eq_ignore_ascii_case(want))
199        })
200        // Dates are `YYYY-MM-DD`, which compares correctly as a string.
201        // A page whose date is malformed sorts where its text puts it
202        // rather than being dropped, which is the same latitude the rest
203        // of the pipeline gives it.
204        && listing.after.as_ref().is_none_or(|a| entry.date >= *a)
205        && listing.before.as_ref().is_none_or(|b| entry.date <= *b)
206}
207
208/// Writes one listing: page 1 at `/{name}/`, the rest at `/{name}/page/N/`.
209///
210/// Unlike the site-wide pagination, page 1 is written here. There is no
211/// pre-existing index at `/{name}/` to defer to — the listing is the only
212/// thing that knows the directory exists.
213fn write_listing(
214    site_dir: &Path,
215    listing: &ListingConfig,
216    entries: &[PageEntry],
217    default_per_page: usize,
218) -> Result<usize, SsgError> {
219    if entries.is_empty() {
220        return Ok(0);
221    }
222    let per_page = listing
223        .per_page
224        .filter(|n| *n > 0)
225        .unwrap_or(default_per_page);
226    let total_pages = entries.len().div_ceil(per_page);
227    let dir = site_dir.join(&listing.name);
228
229    for page_num in 1..=total_pages {
230        let start = (page_num - 1) * per_page;
231        let end = (start + per_page).min(entries.len());
232        let target = if page_num == 1 {
233            dir.clone()
234        } else {
235            dir.join("page").join(page_num.to_string())
236        };
237        write_listing_page(
238            &target,
239            listing,
240            page_num,
241            total_pages,
242            &entries[start..end],
243        )?;
244    }
245    Ok(total_pages)
246}
247
248/// Groups entries by the year in their date and writes `/{name}/{year}/`.
249///
250/// The year is the first four characters of the date, which is what
251/// `YYYY-MM-DD` guarantees; anything shorter is skipped rather than
252/// producing a `/archive//` directory.
253fn write_year_archives(
254    site_dir: &Path,
255    listing: &ListingConfig,
256    entries: &[PageEntry],
257) -> Result<usize, SsgError> {
258    let mut by_year: BTreeMap<&str, Vec<PageEntry>> = BTreeMap::new();
259    for entry in entries {
260        if entry.date.len() >= 4 {
261            by_year
262                .entry(&entry.date[..4])
263                .or_default()
264                .push(entry.clone());
265        }
266    }
267    let count = by_year.len();
268    for (year, group) in by_year {
269        let target = site_dir.join(&listing.name).join(year);
270        write_listing_page(&target, listing, 1, 1, &group)?;
271    }
272    Ok(count)
273}
274
275/// Collects page entries with dates from sidecar JSON files.
276fn collect_page_entries(
277    sidecar_dir: &Path,
278) -> Result<Vec<PageEntry>, SsgError> {
279    let sidecars = collect_json_files(sidecar_dir)?;
280    let mut entries = Vec::new();
281
282    for sidecar_path in &sidecars {
283        if let Some(entry) = parse_page_entry(sidecar_path, sidecar_dir) {
284            entries.push(entry);
285        }
286    }
287
288    Ok(entries)
289}
290
291/// Parses a single sidecar JSON file into a `PageEntry`, if it has a date.
292fn parse_page_entry(
293    sidecar_path: &Path,
294    sidecar_dir: &Path,
295) -> Option<PageEntry> {
296    // Counted by tests/listings_scale.rs: the sidecars are read once per
297    // build however many listings filter them.
298    fail_point!("pagination::read-sidecar");
299    let content = fs::read_to_string(sidecar_path).ok()?;
300    let meta: HashMap<String, serde_json::Value> =
301        serde_json::from_str(&content).ok()?;
302
303    let title = meta
304        .get("title")
305        .and_then(|v| v.as_str())
306        .unwrap_or("Untitled")
307        .to_string();
308    let date = meta
309        .get("date")
310        .and_then(|v| v.as_str())
311        .unwrap_or("")
312        .to_string();
313
314    if date.is_empty() {
315        return None;
316    }
317
318    let rel = sidecar_path
319        .strip_prefix(sidecar_dir)
320        .unwrap_or(sidecar_path)
321        .with_extension("")
322        .with_extension("html");
323    let url = format!("/{}", rel.to_string_lossy().replace('\\', "/"));
324
325    // Both front-matter shapes, as everywhere else: `tags: [a, b]` and
326    // `tags: "a, b"`.
327    let terms = |key: &str| -> Vec<String> {
328        meta.get(key).map_or_else(Vec::new, |v| {
329            v.as_array().map_or_else(
330                || {
331                    v.as_str()
332                        .map_or_else(Vec::new, |s| ssg_core::split_terms(s))
333                },
334                |arr| {
335                    arr.iter()
336                        .filter_map(serde_json::Value::as_str)
337                        .flat_map(ssg_core::split_terms)
338                        .collect()
339                },
340            )
341        })
342    };
343
344    Some(PageEntry {
345        title,
346        url,
347        date,
348        tags: terms("tags"),
349        categories: terms("categories"),
350        topics: terms("topic_clusters"),
351        language: meta
352            .get("language")
353            .and_then(serde_json::Value::as_str)
354            .map(ToOwned::to_owned),
355    })
356}
357
358/// Writes a single pagination page to disk.
359fn write_pagination_page(
360    page_dir: &Path,
361    page_num: usize,
362    total_pages: usize,
363    page_entries: &[PageEntry],
364) -> Result<(), SsgError> {
365    let dir = page_dir.join(page_num.to_string());
366    fs::create_dir_all(&dir).with_path(&dir)?;
367
368    let prev_url = if page_num == 2 {
369        "/".to_string()
370    } else {
371        format!("/page/{}/", page_num - 1)
372    };
373    let next_url = if page_num < total_pages {
374        Some(format!("/page/{}/", page_num + 1))
375    } else {
376        None
377    };
378
379    let mut html = format!(
380        "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\
381         <meta charset=\"utf-8\">\
382         <title>Page {page_num} of {total_pages}</title></head>\n\
383         <body>\n<main>\n\
384         <h1>Page {page_num} of {total_pages}</h1>\n<ul>\n",
385    );
386
387    for entry in page_entries {
388        html.push_str(&format!(
389            "<li><a href=\"{}\">{}</a> <time>{}</time></li>\n",
390            entry.url, entry.title, entry.date
391        ));
392    }
393
394    html.push_str("</ul>\n<nav aria-label=\"Pagination\">\n");
395    html.push_str(&format!(
396        "<a href=\"{prev_url}\" rel=\"prev\">&larr; Previous</a>\n"
397    ));
398    if let Some(next) = &next_url {
399        html.push_str(&format!(
400            "<a href=\"{next}\" rel=\"next\">Next &rarr;</a>\n"
401        ));
402    }
403    html.push_str("</nav>\n</main>\n</body>\n</html>\n");
404
405    let out_file = dir.join("index.html");
406    fs::write(&out_file, html).with_path(&out_file)?;
407    Ok(())
408}
409
410/// Writes one page of a named listing to `dir/index.html`.
411///
412/// Titles and dates are escaped: they come from front matter, and a
413/// title containing `<` would otherwise close the anchor and swallow the
414/// rest of the list.
415fn write_listing_page(
416    dir: &Path,
417    listing: &ListingConfig,
418    page_num: usize,
419    total_pages: usize,
420    entries: &[PageEntry],
421) -> Result<(), SsgError> {
422    fs::create_dir_all(dir).with_path(dir)?;
423
424    let base = format!("/{}", listing.name);
425    let page_url = |n: usize| -> String {
426        if n == 1 {
427            format!("{base}/")
428        } else {
429            format!("{base}/page/{n}/")
430        }
431    };
432
433    let title = escape_html(listing.display_title());
434    let heading = if total_pages > 1 {
435        format!("{title} — page {page_num} of {total_pages}")
436    } else {
437        title.clone()
438    };
439
440    let mut html = format!(
441        "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\
442         <meta charset=\"utf-8\">\
443         <title>{heading}</title></head>\n\
444         <body>\n<main>\n\
445         <h1>{heading}</h1>\n<ul>\n",
446    );
447    for entry in entries {
448        html.push_str(&format!(
449            "<li><a href=\"{}\">{}</a> <time datetime=\"{}\">{}</time></li>\n",
450            escape_html(&entry.url),
451            escape_html(&entry.title),
452            escape_html(&entry.date),
453            escape_html(&entry.date),
454        ));
455    }
456    html.push_str("</ul>\n");
457
458    if total_pages > 1 {
459        html.push_str("<nav aria-label=\"Pagination\">\n");
460        if page_num > 1 {
461            html.push_str(&format!(
462                "<a href=\"{}\" rel=\"prev\">&larr; Previous</a>\n",
463                page_url(page_num - 1)
464            ));
465        }
466        if page_num < total_pages {
467            html.push_str(&format!(
468                "<a href=\"{}\" rel=\"next\">Next &rarr;</a>\n",
469                page_url(page_num + 1)
470            ));
471        }
472        html.push_str("</nav>\n");
473    }
474    html.push_str("</main>\n</body>\n</html>\n");
475
476    let out_file = dir.join("index.html");
477    fs::write(&out_file, html).with_path(&out_file)?;
478    Ok(())
479}
480
481/// Minimal HTML text/attribute escaping for generated listings.
482fn escape_html(s: &str) -> String {
483    let mut out = String::with_capacity(s.len());
484    for c in s.chars() {
485        match c {
486            '&' => out.push_str("&amp;"),
487            '<' => out.push_str("&lt;"),
488            '>' => out.push_str("&gt;"),
489            '"' => out.push_str("&quot;"),
490            '\'' => out.push_str("&#39;"),
491            _ => out.push(c),
492        }
493    }
494    out
495}
496
497fn collect_json_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
498    crate::walk::walk_files(dir, "json")
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504    use crate::test_support::init_logger;
505    use std::path::PathBuf;
506    use tempfile::{tempdir, TempDir};
507
508    // -------------------------------------------------------------------
509    // Test fixtures
510    // -------------------------------------------------------------------
511
512    /// Builds a fresh temp dir layout: `<root>/site`, `<root>/build/.meta`,
513    /// and a `PluginContext` pointing at it. Returns the temp dir guard
514    /// (must outlive the test), the site path, the meta sidecar path,
515    /// and the context.
516    fn make_layout() -> (TempDir, PathBuf, PathBuf, PluginContext) {
517        init_logger();
518        let dir = tempdir().expect("create tempdir");
519        let site = dir.path().join("site");
520        let build = dir.path().join("build");
521        let meta = build.join(".meta");
522        fs::create_dir_all(&site).expect("mkdir site");
523        fs::create_dir_all(&meta).expect("mkdir meta");
524        let ctx = PluginContext::new(dir.path(), &build, &site, dir.path());
525        (dir, site, meta, ctx)
526    }
527
528    /// A layout whose config carries the given listings (#587).
529    fn layout_with_listings(
530        listings: Vec<ListingConfig>,
531    ) -> (TempDir, PathBuf, PathBuf, PluginContext) {
532        init_logger();
533        let dir = tempdir().expect("create tempdir");
534        let site = dir.path().join("site");
535        let build = dir.path().join("build");
536        let meta = build.join(".meta");
537        fs::create_dir_all(&site).expect("mkdir site");
538        fs::create_dir_all(&meta).expect("mkdir meta");
539        let mut cfg = crate::cmd::default_config().as_ref().clone();
540        cfg.listings = listings;
541        let ctx = PluginContext::with_config(
542            dir.path(),
543            &build,
544            &site,
545            dir.path(),
546            cfg,
547        );
548        (dir, site, meta, ctx)
549    }
550
551    /// A sidecar with terms and a language, for listing filters.
552    fn write_rich_sidecar(
553        meta: &Path,
554        name: &str,
555        title: &str,
556        date: &str,
557        extra: &str,
558    ) {
559        let json = format!(
560            r#"{{"title": "{title}", "date": "{date}"{}{extra}}}"#,
561            if extra.is_empty() { "" } else { ", " }
562        );
563        fs::write(meta.join(format!("{name}.meta.json")), json)
564            .expect("write sidecar");
565    }
566
567    #[test]
568    fn a_named_listing_writes_page_one_at_its_own_path() {
569        let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
570            name: "archive".to_string(),
571            title: Some("Archive".to_string()),
572            ..ListingConfig::default()
573        }]);
574        write_sidecar(&meta, "a", "Alpha", "2026-01-01");
575
576        PaginationPlugin::default().after_compile(&ctx).unwrap();
577
578        let page = fs::read_to_string(site.join("archive/index.html"))
579            .expect("page 1 is written at the listing root");
580        assert!(page.contains("Archive"), "{page}");
581        assert!(page.contains("Alpha"), "{page}");
582    }
583
584    #[test]
585    fn a_listing_paginates_at_its_own_per_page() {
586        let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
587            name: "archive".to_string(),
588            per_page: Some(2),
589            ..ListingConfig::default()
590        }]);
591        for i in 1..=5 {
592            write_sidecar(
593                &meta,
594                &format!("p{i}"),
595                &format!("P{i}"),
596                &format!("2026-01-0{i}"),
597            );
598        }
599
600        PaginationPlugin::default().after_compile(&ctx).unwrap();
601
602        assert!(site.join("archive/index.html").exists());
603        assert!(site.join("archive/page/2/index.html").exists());
604        assert!(site.join("archive/page/3/index.html").exists());
605        assert!(
606            !site.join("archive/page/4/index.html").exists(),
607            "5 entries at 2 per page is 3 pages"
608        );
609        let p2 =
610            fs::read_to_string(site.join("archive/page/2/index.html")).unwrap();
611        assert!(
612            p2.contains(r#"href="/archive/""#),
613            "prev goes to page 1: {p2}"
614        );
615        assert!(p2.contains(r#"href="/archive/page/3/""#), "next: {p2}");
616    }
617
618    #[test]
619    fn filters_select_the_pages_and_combine_with_and() {
620        let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
621            name: "rust-2026".to_string(),
622            tag: Some("rust".to_string()),
623            after: Some("2026-01-01".to_string()),
624            ..ListingConfig::default()
625        }]);
626        write_rich_sidecar(
627            &meta,
628            "a",
629            "Match",
630            "2026-06-01",
631            r#""tags": "rust""#,
632        );
633        write_rich_sidecar(
634            &meta,
635            "b",
636            "WrongTag",
637            "2026-06-01",
638            r#""tags": "go""#,
639        );
640        write_rich_sidecar(
641            &meta,
642            "c",
643            "TooOld",
644            "2025-06-01",
645            r#""tags": "rust""#,
646        );
647
648        PaginationPlugin::default().after_compile(&ctx).unwrap();
649
650        let page =
651            fs::read_to_string(site.join("rust-2026/index.html")).unwrap();
652        assert!(page.contains("Match"), "{page}");
653        assert!(!page.contains("WrongTag"), "tag filter: {page}");
654        assert!(!page.contains("TooOld"), "date filter: {page}");
655    }
656
657    /// `tag = "Rust"` and `tags: "rust"` are the same tag to a reader.
658    #[test]
659    fn term_filters_ignore_case() {
660        let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
661            name: "r".to_string(),
662            tag: Some("Rust".to_string()),
663            ..ListingConfig::default()
664        }]);
665        write_rich_sidecar(
666            &meta,
667            "a",
668            "Alpha",
669            "2026-01-01",
670            r#""tags": "rust""#,
671        );
672
673        PaginationPlugin::default().after_compile(&ctx).unwrap();
674        assert!(fs::read_to_string(site.join("r/index.html"))
675            .unwrap()
676            .contains("Alpha"));
677    }
678
679    #[test]
680    fn by_year_writes_one_archive_per_year() {
681        let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
682            name: "archive".to_string(),
683            by_year: true,
684            ..ListingConfig::default()
685        }]);
686        write_sidecar(&meta, "a", "Old", "2025-03-01");
687        write_sidecar(&meta, "b", "New", "2026-03-01");
688
689        PaginationPlugin::default().after_compile(&ctx).unwrap();
690
691        let y2025 =
692            fs::read_to_string(site.join("archive/2025/index.html")).unwrap();
693        assert!(y2025.contains("Old") && !y2025.contains("New"), "{y2025}");
694        let y2026 =
695            fs::read_to_string(site.join("archive/2026/index.html")).unwrap();
696        assert!(y2026.contains("New") && !y2026.contains("Old"), "{y2026}");
697    }
698
699    /// A filter that matches nothing writes nothing — and says so.
700    #[test]
701    fn a_listing_matching_nothing_writes_no_directory() {
702        let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
703            name: "ghost".to_string(),
704            tag: Some("nonexistent".to_string()),
705            ..ListingConfig::default()
706        }]);
707        write_sidecar(&meta, "a", "Alpha", "2026-01-01");
708
709        PaginationPlugin::default().after_compile(&ctx).unwrap();
710        assert!(!site.join("ghost").exists(), "no empty listing directory");
711    }
712
713    /// Front-matter text reaches the page as text, not as markup.
714    #[test]
715    fn titles_are_escaped_in_generated_listings() {
716        let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
717            name: "archive".to_string(),
718            ..ListingConfig::default()
719        }]);
720        write_sidecar(&meta, "a", "A <b>bold</b> title", "2026-01-01");
721
722        PaginationPlugin::default().after_compile(&ctx).unwrap();
723        let page = fs::read_to_string(site.join("archive/index.html")).unwrap();
724        assert!(page.contains("&lt;b&gt;bold&lt;/b&gt;"), "{page}");
725        assert!(!page.contains("<b>bold</b>"), "{page}");
726    }
727
728    /// Sites with no listings configured behave exactly as before.
729    #[test]
730    fn no_listings_configured_writes_no_listing_directories() {
731        let (_d, site, meta, ctx) = make_layout();
732        write_n_dated_posts(&meta, 25);
733
734        PaginationPlugin::default().after_compile(&ctx).unwrap();
735
736        assert!(
737            site.join("page/2/index.html").exists(),
738            "site-wide unchanged"
739        );
740        let dirs: Vec<_> = fs::read_dir(&site)
741            .unwrap()
742            .filter_map(Result::ok)
743            .map(|e| e.file_name().to_string_lossy().to_string())
744            .collect();
745        assert_eq!(dirs, vec!["page".to_string()], "only /page/: {dirs:?}");
746    }
747
748    /// Writes a sidecar JSON file shaped `{"title": ..., "date": ...}`.
749    fn write_sidecar(meta: &Path, name: &str, title: &str, date: &str) {
750        let json = if date.is_empty() {
751            format!(r#"{{"title": "{title}"}}"#)
752        } else {
753            format!(r#"{{"title": "{title}", "date": "{date}"}}"#)
754        };
755        fs::write(meta.join(format!("{name}.meta.json")), json)
756            .expect("write sidecar");
757    }
758
759    /// Writes `n` dated posts numbered 1..=n with monotonically
760    /// increasing dates so sort order is well-defined.
761    fn write_n_dated_posts(meta: &Path, n: usize) {
762        for i in 1..=n {
763            write_sidecar(
764                meta,
765                &format!("post{i:03}"),
766                &format!("Post {i}"),
767                &format!("2026-01-{i:02}"),
768            );
769        }
770    }
771
772    // -------------------------------------------------------------------
773    // Constructor + derive surface
774    // -------------------------------------------------------------------
775
776    #[test]
777    fn default_uses_default_per_page_constant() {
778        // The Default impl is the public ergonomic — assert it matches
779        // the documented constant rather than a magic number, so the
780        // test stays correct if DEFAULT_PER_PAGE is ever retuned.
781        let plugin = PaginationPlugin::default();
782        assert_eq!(plugin.per_page, DEFAULT_PER_PAGE);
783    }
784
785    #[test]
786    fn with_per_page_stores_supplied_value() {
787        let plugin = PaginationPlugin::with_per_page(7);
788        assert_eq!(plugin.per_page, 7);
789    }
790
791    #[test]
792    fn with_per_page_zero_clamps_to_one() {
793        // Zero would cause a divide-by-zero in `div_ceil`. The
794        // constructor must clamp it.
795        let plugin = PaginationPlugin::with_per_page(0);
796        assert_eq!(plugin.per_page, 1);
797    }
798
799    #[test]
800    fn with_per_page_one_is_valid_lower_bound() {
801        let plugin = PaginationPlugin::with_per_page(1);
802        assert_eq!(plugin.per_page, 1);
803    }
804
805    #[test]
806    fn with_per_page_table_driven_values() {
807        // Table-driven sanity check across a spread of valid sizes.
808        let cases: &[(usize, usize)] = &[
809            (1, 1),
810            (5, 5),
811            (10, 10),
812            (100, 100),
813            (usize::MAX, usize::MAX),
814        ];
815        for &(input, expected) in cases {
816            let plugin = PaginationPlugin::with_per_page(input);
817            assert_eq!(
818                plugin.per_page, expected,
819                "with_per_page({input}) should store {expected}"
820            );
821        }
822    }
823
824    #[test]
825    fn pagination_plugin_is_copy_after_move() {
826        // Guards the `Copy` derive added in v0.0.34.
827        let plugin = PaginationPlugin::with_per_page(3);
828        let _copy = plugin;
829        assert_eq!(plugin.per_page, 3);
830    }
831
832    #[test]
833    fn name_returns_static_pagination_identifier() {
834        let plugin = PaginationPlugin::default();
835        assert_eq!(plugin.name(), "pagination");
836    }
837
838    // -------------------------------------------------------------------
839    // after_compile — early-return paths
840    // -------------------------------------------------------------------
841
842    #[test]
843    fn after_compile_missing_meta_dir_returns_ok_without_writing() {
844        // No `.meta` directory under build/ — must short-circuit, not
845        // error, and must not create the page/ directory.
846        let dir = tempdir().expect("tempdir");
847        let site = dir.path().join("site");
848        let build = dir.path().join("build");
849        fs::create_dir_all(&site).expect("mkdir site");
850        fs::create_dir_all(&build).expect("mkdir build");
851        let ctx = PluginContext::new(dir.path(), &build, &site, dir.path());
852
853        PaginationPlugin::default()
854            .after_compile(&ctx)
855            .expect("missing meta dir is not an error");
856
857        assert!(!site.join("page").exists());
858    }
859
860    #[test]
861    fn after_compile_empty_meta_dir_returns_ok_without_writing() {
862        let (_tmp, site, _meta, ctx) = make_layout();
863        PaginationPlugin::default()
864            .after_compile(&ctx)
865            .expect("empty meta is fine");
866        assert!(!site.join("page").exists());
867    }
868
869    #[test]
870    fn after_compile_only_undated_pages_returns_ok_without_writing() {
871        // Pages without `date` are skipped — see line 91. Only undated
872        // entries means `entries.is_empty()` short-circuit at line 105.
873        let (_tmp, site, meta, ctx) = make_layout();
874        write_sidecar(&meta, "about", "About", "");
875        write_sidecar(&meta, "contact", "Contact", "");
876
877        PaginationPlugin::default().after_compile(&ctx).unwrap();
878        assert!(!site.join("page").exists());
879    }
880
881    #[test]
882    fn after_compile_single_page_skips_pagination() {
883        // 5 dated posts at default per_page=10 → 1 page total → no
884        // /page/N/ directories produced (line 114 `total_pages <= 1`).
885        let (_tmp, site, meta, ctx) = make_layout();
886        write_n_dated_posts(&meta, 5);
887
888        PaginationPlugin::default().after_compile(&ctx).unwrap();
889        assert!(!site.join("page").exists());
890    }
891
892    // -------------------------------------------------------------------
893    // after_compile — sidecar parsing fallbacks
894    // -------------------------------------------------------------------
895
896    #[test]
897    fn after_compile_skips_invalid_json_sidecars() {
898        // The JSON parser error branch at line 76 must not propagate —
899        // bad sidecars are silently skipped so a single corrupt file
900        // can't poison the whole build.
901        let (_tmp, site, meta, ctx) = make_layout();
902        fs::write(meta.join("broken.meta.json"), "{not valid json").unwrap();
903        // Add 11 valid posts so we still cross the pagination threshold
904        // (default per_page=10 → 2 pages).
905        write_n_dated_posts(&meta, 11);
906
907        PaginationPlugin::default()
908            .after_compile(&ctx)
909            .expect("broken sidecar must not error");
910        assert!(site.join("page/2/index.html").exists());
911    }
912
913    #[test]
914    fn after_compile_missing_title_defaults_to_untitled() {
915        // Pages without a `title` field but with a `date` are still
916        // paginated; the title falls back to "Untitled" (line 82).
917        let (_tmp, site, meta, ctx) = make_layout();
918        // 11 entries with NO title field → "Untitled" fallback used.
919        for i in 1..=11 {
920            fs::write(
921                meta.join(format!("post{i}.meta.json")),
922                format!(r#"{{"date": "2026-01-{i:02}"}}"#),
923            )
924            .unwrap();
925        }
926
927        PaginationPlugin::default().after_compile(&ctx).unwrap();
928        let page2 = fs::read_to_string(site.join("page/2/index.html")).unwrap();
929        assert!(
930            page2.contains("Untitled"),
931            "missing title must fall back to \"Untitled\":\n{page2}"
932        );
933    }
934
935    #[test]
936    fn after_compile_skips_pages_with_empty_date_string() {
937        // A `date` field present but empty must be treated the same
938        // as a missing date (line 91-93).
939        let (_tmp, site, meta, ctx) = make_layout();
940        write_sidecar(&meta, "draft", "Draft", ""); // empty date branch
941        write_n_dated_posts(&meta, 11);
942
943        PaginationPlugin::default().after_compile(&ctx).unwrap();
944        // Only the 11 dated posts paginate; the empty-date entry is
945        // ignored, so we get exactly one /page/2/ (11 → 2 pages).
946        assert!(site.join("page/2/index.html").exists());
947        assert!(!site.join("page/3/index.html").exists());
948    }
949
950    // -------------------------------------------------------------------
951    // after_compile — page slicing arithmetic
952    // -------------------------------------------------------------------
953
954    #[test]
955    fn after_compile_exact_multiple_yields_full_pages() {
956        // 10 posts at per_page=5 → exactly 2 full pages, no remainder.
957        let (_tmp, site, meta, ctx) = make_layout();
958        write_n_dated_posts(&meta, 10);
959
960        PaginationPlugin::with_per_page(5)
961            .after_compile(&ctx)
962            .unwrap();
963
964        let page2 = fs::read_to_string(site.join("page/2/index.html")).unwrap();
965        // Page 2 of 2: should contain exactly 5 list items.
966        let li_count = page2.matches("<li>").count();
967        assert_eq!(li_count, 5, "page 2 should have 5 entries:\n{page2}");
968        assert!(!site.join("page/3/index.html").exists());
969    }
970
971    #[test]
972    fn after_compile_non_multiple_yields_partial_last_page() {
973        // 11 posts at per_page=5 → 3 pages: 5 + 5 + 1.
974        let (_tmp, site, meta, ctx) = make_layout();
975        write_n_dated_posts(&meta, 11);
976
977        PaginationPlugin::with_per_page(5)
978            .after_compile(&ctx)
979            .unwrap();
980
981        assert!(site.join("page/2/index.html").exists());
982        assert!(site.join("page/3/index.html").exists());
983        assert!(!site.join("page/4/index.html").exists());
984
985        let page3 = fs::read_to_string(site.join("page/3/index.html")).unwrap();
986        let li_count = page3.matches("<li>").count();
987        assert_eq!(li_count, 1, "last page should have 1 entry:\n{page3}");
988    }
989
990    #[test]
991    fn after_compile_per_page_one_yields_one_page_per_post() {
992        // per_page=1 boundary: 5 posts → 5 pages → /page/2 .. /page/5.
993        let (_tmp, site, meta, ctx) = make_layout();
994        write_n_dated_posts(&meta, 5);
995
996        PaginationPlugin::with_per_page(1)
997            .after_compile(&ctx)
998            .unwrap();
999
1000        for n in 2..=5 {
1001            assert!(
1002                site.join(format!("page/{n}/index.html")).exists(),
1003                "page/{n}/ should exist"
1004            );
1005        }
1006        assert!(!site.join("page/6/index.html").exists());
1007    }
1008
1009    // -------------------------------------------------------------------
1010    // after_compile — sort order
1011    // -------------------------------------------------------------------
1012
1013    #[test]
1014    fn after_compile_sorts_entries_by_date_descending() {
1015        // Posts written out of order — newest must appear first on
1016        // page 1 (which is unwritten by this plugin), so the remainder
1017        // on page 2 must be the *oldest* entries.
1018        let (_tmp, site, meta, ctx) = make_layout();
1019        // Write posts with dates that are NOT in filename order:
1020        // file `a` → 2026-01-01 (oldest)
1021        // file `z` → 2026-01-11 (newest)
1022        let dates = [
1023            ("a", "2026-01-01"),
1024            ("m", "2026-01-05"),
1025            ("z", "2026-01-11"),
1026            ("b", "2026-01-02"),
1027            ("y", "2026-01-10"),
1028            ("c", "2026-01-03"),
1029            ("x", "2026-01-09"),
1030            ("d", "2026-01-04"),
1031            ("w", "2026-01-08"),
1032            ("e", "2026-01-06"),
1033            ("f", "2026-01-07"),
1034        ];
1035        for (name, date) in dates {
1036            write_sidecar(&meta, name, &format!("Post {name}"), date);
1037        }
1038
1039        PaginationPlugin::with_per_page(10)
1040            .after_compile(&ctx)
1041            .unwrap();
1042
1043        // 11 entries / 10 per page → page 2 has the single OLDEST entry.
1044        let page2 = fs::read_to_string(site.join("page/2/index.html")).unwrap();
1045        assert!(
1046            page2.contains("2026-01-01"),
1047            "page 2 should contain the oldest entry:\n{page2}"
1048        );
1049        assert!(
1050            !page2.contains("2026-01-11"),
1051            "page 2 should NOT contain the newest entry:\n{page2}"
1052        );
1053    }
1054
1055    // -------------------------------------------------------------------
1056    // after_compile — HTML structure & navigation
1057    // -------------------------------------------------------------------
1058
1059    #[test]
1060    fn after_compile_emits_doctype_lang_and_charset() {
1061        let (_tmp, site, meta, ctx) = make_layout();
1062        write_n_dated_posts(&meta, 11);
1063        PaginationPlugin::default().after_compile(&ctx).unwrap();
1064
1065        let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
1066        assert!(html.starts_with("<!DOCTYPE html>"));
1067        assert!(html.contains("<html lang=\"en\">"));
1068        assert!(html.contains("<meta charset=\"utf-8\">"));
1069    }
1070
1071    #[test]
1072    fn after_compile_emits_pagination_nav_landmark() {
1073        let (_tmp, site, meta, ctx) = make_layout();
1074        write_n_dated_posts(&meta, 11);
1075        PaginationPlugin::default().after_compile(&ctx).unwrap();
1076
1077        let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
1078        assert!(html.contains("<nav aria-label=\"Pagination\">"));
1079    }
1080
1081    #[test]
1082    fn after_compile_page_two_prev_link_points_at_root() {
1083        // The "previous" link from page 2 must point to "/" — page 1
1084        // is the home/root, not /page/1/. Guards line 129-130.
1085        let (_tmp, site, meta, ctx) = make_layout();
1086        write_n_dated_posts(&meta, 11);
1087        PaginationPlugin::default().after_compile(&ctx).unwrap();
1088
1089        let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
1090        assert!(
1091            html.contains(r#"<a href="/" rel="prev">"#),
1092            "page 2's prev should point to root:\n{html}"
1093        );
1094    }
1095
1096    #[test]
1097    fn after_compile_page_three_prev_link_points_at_page_two() {
1098        // Beyond page 2 the prev link uses /page/N-1/ form (line 132).
1099        let (_tmp, site, meta, ctx) = make_layout();
1100        write_n_dated_posts(&meta, 11);
1101        PaginationPlugin::with_per_page(5)
1102            .after_compile(&ctx)
1103            .unwrap();
1104
1105        let html = fs::read_to_string(site.join("page/3/index.html")).unwrap();
1106        assert!(
1107            html.contains(r#"<a href="/page/2/" rel="prev">"#),
1108            "page 3's prev should point to /page/2/:\n{html}"
1109        );
1110    }
1111
1112    #[test]
1113    fn after_compile_last_page_has_no_next_link() {
1114        // The Next link is omitted on the final page — guards
1115        // the `if let Some(next)` branch at line 159.
1116        let (_tmp, site, meta, ctx) = make_layout();
1117        write_n_dated_posts(&meta, 11);
1118        PaginationPlugin::with_per_page(5)
1119            .after_compile(&ctx)
1120            .unwrap();
1121
1122        let last = fs::read_to_string(site.join("page/3/index.html")).unwrap();
1123        assert!(
1124            !last.contains(r#"rel="next""#),
1125            "last page must not emit a Next link:\n{last}"
1126        );
1127    }
1128
1129    #[test]
1130    fn after_compile_middle_page_has_both_prev_and_next() {
1131        // 16 posts / per_page=5 → 4 pages. Page 2 and page 3 are both
1132        // "middle" — assert they have BOTH prev and next.
1133        let (_tmp, site, meta, ctx) = make_layout();
1134        write_n_dated_posts(&meta, 16);
1135        PaginationPlugin::with_per_page(5)
1136            .after_compile(&ctx)
1137            .unwrap();
1138
1139        let page3 = fs::read_to_string(site.join("page/3/index.html")).unwrap();
1140        assert!(page3.contains(r#"rel="prev""#));
1141        assert!(page3.contains(r#"rel="next""#));
1142    }
1143
1144    #[test]
1145    fn after_compile_renders_time_element_per_entry() {
1146        let (_tmp, site, meta, ctx) = make_layout();
1147        write_n_dated_posts(&meta, 11);
1148        PaginationPlugin::default().after_compile(&ctx).unwrap();
1149
1150        let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
1151        assert!(
1152            html.contains("<time>2026-01-01</time>"),
1153            "page 2 should render a <time> element:\n{html}"
1154        );
1155    }
1156
1157    #[test]
1158    fn after_compile_idempotent_overwrites_existing_pages() {
1159        // Re-running must not error and must leave the page directories
1160        // intact. Guards against any future use of `create_new`.
1161        let (_tmp, site, meta, ctx) = make_layout();
1162        write_n_dated_posts(&meta, 11);
1163        let plugin = PaginationPlugin::default();
1164        plugin.after_compile(&ctx).expect("first run");
1165        plugin.after_compile(&ctx).expect("second run");
1166        assert!(site.join("page/2/index.html").exists());
1167    }
1168
1169    // -------------------------------------------------------------------
1170    // collect_json_files — recursion + filtering
1171    // -------------------------------------------------------------------
1172
1173    #[test]
1174    fn collect_json_files_returns_empty_for_missing_directory() {
1175        // Non-existent path: the inner `is_dir()` check at line 183
1176        // means we just `continue`, ending with an empty Vec — no Err.
1177        let dir = tempdir().expect("tempdir");
1178        let result =
1179            collect_json_files(&dir.path().join("does-not-exist")).unwrap();
1180        assert!(result.is_empty(), "{:?}", result);
1181    }
1182
1183    #[test]
1184    fn collect_json_files_returns_empty_for_empty_directory() {
1185        let dir = tempdir().expect("tempdir");
1186        let result = collect_json_files(dir.path()).unwrap();
1187        assert!(result.is_empty(), "{:?}", result);
1188    }
1189
1190    #[test]
1191    fn collect_json_files_filters_non_json_extensions() {
1192        // Only `.json` files are returned. The `is_some_and` filter at
1193        // line 191 must reject `.txt`, `.md`, extensionless files, etc.
1194        let dir = tempdir().expect("tempdir");
1195        fs::write(dir.path().join("a.json"), "{}").unwrap();
1196        fs::write(dir.path().join("b.txt"), "x").unwrap();
1197        fs::write(dir.path().join("c.md"), "x").unwrap();
1198        fs::write(dir.path().join("noext"), "x").unwrap();
1199
1200        let result = collect_json_files(dir.path()).unwrap();
1201        assert_eq!(result.len(), 1);
1202        assert_eq!(
1203            result[0].file_name().unwrap(),
1204            std::ffi::OsStr::new("a.json")
1205        );
1206    }
1207
1208    #[test]
1209    fn collect_json_files_recurses_into_subdirectories() {
1210        // Walks nested directories — guards line 189-190 (push subdir
1211        // onto stack).
1212        let dir = tempdir().expect("tempdir");
1213        let nested = dir.path().join("a").join("b").join("c");
1214        fs::create_dir_all(&nested).unwrap();
1215        fs::write(dir.path().join("top.json"), "{}").unwrap();
1216        fs::write(dir.path().join("a").join("mid.json"), "{}").unwrap();
1217        fs::write(nested.join("deep.json"), "{}").unwrap();
1218
1219        let result = collect_json_files(dir.path()).unwrap();
1220        assert_eq!(result.len(), 3);
1221    }
1222
1223    #[test]
1224    fn collect_json_files_returns_results_sorted() {
1225        // The `files.sort()` at line 196 must yield deterministic output.
1226        let dir = tempdir().expect("tempdir");
1227        for name in ["zebra.json", "apple.json", "mango.json"] {
1228            fs::write(dir.path().join(name), "{}").unwrap();
1229        }
1230        let result = collect_json_files(dir.path()).unwrap();
1231        let names: Vec<&str> = result
1232            .iter()
1233            .map(|p| p.file_name().unwrap().to_str().unwrap())
1234            .collect();
1235        assert_eq!(names, vec!["apple.json", "mango.json", "zebra.json"]);
1236    }
1237
1238    // -------------------------------------------------------------------
1239    // I/O error propagation
1240    // -------------------------------------------------------------------
1241
1242    #[test]
1243    #[cfg(unix)]
1244    fn after_compile_propagates_walk_error_from_unreadable_meta_subdir() {
1245        use std::os::unix::fs::PermissionsExt;
1246
1247        let (_tmp, _site, meta, ctx) = make_layout();
1248        let locked = meta.join("locked");
1249        fs::create_dir_all(&locked).unwrap();
1250        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1251            .unwrap();
1252
1253        let result = PaginationPlugin::default().after_compile(&ctx);
1254        fs::set_permissions(&locked, fs::Permissions::from_mode(0o755))
1255            .unwrap();
1256        assert!(result.is_err(), "unreadable meta subdir must be an Err");
1257    }
1258
1259    #[test]
1260    #[cfg(unix)]
1261    fn after_compile_skips_unreadable_sidecar_file() {
1262        use std::os::unix::fs::PermissionsExt;
1263
1264        // An unreadable sidecar takes the `.ok()?` None path in
1265        // parse_page_entry and is silently skipped; the remaining
1266        // dated posts still paginate.
1267        let (_tmp, site, meta, ctx) = make_layout();
1268        write_n_dated_posts(&meta, 11);
1269        let locked = meta.join("locked.meta.json");
1270        fs::write(&locked, r#"{"title": "L", "date": "2026-02-01"}"#).unwrap();
1271        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1272            .unwrap();
1273
1274        let result = PaginationPlugin::default().after_compile(&ctx);
1275        fs::set_permissions(&locked, fs::Permissions::from_mode(0o644))
1276            .unwrap();
1277        result.expect("unreadable sidecar must be skipped, not fatal");
1278        assert!(site.join("page/2/index.html").exists());
1279        assert!(!site.join("page/3/index.html").exists());
1280    }
1281
1282    #[test]
1283    fn after_compile_create_dir_failure_when_page_is_a_file() {
1284        // A regular file occupying site/page makes create_dir_all fail,
1285        // which must propagate through write_pagination_page's `?`.
1286        let (_tmp, site, meta, ctx) = make_layout();
1287        write_n_dated_posts(&meta, 11);
1288        fs::write(site.join("page"), "not a directory").unwrap();
1289
1290        let err = PaginationPlugin::default()
1291            .after_compile(&ctx)
1292            .expect_err("create_dir_all over a file must fail");
1293        let msg = format!("{err:?}");
1294        assert!(msg.contains("Io"), "expected Io error, got: {msg}");
1295    }
1296
1297    #[test]
1298    fn after_compile_write_failure_when_index_html_is_a_directory() {
1299        // page/2/index.html pre-created as a directory: create_dir_all
1300        // succeeds (page/2 exists) but the fs::write must fail.
1301        let (_tmp, site, meta, ctx) = make_layout();
1302        write_n_dated_posts(&meta, 11);
1303        fs::create_dir_all(site.join("page/2/index.html")).unwrap();
1304
1305        let err = PaginationPlugin::default()
1306            .after_compile(&ctx)
1307            .expect_err("write over a directory must fail");
1308        let msg = format!("{err:?}");
1309        assert!(msg.contains("index.html"), "path context expected: {msg}");
1310    }
1311}