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    let content = fs::read_to_string(sidecar_path).ok()?;
297    let meta: HashMap<String, serde_json::Value> =
298        serde_json::from_str(&content).ok()?;
299
300    let title = meta
301        .get("title")
302        .and_then(|v| v.as_str())
303        .unwrap_or("Untitled")
304        .to_string();
305    let date = meta
306        .get("date")
307        .and_then(|v| v.as_str())
308        .unwrap_or("")
309        .to_string();
310
311    if date.is_empty() {
312        return None;
313    }
314
315    let rel = sidecar_path
316        .strip_prefix(sidecar_dir)
317        .unwrap_or(sidecar_path)
318        .with_extension("")
319        .with_extension("html");
320    let url = format!("/{}", rel.to_string_lossy().replace('\\', "/"));
321
322    // Both front-matter shapes, as everywhere else: `tags: [a, b]` and
323    // `tags: "a, b"`.
324    let terms = |key: &str| -> Vec<String> {
325        meta.get(key).map_or_else(Vec::new, |v| {
326            v.as_array().map_or_else(
327                || {
328                    v.as_str()
329                        .map_or_else(Vec::new, |s| ssg_core::split_terms(s))
330                },
331                |arr| {
332                    arr.iter()
333                        .filter_map(serde_json::Value::as_str)
334                        .flat_map(ssg_core::split_terms)
335                        .collect()
336                },
337            )
338        })
339    };
340
341    Some(PageEntry {
342        title,
343        url,
344        date,
345        tags: terms("tags"),
346        categories: terms("categories"),
347        topics: terms("topic_clusters"),
348        language: meta
349            .get("language")
350            .and_then(serde_json::Value::as_str)
351            .map(ToOwned::to_owned),
352    })
353}
354
355/// Writes a single pagination page to disk.
356fn write_pagination_page(
357    page_dir: &Path,
358    page_num: usize,
359    total_pages: usize,
360    page_entries: &[PageEntry],
361) -> Result<(), SsgError> {
362    let dir = page_dir.join(page_num.to_string());
363    fs::create_dir_all(&dir).with_path(&dir)?;
364
365    let prev_url = if page_num == 2 {
366        "/".to_string()
367    } else {
368        format!("/page/{}/", page_num - 1)
369    };
370    let next_url = if page_num < total_pages {
371        Some(format!("/page/{}/", page_num + 1))
372    } else {
373        None
374    };
375
376    let mut html = format!(
377        "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\
378         <meta charset=\"utf-8\">\
379         <title>Page {page_num} of {total_pages}</title></head>\n\
380         <body>\n<main>\n\
381         <h1>Page {page_num} of {total_pages}</h1>\n<ul>\n",
382    );
383
384    for entry in page_entries {
385        html.push_str(&format!(
386            "<li><a href=\"{}\">{}</a> <time>{}</time></li>\n",
387            entry.url, entry.title, entry.date
388        ));
389    }
390
391    html.push_str("</ul>\n<nav aria-label=\"Pagination\">\n");
392    html.push_str(&format!(
393        "<a href=\"{prev_url}\" rel=\"prev\">&larr; Previous</a>\n"
394    ));
395    if let Some(next) = &next_url {
396        html.push_str(&format!(
397            "<a href=\"{next}\" rel=\"next\">Next &rarr;</a>\n"
398        ));
399    }
400    html.push_str("</nav>\n</main>\n</body>\n</html>\n");
401
402    let out_file = dir.join("index.html");
403    fs::write(&out_file, html).with_path(&out_file)?;
404    Ok(())
405}
406
407/// Writes one page of a named listing to `dir/index.html`.
408///
409/// Titles and dates are escaped: they come from front matter, and a
410/// title containing `<` would otherwise close the anchor and swallow the
411/// rest of the list.
412fn write_listing_page(
413    dir: &Path,
414    listing: &ListingConfig,
415    page_num: usize,
416    total_pages: usize,
417    entries: &[PageEntry],
418) -> Result<(), SsgError> {
419    fs::create_dir_all(dir).with_path(dir)?;
420
421    let base = format!("/{}", listing.name);
422    let page_url = |n: usize| -> String {
423        if n == 1 {
424            format!("{base}/")
425        } else {
426            format!("{base}/page/{n}/")
427        }
428    };
429
430    let title = escape_html(listing.display_title());
431    let heading = if total_pages > 1 {
432        format!("{title} — page {page_num} of {total_pages}")
433    } else {
434        title.clone()
435    };
436
437    let mut html = format!(
438        "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\
439         <meta charset=\"utf-8\">\
440         <title>{heading}</title></head>\n\
441         <body>\n<main>\n\
442         <h1>{heading}</h1>\n<ul>\n",
443    );
444    for entry in entries {
445        html.push_str(&format!(
446            "<li><a href=\"{}\">{}</a> <time datetime=\"{}\">{}</time></li>\n",
447            escape_html(&entry.url),
448            escape_html(&entry.title),
449            escape_html(&entry.date),
450            escape_html(&entry.date),
451        ));
452    }
453    html.push_str("</ul>\n");
454
455    if total_pages > 1 {
456        html.push_str("<nav aria-label=\"Pagination\">\n");
457        if page_num > 1 {
458            html.push_str(&format!(
459                "<a href=\"{}\" rel=\"prev\">&larr; Previous</a>\n",
460                page_url(page_num - 1)
461            ));
462        }
463        if page_num < total_pages {
464            html.push_str(&format!(
465                "<a href=\"{}\" rel=\"next\">Next &rarr;</a>\n",
466                page_url(page_num + 1)
467            ));
468        }
469        html.push_str("</nav>\n");
470    }
471    html.push_str("</main>\n</body>\n</html>\n");
472
473    let out_file = dir.join("index.html");
474    fs::write(&out_file, html).with_path(&out_file)?;
475    Ok(())
476}
477
478/// Minimal HTML text/attribute escaping for generated listings.
479fn escape_html(s: &str) -> String {
480    let mut out = String::with_capacity(s.len());
481    for c in s.chars() {
482        match c {
483            '&' => out.push_str("&amp;"),
484            '<' => out.push_str("&lt;"),
485            '>' => out.push_str("&gt;"),
486            '"' => out.push_str("&quot;"),
487            '\'' => out.push_str("&#39;"),
488            _ => out.push(c),
489        }
490    }
491    out
492}
493
494fn collect_json_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
495    crate::walk::walk_files(dir, "json")
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    use crate::test_support::init_logger;
502    use std::path::PathBuf;
503    use tempfile::{tempdir, TempDir};
504
505    // -------------------------------------------------------------------
506    // Test fixtures
507    // -------------------------------------------------------------------
508
509    /// Builds a fresh temp dir layout: `<root>/site`, `<root>/build/.meta`,
510    /// and a `PluginContext` pointing at it. Returns the temp dir guard
511    /// (must outlive the test), the site path, the meta sidecar path,
512    /// and the context.
513    fn make_layout() -> (TempDir, PathBuf, PathBuf, PluginContext) {
514        init_logger();
515        let dir = tempdir().expect("create tempdir");
516        let site = dir.path().join("site");
517        let build = dir.path().join("build");
518        let meta = build.join(".meta");
519        fs::create_dir_all(&site).expect("mkdir site");
520        fs::create_dir_all(&meta).expect("mkdir meta");
521        let ctx = PluginContext::new(dir.path(), &build, &site, dir.path());
522        (dir, site, meta, ctx)
523    }
524
525    /// A layout whose config carries the given listings (#587).
526    fn layout_with_listings(
527        listings: Vec<ListingConfig>,
528    ) -> (TempDir, PathBuf, PathBuf, PluginContext) {
529        init_logger();
530        let dir = tempdir().expect("create tempdir");
531        let site = dir.path().join("site");
532        let build = dir.path().join("build");
533        let meta = build.join(".meta");
534        fs::create_dir_all(&site).expect("mkdir site");
535        fs::create_dir_all(&meta).expect("mkdir meta");
536        let mut cfg = crate::cmd::default_config().as_ref().clone();
537        cfg.listings = listings;
538        let ctx = PluginContext::with_config(
539            dir.path(),
540            &build,
541            &site,
542            dir.path(),
543            cfg,
544        );
545        (dir, site, meta, ctx)
546    }
547
548    /// A sidecar with terms and a language, for listing filters.
549    fn write_rich_sidecar(
550        meta: &Path,
551        name: &str,
552        title: &str,
553        date: &str,
554        extra: &str,
555    ) {
556        let json = format!(
557            r#"{{"title": "{title}", "date": "{date}"{}{extra}}}"#,
558            if extra.is_empty() { "" } else { ", " }
559        );
560        fs::write(meta.join(format!("{name}.meta.json")), json)
561            .expect("write sidecar");
562    }
563
564    #[test]
565    fn a_named_listing_writes_page_one_at_its_own_path() {
566        let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
567            name: "archive".to_string(),
568            title: Some("Archive".to_string()),
569            ..ListingConfig::default()
570        }]);
571        write_sidecar(&meta, "a", "Alpha", "2026-01-01");
572
573        PaginationPlugin::default().after_compile(&ctx).unwrap();
574
575        let page = fs::read_to_string(site.join("archive/index.html"))
576            .expect("page 1 is written at the listing root");
577        assert!(page.contains("Archive"), "{page}");
578        assert!(page.contains("Alpha"), "{page}");
579    }
580
581    #[test]
582    fn a_listing_paginates_at_its_own_per_page() {
583        let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
584            name: "archive".to_string(),
585            per_page: Some(2),
586            ..ListingConfig::default()
587        }]);
588        for i in 1..=5 {
589            write_sidecar(
590                &meta,
591                &format!("p{i}"),
592                &format!("P{i}"),
593                &format!("2026-01-0{i}"),
594            );
595        }
596
597        PaginationPlugin::default().after_compile(&ctx).unwrap();
598
599        assert!(site.join("archive/index.html").exists());
600        assert!(site.join("archive/page/2/index.html").exists());
601        assert!(site.join("archive/page/3/index.html").exists());
602        assert!(
603            !site.join("archive/page/4/index.html").exists(),
604            "5 entries at 2 per page is 3 pages"
605        );
606        let p2 =
607            fs::read_to_string(site.join("archive/page/2/index.html")).unwrap();
608        assert!(
609            p2.contains(r#"href="/archive/""#),
610            "prev goes to page 1: {p2}"
611        );
612        assert!(p2.contains(r#"href="/archive/page/3/""#), "next: {p2}");
613    }
614
615    #[test]
616    fn filters_select_the_pages_and_combine_with_and() {
617        let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
618            name: "rust-2026".to_string(),
619            tag: Some("rust".to_string()),
620            after: Some("2026-01-01".to_string()),
621            ..ListingConfig::default()
622        }]);
623        write_rich_sidecar(
624            &meta,
625            "a",
626            "Match",
627            "2026-06-01",
628            r#""tags": "rust""#,
629        );
630        write_rich_sidecar(
631            &meta,
632            "b",
633            "WrongTag",
634            "2026-06-01",
635            r#""tags": "go""#,
636        );
637        write_rich_sidecar(
638            &meta,
639            "c",
640            "TooOld",
641            "2025-06-01",
642            r#""tags": "rust""#,
643        );
644
645        PaginationPlugin::default().after_compile(&ctx).unwrap();
646
647        let page =
648            fs::read_to_string(site.join("rust-2026/index.html")).unwrap();
649        assert!(page.contains("Match"), "{page}");
650        assert!(!page.contains("WrongTag"), "tag filter: {page}");
651        assert!(!page.contains("TooOld"), "date filter: {page}");
652    }
653
654    /// `tag = "Rust"` and `tags: "rust"` are the same tag to a reader.
655    #[test]
656    fn term_filters_ignore_case() {
657        let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
658            name: "r".to_string(),
659            tag: Some("Rust".to_string()),
660            ..ListingConfig::default()
661        }]);
662        write_rich_sidecar(
663            &meta,
664            "a",
665            "Alpha",
666            "2026-01-01",
667            r#""tags": "rust""#,
668        );
669
670        PaginationPlugin::default().after_compile(&ctx).unwrap();
671        assert!(fs::read_to_string(site.join("r/index.html"))
672            .unwrap()
673            .contains("Alpha"));
674    }
675
676    #[test]
677    fn by_year_writes_one_archive_per_year() {
678        let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
679            name: "archive".to_string(),
680            by_year: true,
681            ..ListingConfig::default()
682        }]);
683        write_sidecar(&meta, "a", "Old", "2025-03-01");
684        write_sidecar(&meta, "b", "New", "2026-03-01");
685
686        PaginationPlugin::default().after_compile(&ctx).unwrap();
687
688        let y2025 =
689            fs::read_to_string(site.join("archive/2025/index.html")).unwrap();
690        assert!(y2025.contains("Old") && !y2025.contains("New"), "{y2025}");
691        let y2026 =
692            fs::read_to_string(site.join("archive/2026/index.html")).unwrap();
693        assert!(y2026.contains("New") && !y2026.contains("Old"), "{y2026}");
694    }
695
696    /// A filter that matches nothing writes nothing — and says so.
697    #[test]
698    fn a_listing_matching_nothing_writes_no_directory() {
699        let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
700            name: "ghost".to_string(),
701            tag: Some("nonexistent".to_string()),
702            ..ListingConfig::default()
703        }]);
704        write_sidecar(&meta, "a", "Alpha", "2026-01-01");
705
706        PaginationPlugin::default().after_compile(&ctx).unwrap();
707        assert!(!site.join("ghost").exists(), "no empty listing directory");
708    }
709
710    /// Front-matter text reaches the page as text, not as markup.
711    #[test]
712    fn titles_are_escaped_in_generated_listings() {
713        let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
714            name: "archive".to_string(),
715            ..ListingConfig::default()
716        }]);
717        write_sidecar(&meta, "a", "A <b>bold</b> title", "2026-01-01");
718
719        PaginationPlugin::default().after_compile(&ctx).unwrap();
720        let page = fs::read_to_string(site.join("archive/index.html")).unwrap();
721        assert!(page.contains("&lt;b&gt;bold&lt;/b&gt;"), "{page}");
722        assert!(!page.contains("<b>bold</b>"), "{page}");
723    }
724
725    /// Sites with no listings configured behave exactly as before.
726    #[test]
727    fn no_listings_configured_writes_no_listing_directories() {
728        let (_d, site, meta, ctx) = make_layout();
729        write_n_dated_posts(&meta, 25);
730
731        PaginationPlugin::default().after_compile(&ctx).unwrap();
732
733        assert!(
734            site.join("page/2/index.html").exists(),
735            "site-wide unchanged"
736        );
737        let dirs: Vec<_> = fs::read_dir(&site)
738            .unwrap()
739            .filter_map(Result::ok)
740            .map(|e| e.file_name().to_string_lossy().to_string())
741            .collect();
742        assert_eq!(dirs, vec!["page".to_string()], "only /page/: {dirs:?}");
743    }
744
745    /// Writes a sidecar JSON file shaped `{"title": ..., "date": ...}`.
746    fn write_sidecar(meta: &Path, name: &str, title: &str, date: &str) {
747        let json = if date.is_empty() {
748            format!(r#"{{"title": "{title}"}}"#)
749        } else {
750            format!(r#"{{"title": "{title}", "date": "{date}"}}"#)
751        };
752        fs::write(meta.join(format!("{name}.meta.json")), json)
753            .expect("write sidecar");
754    }
755
756    /// Writes `n` dated posts numbered 1..=n with monotonically
757    /// increasing dates so sort order is well-defined.
758    fn write_n_dated_posts(meta: &Path, n: usize) {
759        for i in 1..=n {
760            write_sidecar(
761                meta,
762                &format!("post{i:03}"),
763                &format!("Post {i}"),
764                &format!("2026-01-{i:02}"),
765            );
766        }
767    }
768
769    // -------------------------------------------------------------------
770    // Constructor + derive surface
771    // -------------------------------------------------------------------
772
773    #[test]
774    fn default_uses_default_per_page_constant() {
775        // The Default impl is the public ergonomic — assert it matches
776        // the documented constant rather than a magic number, so the
777        // test stays correct if DEFAULT_PER_PAGE is ever retuned.
778        let plugin = PaginationPlugin::default();
779        assert_eq!(plugin.per_page, DEFAULT_PER_PAGE);
780    }
781
782    #[test]
783    fn with_per_page_stores_supplied_value() {
784        let plugin = PaginationPlugin::with_per_page(7);
785        assert_eq!(plugin.per_page, 7);
786    }
787
788    #[test]
789    fn with_per_page_zero_clamps_to_one() {
790        // Zero would cause a divide-by-zero in `div_ceil`. The
791        // constructor must clamp it.
792        let plugin = PaginationPlugin::with_per_page(0);
793        assert_eq!(plugin.per_page, 1);
794    }
795
796    #[test]
797    fn with_per_page_one_is_valid_lower_bound() {
798        let plugin = PaginationPlugin::with_per_page(1);
799        assert_eq!(plugin.per_page, 1);
800    }
801
802    #[test]
803    fn with_per_page_table_driven_values() {
804        // Table-driven sanity check across a spread of valid sizes.
805        let cases: &[(usize, usize)] = &[
806            (1, 1),
807            (5, 5),
808            (10, 10),
809            (100, 100),
810            (usize::MAX, usize::MAX),
811        ];
812        for &(input, expected) in cases {
813            let plugin = PaginationPlugin::with_per_page(input);
814            assert_eq!(
815                plugin.per_page, expected,
816                "with_per_page({input}) should store {expected}"
817            );
818        }
819    }
820
821    #[test]
822    fn pagination_plugin_is_copy_after_move() {
823        // Guards the `Copy` derive added in v0.0.34.
824        let plugin = PaginationPlugin::with_per_page(3);
825        let _copy = plugin;
826        assert_eq!(plugin.per_page, 3);
827    }
828
829    #[test]
830    fn name_returns_static_pagination_identifier() {
831        let plugin = PaginationPlugin::default();
832        assert_eq!(plugin.name(), "pagination");
833    }
834
835    // -------------------------------------------------------------------
836    // after_compile — early-return paths
837    // -------------------------------------------------------------------
838
839    #[test]
840    fn after_compile_missing_meta_dir_returns_ok_without_writing() {
841        // No `.meta` directory under build/ — must short-circuit, not
842        // error, and must not create the page/ directory.
843        let dir = tempdir().expect("tempdir");
844        let site = dir.path().join("site");
845        let build = dir.path().join("build");
846        fs::create_dir_all(&site).expect("mkdir site");
847        fs::create_dir_all(&build).expect("mkdir build");
848        let ctx = PluginContext::new(dir.path(), &build, &site, dir.path());
849
850        PaginationPlugin::default()
851            .after_compile(&ctx)
852            .expect("missing meta dir is not an error");
853
854        assert!(!site.join("page").exists());
855    }
856
857    #[test]
858    fn after_compile_empty_meta_dir_returns_ok_without_writing() {
859        let (_tmp, site, _meta, ctx) = make_layout();
860        PaginationPlugin::default()
861            .after_compile(&ctx)
862            .expect("empty meta is fine");
863        assert!(!site.join("page").exists());
864    }
865
866    #[test]
867    fn after_compile_only_undated_pages_returns_ok_without_writing() {
868        // Pages without `date` are skipped — see line 91. Only undated
869        // entries means `entries.is_empty()` short-circuit at line 105.
870        let (_tmp, site, meta, ctx) = make_layout();
871        write_sidecar(&meta, "about", "About", "");
872        write_sidecar(&meta, "contact", "Contact", "");
873
874        PaginationPlugin::default().after_compile(&ctx).unwrap();
875        assert!(!site.join("page").exists());
876    }
877
878    #[test]
879    fn after_compile_single_page_skips_pagination() {
880        // 5 dated posts at default per_page=10 → 1 page total → no
881        // /page/N/ directories produced (line 114 `total_pages <= 1`).
882        let (_tmp, site, meta, ctx) = make_layout();
883        write_n_dated_posts(&meta, 5);
884
885        PaginationPlugin::default().after_compile(&ctx).unwrap();
886        assert!(!site.join("page").exists());
887    }
888
889    // -------------------------------------------------------------------
890    // after_compile — sidecar parsing fallbacks
891    // -------------------------------------------------------------------
892
893    #[test]
894    fn after_compile_skips_invalid_json_sidecars() {
895        // The JSON parser error branch at line 76 must not propagate —
896        // bad sidecars are silently skipped so a single corrupt file
897        // can't poison the whole build.
898        let (_tmp, site, meta, ctx) = make_layout();
899        fs::write(meta.join("broken.meta.json"), "{not valid json").unwrap();
900        // Add 11 valid posts so we still cross the pagination threshold
901        // (default per_page=10 → 2 pages).
902        write_n_dated_posts(&meta, 11);
903
904        PaginationPlugin::default()
905            .after_compile(&ctx)
906            .expect("broken sidecar must not error");
907        assert!(site.join("page/2/index.html").exists());
908    }
909
910    #[test]
911    fn after_compile_missing_title_defaults_to_untitled() {
912        // Pages without a `title` field but with a `date` are still
913        // paginated; the title falls back to "Untitled" (line 82).
914        let (_tmp, site, meta, ctx) = make_layout();
915        // 11 entries with NO title field → "Untitled" fallback used.
916        for i in 1..=11 {
917            fs::write(
918                meta.join(format!("post{i}.meta.json")),
919                format!(r#"{{"date": "2026-01-{i:02}"}}"#),
920            )
921            .unwrap();
922        }
923
924        PaginationPlugin::default().after_compile(&ctx).unwrap();
925        let page2 = fs::read_to_string(site.join("page/2/index.html")).unwrap();
926        assert!(
927            page2.contains("Untitled"),
928            "missing title must fall back to \"Untitled\":\n{page2}"
929        );
930    }
931
932    #[test]
933    fn after_compile_skips_pages_with_empty_date_string() {
934        // A `date` field present but empty must be treated the same
935        // as a missing date (line 91-93).
936        let (_tmp, site, meta, ctx) = make_layout();
937        write_sidecar(&meta, "draft", "Draft", ""); // empty date branch
938        write_n_dated_posts(&meta, 11);
939
940        PaginationPlugin::default().after_compile(&ctx).unwrap();
941        // Only the 11 dated posts paginate; the empty-date entry is
942        // ignored, so we get exactly one /page/2/ (11 → 2 pages).
943        assert!(site.join("page/2/index.html").exists());
944        assert!(!site.join("page/3/index.html").exists());
945    }
946
947    // -------------------------------------------------------------------
948    // after_compile — page slicing arithmetic
949    // -------------------------------------------------------------------
950
951    #[test]
952    fn after_compile_exact_multiple_yields_full_pages() {
953        // 10 posts at per_page=5 → exactly 2 full pages, no remainder.
954        let (_tmp, site, meta, ctx) = make_layout();
955        write_n_dated_posts(&meta, 10);
956
957        PaginationPlugin::with_per_page(5)
958            .after_compile(&ctx)
959            .unwrap();
960
961        let page2 = fs::read_to_string(site.join("page/2/index.html")).unwrap();
962        // Page 2 of 2: should contain exactly 5 list items.
963        let li_count = page2.matches("<li>").count();
964        assert_eq!(li_count, 5, "page 2 should have 5 entries:\n{page2}");
965        assert!(!site.join("page/3/index.html").exists());
966    }
967
968    #[test]
969    fn after_compile_non_multiple_yields_partial_last_page() {
970        // 11 posts at per_page=5 → 3 pages: 5 + 5 + 1.
971        let (_tmp, site, meta, ctx) = make_layout();
972        write_n_dated_posts(&meta, 11);
973
974        PaginationPlugin::with_per_page(5)
975            .after_compile(&ctx)
976            .unwrap();
977
978        assert!(site.join("page/2/index.html").exists());
979        assert!(site.join("page/3/index.html").exists());
980        assert!(!site.join("page/4/index.html").exists());
981
982        let page3 = fs::read_to_string(site.join("page/3/index.html")).unwrap();
983        let li_count = page3.matches("<li>").count();
984        assert_eq!(li_count, 1, "last page should have 1 entry:\n{page3}");
985    }
986
987    #[test]
988    fn after_compile_per_page_one_yields_one_page_per_post() {
989        // per_page=1 boundary: 5 posts → 5 pages → /page/2 .. /page/5.
990        let (_tmp, site, meta, ctx) = make_layout();
991        write_n_dated_posts(&meta, 5);
992
993        PaginationPlugin::with_per_page(1)
994            .after_compile(&ctx)
995            .unwrap();
996
997        for n in 2..=5 {
998            assert!(
999                site.join(format!("page/{n}/index.html")).exists(),
1000                "page/{n}/ should exist"
1001            );
1002        }
1003        assert!(!site.join("page/6/index.html").exists());
1004    }
1005
1006    // -------------------------------------------------------------------
1007    // after_compile — sort order
1008    // -------------------------------------------------------------------
1009
1010    #[test]
1011    fn after_compile_sorts_entries_by_date_descending() {
1012        // Posts written out of order — newest must appear first on
1013        // page 1 (which is unwritten by this plugin), so the remainder
1014        // on page 2 must be the *oldest* entries.
1015        let (_tmp, site, meta, ctx) = make_layout();
1016        // Write posts with dates that are NOT in filename order:
1017        // file `a` → 2026-01-01 (oldest)
1018        // file `z` → 2026-01-11 (newest)
1019        let dates = [
1020            ("a", "2026-01-01"),
1021            ("m", "2026-01-05"),
1022            ("z", "2026-01-11"),
1023            ("b", "2026-01-02"),
1024            ("y", "2026-01-10"),
1025            ("c", "2026-01-03"),
1026            ("x", "2026-01-09"),
1027            ("d", "2026-01-04"),
1028            ("w", "2026-01-08"),
1029            ("e", "2026-01-06"),
1030            ("f", "2026-01-07"),
1031        ];
1032        for (name, date) in dates {
1033            write_sidecar(&meta, name, &format!("Post {name}"), date);
1034        }
1035
1036        PaginationPlugin::with_per_page(10)
1037            .after_compile(&ctx)
1038            .unwrap();
1039
1040        // 11 entries / 10 per page → page 2 has the single OLDEST entry.
1041        let page2 = fs::read_to_string(site.join("page/2/index.html")).unwrap();
1042        assert!(
1043            page2.contains("2026-01-01"),
1044            "page 2 should contain the oldest entry:\n{page2}"
1045        );
1046        assert!(
1047            !page2.contains("2026-01-11"),
1048            "page 2 should NOT contain the newest entry:\n{page2}"
1049        );
1050    }
1051
1052    // -------------------------------------------------------------------
1053    // after_compile — HTML structure & navigation
1054    // -------------------------------------------------------------------
1055
1056    #[test]
1057    fn after_compile_emits_doctype_lang_and_charset() {
1058        let (_tmp, site, meta, ctx) = make_layout();
1059        write_n_dated_posts(&meta, 11);
1060        PaginationPlugin::default().after_compile(&ctx).unwrap();
1061
1062        let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
1063        assert!(html.starts_with("<!DOCTYPE html>"));
1064        assert!(html.contains("<html lang=\"en\">"));
1065        assert!(html.contains("<meta charset=\"utf-8\">"));
1066    }
1067
1068    #[test]
1069    fn after_compile_emits_pagination_nav_landmark() {
1070        let (_tmp, site, meta, ctx) = make_layout();
1071        write_n_dated_posts(&meta, 11);
1072        PaginationPlugin::default().after_compile(&ctx).unwrap();
1073
1074        let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
1075        assert!(html.contains("<nav aria-label=\"Pagination\">"));
1076    }
1077
1078    #[test]
1079    fn after_compile_page_two_prev_link_points_at_root() {
1080        // The "previous" link from page 2 must point to "/" — page 1
1081        // is the home/root, not /page/1/. Guards line 129-130.
1082        let (_tmp, site, meta, ctx) = make_layout();
1083        write_n_dated_posts(&meta, 11);
1084        PaginationPlugin::default().after_compile(&ctx).unwrap();
1085
1086        let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
1087        assert!(
1088            html.contains(r#"<a href="/" rel="prev">"#),
1089            "page 2's prev should point to root:\n{html}"
1090        );
1091    }
1092
1093    #[test]
1094    fn after_compile_page_three_prev_link_points_at_page_two() {
1095        // Beyond page 2 the prev link uses /page/N-1/ form (line 132).
1096        let (_tmp, site, meta, ctx) = make_layout();
1097        write_n_dated_posts(&meta, 11);
1098        PaginationPlugin::with_per_page(5)
1099            .after_compile(&ctx)
1100            .unwrap();
1101
1102        let html = fs::read_to_string(site.join("page/3/index.html")).unwrap();
1103        assert!(
1104            html.contains(r#"<a href="/page/2/" rel="prev">"#),
1105            "page 3's prev should point to /page/2/:\n{html}"
1106        );
1107    }
1108
1109    #[test]
1110    fn after_compile_last_page_has_no_next_link() {
1111        // The Next link is omitted on the final page — guards
1112        // the `if let Some(next)` branch at line 159.
1113        let (_tmp, site, meta, ctx) = make_layout();
1114        write_n_dated_posts(&meta, 11);
1115        PaginationPlugin::with_per_page(5)
1116            .after_compile(&ctx)
1117            .unwrap();
1118
1119        let last = fs::read_to_string(site.join("page/3/index.html")).unwrap();
1120        assert!(
1121            !last.contains(r#"rel="next""#),
1122            "last page must not emit a Next link:\n{last}"
1123        );
1124    }
1125
1126    #[test]
1127    fn after_compile_middle_page_has_both_prev_and_next() {
1128        // 16 posts / per_page=5 → 4 pages. Page 2 and page 3 are both
1129        // "middle" — assert they have BOTH prev and next.
1130        let (_tmp, site, meta, ctx) = make_layout();
1131        write_n_dated_posts(&meta, 16);
1132        PaginationPlugin::with_per_page(5)
1133            .after_compile(&ctx)
1134            .unwrap();
1135
1136        let page3 = fs::read_to_string(site.join("page/3/index.html")).unwrap();
1137        assert!(page3.contains(r#"rel="prev""#));
1138        assert!(page3.contains(r#"rel="next""#));
1139    }
1140
1141    #[test]
1142    fn after_compile_renders_time_element_per_entry() {
1143        let (_tmp, site, meta, ctx) = make_layout();
1144        write_n_dated_posts(&meta, 11);
1145        PaginationPlugin::default().after_compile(&ctx).unwrap();
1146
1147        let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
1148        assert!(
1149            html.contains("<time>2026-01-01</time>"),
1150            "page 2 should render a <time> element:\n{html}"
1151        );
1152    }
1153
1154    #[test]
1155    fn after_compile_idempotent_overwrites_existing_pages() {
1156        // Re-running must not error and must leave the page directories
1157        // intact. Guards against any future use of `create_new`.
1158        let (_tmp, site, meta, ctx) = make_layout();
1159        write_n_dated_posts(&meta, 11);
1160        let plugin = PaginationPlugin::default();
1161        plugin.after_compile(&ctx).expect("first run");
1162        plugin.after_compile(&ctx).expect("second run");
1163        assert!(site.join("page/2/index.html").exists());
1164    }
1165
1166    // -------------------------------------------------------------------
1167    // collect_json_files — recursion + filtering
1168    // -------------------------------------------------------------------
1169
1170    #[test]
1171    fn collect_json_files_returns_empty_for_missing_directory() {
1172        // Non-existent path: the inner `is_dir()` check at line 183
1173        // means we just `continue`, ending with an empty Vec — no Err.
1174        let dir = tempdir().expect("tempdir");
1175        let result =
1176            collect_json_files(&dir.path().join("does-not-exist")).unwrap();
1177        assert!(result.is_empty());
1178    }
1179
1180    #[test]
1181    fn collect_json_files_returns_empty_for_empty_directory() {
1182        let dir = tempdir().expect("tempdir");
1183        let result = collect_json_files(dir.path()).unwrap();
1184        assert!(result.is_empty());
1185    }
1186
1187    #[test]
1188    fn collect_json_files_filters_non_json_extensions() {
1189        // Only `.json` files are returned. The `is_some_and` filter at
1190        // line 191 must reject `.txt`, `.md`, extensionless files, etc.
1191        let dir = tempdir().expect("tempdir");
1192        fs::write(dir.path().join("a.json"), "{}").unwrap();
1193        fs::write(dir.path().join("b.txt"), "x").unwrap();
1194        fs::write(dir.path().join("c.md"), "x").unwrap();
1195        fs::write(dir.path().join("noext"), "x").unwrap();
1196
1197        let result = collect_json_files(dir.path()).unwrap();
1198        assert_eq!(result.len(), 1);
1199        assert_eq!(
1200            result[0].file_name().unwrap(),
1201            std::ffi::OsStr::new("a.json")
1202        );
1203    }
1204
1205    #[test]
1206    fn collect_json_files_recurses_into_subdirectories() {
1207        // Walks nested directories — guards line 189-190 (push subdir
1208        // onto stack).
1209        let dir = tempdir().expect("tempdir");
1210        let nested = dir.path().join("a").join("b").join("c");
1211        fs::create_dir_all(&nested).unwrap();
1212        fs::write(dir.path().join("top.json"), "{}").unwrap();
1213        fs::write(dir.path().join("a").join("mid.json"), "{}").unwrap();
1214        fs::write(nested.join("deep.json"), "{}").unwrap();
1215
1216        let result = collect_json_files(dir.path()).unwrap();
1217        assert_eq!(result.len(), 3);
1218    }
1219
1220    #[test]
1221    fn collect_json_files_returns_results_sorted() {
1222        // The `files.sort()` at line 196 must yield deterministic output.
1223        let dir = tempdir().expect("tempdir");
1224        for name in ["zebra.json", "apple.json", "mango.json"] {
1225            fs::write(dir.path().join(name), "{}").unwrap();
1226        }
1227        let result = collect_json_files(dir.path()).unwrap();
1228        let names: Vec<&str> = result
1229            .iter()
1230            .map(|p| p.file_name().unwrap().to_str().unwrap())
1231            .collect();
1232        assert_eq!(names, vec!["apple.json", "mango.json", "zebra.json"]);
1233    }
1234
1235    // -------------------------------------------------------------------
1236    // I/O error propagation
1237    // -------------------------------------------------------------------
1238
1239    #[test]
1240    #[cfg(unix)]
1241    fn after_compile_propagates_walk_error_from_unreadable_meta_subdir() {
1242        use std::os::unix::fs::PermissionsExt;
1243
1244        let (_tmp, _site, meta, ctx) = make_layout();
1245        let locked = meta.join("locked");
1246        fs::create_dir_all(&locked).unwrap();
1247        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1248            .unwrap();
1249
1250        let result = PaginationPlugin::default().after_compile(&ctx);
1251        fs::set_permissions(&locked, fs::Permissions::from_mode(0o755))
1252            .unwrap();
1253        assert!(result.is_err(), "unreadable meta subdir must be an Err");
1254    }
1255
1256    #[test]
1257    #[cfg(unix)]
1258    fn after_compile_skips_unreadable_sidecar_file() {
1259        use std::os::unix::fs::PermissionsExt;
1260
1261        // An unreadable sidecar takes the `.ok()?` None path in
1262        // parse_page_entry and is silently skipped; the remaining
1263        // dated posts still paginate.
1264        let (_tmp, site, meta, ctx) = make_layout();
1265        write_n_dated_posts(&meta, 11);
1266        let locked = meta.join("locked.meta.json");
1267        fs::write(&locked, r#"{"title": "L", "date": "2026-02-01"}"#).unwrap();
1268        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1269            .unwrap();
1270
1271        let result = PaginationPlugin::default().after_compile(&ctx);
1272        fs::set_permissions(&locked, fs::Permissions::from_mode(0o644))
1273            .unwrap();
1274        result.expect("unreadable sidecar must be skipped, not fatal");
1275        assert!(site.join("page/2/index.html").exists());
1276        assert!(!site.join("page/3/index.html").exists());
1277    }
1278
1279    #[test]
1280    fn after_compile_create_dir_failure_when_page_is_a_file() {
1281        // A regular file occupying site/page makes create_dir_all fail,
1282        // which must propagate through write_pagination_page's `?`.
1283        let (_tmp, site, meta, ctx) = make_layout();
1284        write_n_dated_posts(&meta, 11);
1285        fs::write(site.join("page"), "not a directory").unwrap();
1286
1287        let err = PaginationPlugin::default()
1288            .after_compile(&ctx)
1289            .expect_err("create_dir_all over a file must fail");
1290        let msg = format!("{err:?}");
1291        assert!(msg.contains("Io"), "expected Io error, got: {msg}");
1292    }
1293
1294    #[test]
1295    fn after_compile_write_failure_when_index_html_is_a_directory() {
1296        // page/2/index.html pre-created as a directory: create_dir_all
1297        // succeeds (page/2 exists) but the fs::write must fail.
1298        let (_tmp, site, meta, ctx) = make_layout();
1299        write_n_dated_posts(&meta, 11);
1300        fs::create_dir_all(site.join("page/2/index.html")).unwrap();
1301
1302        let err = PaginationPlugin::default()
1303            .after_compile(&ctx)
1304            .expect_err("write over a directory must fail");
1305        let msg = format!("{err:?}");
1306        assert!(msg.contains("index.html"), "path context expected: {msg}");
1307    }
1308}