use crate::plugin::{Plugin, PluginContext};
use anyhow::Result;
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
};
const DEFAULT_PER_PAGE: usize = 10;
#[derive(Debug, Clone)]
struct PageEntry {
title: String,
url: String,
date: String,
}
#[derive(Debug, Clone, Copy)]
pub struct PaginationPlugin {
per_page: usize,
}
impl Default for PaginationPlugin {
fn default() -> Self {
Self {
per_page: DEFAULT_PER_PAGE,
}
}
}
impl PaginationPlugin {
#[must_use]
pub fn with_per_page(per_page: usize) -> Self {
Self {
per_page: per_page.max(1),
}
}
}
impl Plugin for PaginationPlugin {
fn name(&self) -> &'static str {
"pagination"
}
fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
let sidecar_dir = ctx.build_dir.join(".meta");
if !sidecar_dir.exists() {
return Ok(());
}
let mut entries = collect_page_entries(&sidecar_dir)?;
if entries.is_empty() {
return Ok(());
}
entries.sort_by(|a, b| b.date.cmp(&a.date));
let total_pages = entries.len().div_ceil(self.per_page);
if total_pages <= 1 {
return Ok(());
}
let page_dir = ctx.site_dir.join("page");
for page_num in 2..=total_pages {
let start = (page_num - 1) * self.per_page;
let end = (start + self.per_page).min(entries.len());
let page_entries = &entries[start..end];
write_pagination_page(
&page_dir,
page_num,
total_pages,
page_entries,
)?;
}
log::info!(
"[pagination] Generated {} page(s) ({} entries, {} per page)",
total_pages - 1,
entries.len(),
self.per_page
);
Ok(())
}
}
fn collect_page_entries(sidecar_dir: &Path) -> Result<Vec<PageEntry>> {
let sidecars = collect_json_files(sidecar_dir)?;
let mut entries = Vec::new();
for sidecar_path in &sidecars {
if let Some(entry) = parse_page_entry(sidecar_path, sidecar_dir) {
entries.push(entry);
}
}
Ok(entries)
}
fn parse_page_entry(
sidecar_path: &Path,
sidecar_dir: &Path,
) -> Option<PageEntry> {
let content = fs::read_to_string(sidecar_path).ok()?;
let meta: HashMap<String, serde_json::Value> =
serde_json::from_str(&content).ok()?;
let title = meta
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("Untitled")
.to_string();
let date = meta
.get("date")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if date.is_empty() {
return None;
}
let rel = sidecar_path
.strip_prefix(sidecar_dir)
.unwrap_or(sidecar_path)
.with_extension("")
.with_extension("html");
let url = format!("/{}", rel.to_string_lossy().replace('\\', "/"));
Some(PageEntry { title, url, date })
}
fn write_pagination_page(
page_dir: &Path,
page_num: usize,
total_pages: usize,
page_entries: &[PageEntry],
) -> Result<()> {
let dir = page_dir.join(page_num.to_string());
fs::create_dir_all(&dir)?;
let prev_url = if page_num == 2 {
"/".to_string()
} else {
format!("/page/{}/", page_num - 1)
};
let next_url = if page_num < total_pages {
Some(format!("/page/{}/", page_num + 1))
} else {
None
};
let mut html = format!(
"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\
<meta charset=\"utf-8\">\
<title>Page {page_num} of {total_pages}</title></head>\n\
<body>\n<main>\n\
<h1>Page {page_num} of {total_pages}</h1>\n<ul>\n",
);
for entry in page_entries {
html.push_str(&format!(
"<li><a href=\"{}\">{}</a> <time>{}</time></li>\n",
entry.url, entry.title, entry.date
));
}
html.push_str("</ul>\n<nav aria-label=\"Pagination\">\n");
html.push_str(&format!(
"<a href=\"{prev_url}\" rel=\"prev\">← Previous</a>\n"
));
if let Some(next) = &next_url {
html.push_str(&format!(
"<a href=\"{next}\" rel=\"next\">Next →</a>\n"
));
}
html.push_str("</nav>\n</main>\n</body>\n</html>\n");
fs::write(dir.join("index.html"), html)?;
Ok(())
}
fn collect_json_files(dir: &Path) -> Result<Vec<PathBuf>> {
crate::walk::walk_files(dir, "json")
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::test_support::init_logger;
use std::path::PathBuf;
use tempfile::{tempdir, TempDir};
fn make_layout() -> (TempDir, PathBuf, PathBuf, PluginContext) {
init_logger();
let dir = tempdir().expect("create tempdir");
let site = dir.path().join("site");
let build = dir.path().join("build");
let meta = build.join(".meta");
fs::create_dir_all(&site).expect("mkdir site");
fs::create_dir_all(&meta).expect("mkdir meta");
let ctx = PluginContext::new(dir.path(), &build, &site, dir.path());
(dir, site, meta, ctx)
}
fn write_sidecar(meta: &Path, name: &str, title: &str, date: &str) {
let json = if date.is_empty() {
format!(r#"{{"title": "{title}"}}"#)
} else {
format!(r#"{{"title": "{title}", "date": "{date}"}}"#)
};
fs::write(meta.join(format!("{name}.meta.json")), json)
.expect("write sidecar");
}
fn write_n_dated_posts(meta: &Path, n: usize) {
for i in 1..=n {
write_sidecar(
meta,
&format!("post{i:03}"),
&format!("Post {i}"),
&format!("2026-01-{i:02}"),
);
}
}
#[test]
fn default_uses_default_per_page_constant() {
let plugin = PaginationPlugin::default();
assert_eq!(plugin.per_page, DEFAULT_PER_PAGE);
}
#[test]
fn with_per_page_stores_supplied_value() {
let plugin = PaginationPlugin::with_per_page(7);
assert_eq!(plugin.per_page, 7);
}
#[test]
fn with_per_page_zero_clamps_to_one() {
let plugin = PaginationPlugin::with_per_page(0);
assert_eq!(plugin.per_page, 1);
}
#[test]
fn with_per_page_one_is_valid_lower_bound() {
let plugin = PaginationPlugin::with_per_page(1);
assert_eq!(plugin.per_page, 1);
}
#[test]
fn with_per_page_table_driven_values() {
let cases: &[(usize, usize)] = &[
(1, 1),
(5, 5),
(10, 10),
(100, 100),
(usize::MAX, usize::MAX),
];
for &(input, expected) in cases {
let plugin = PaginationPlugin::with_per_page(input);
assert_eq!(
plugin.per_page, expected,
"with_per_page({input}) should store {expected}"
);
}
}
#[test]
fn pagination_plugin_is_copy_after_move() {
let plugin = PaginationPlugin::with_per_page(3);
let _copy = plugin;
assert_eq!(plugin.per_page, 3);
}
#[test]
fn name_returns_static_pagination_identifier() {
let plugin = PaginationPlugin::default();
assert_eq!(plugin.name(), "pagination");
}
#[test]
fn after_compile_missing_meta_dir_returns_ok_without_writing() {
let dir = tempdir().expect("tempdir");
let site = dir.path().join("site");
let build = dir.path().join("build");
fs::create_dir_all(&site).expect("mkdir site");
fs::create_dir_all(&build).expect("mkdir build");
let ctx = PluginContext::new(dir.path(), &build, &site, dir.path());
PaginationPlugin::default()
.after_compile(&ctx)
.expect("missing meta dir is not an error");
assert!(!site.join("page").exists());
}
#[test]
fn after_compile_empty_meta_dir_returns_ok_without_writing() {
let (_tmp, site, _meta, ctx) = make_layout();
PaginationPlugin::default()
.after_compile(&ctx)
.expect("empty meta is fine");
assert!(!site.join("page").exists());
}
#[test]
fn after_compile_only_undated_pages_returns_ok_without_writing() {
let (_tmp, site, meta, ctx) = make_layout();
write_sidecar(&meta, "about", "About", "");
write_sidecar(&meta, "contact", "Contact", "");
PaginationPlugin::default().after_compile(&ctx).unwrap();
assert!(!site.join("page").exists());
}
#[test]
fn after_compile_single_page_skips_pagination() {
let (_tmp, site, meta, ctx) = make_layout();
write_n_dated_posts(&meta, 5);
PaginationPlugin::default().after_compile(&ctx).unwrap();
assert!(!site.join("page").exists());
}
#[test]
fn after_compile_skips_invalid_json_sidecars() {
let (_tmp, site, meta, ctx) = make_layout();
fs::write(meta.join("broken.meta.json"), "{not valid json").unwrap();
write_n_dated_posts(&meta, 11);
PaginationPlugin::default()
.after_compile(&ctx)
.expect("broken sidecar must not error");
assert!(site.join("page/2/index.html").exists());
}
#[test]
fn after_compile_missing_title_defaults_to_untitled() {
let (_tmp, site, meta, ctx) = make_layout();
for i in 1..=11 {
fs::write(
meta.join(format!("post{i}.meta.json")),
format!(r#"{{"date": "2026-01-{i:02}"}}"#),
)
.unwrap();
}
PaginationPlugin::default().after_compile(&ctx).unwrap();
let page2 = fs::read_to_string(site.join("page/2/index.html")).unwrap();
assert!(
page2.contains("Untitled"),
"missing title must fall back to \"Untitled\":\n{page2}"
);
}
#[test]
fn after_compile_skips_pages_with_empty_date_string() {
let (_tmp, site, meta, ctx) = make_layout();
write_sidecar(&meta, "draft", "Draft", ""); write_n_dated_posts(&meta, 11);
PaginationPlugin::default().after_compile(&ctx).unwrap();
assert!(site.join("page/2/index.html").exists());
assert!(!site.join("page/3/index.html").exists());
}
#[test]
fn after_compile_exact_multiple_yields_full_pages() {
let (_tmp, site, meta, ctx) = make_layout();
write_n_dated_posts(&meta, 10);
PaginationPlugin::with_per_page(5)
.after_compile(&ctx)
.unwrap();
let page2 = fs::read_to_string(site.join("page/2/index.html")).unwrap();
let li_count = page2.matches("<li>").count();
assert_eq!(li_count, 5, "page 2 should have 5 entries:\n{page2}");
assert!(!site.join("page/3/index.html").exists());
}
#[test]
fn after_compile_non_multiple_yields_partial_last_page() {
let (_tmp, site, meta, ctx) = make_layout();
write_n_dated_posts(&meta, 11);
PaginationPlugin::with_per_page(5)
.after_compile(&ctx)
.unwrap();
assert!(site.join("page/2/index.html").exists());
assert!(site.join("page/3/index.html").exists());
assert!(!site.join("page/4/index.html").exists());
let page3 = fs::read_to_string(site.join("page/3/index.html")).unwrap();
let li_count = page3.matches("<li>").count();
assert_eq!(li_count, 1, "last page should have 1 entry:\n{page3}");
}
#[test]
fn after_compile_per_page_one_yields_one_page_per_post() {
let (_tmp, site, meta, ctx) = make_layout();
write_n_dated_posts(&meta, 5);
PaginationPlugin::with_per_page(1)
.after_compile(&ctx)
.unwrap();
for n in 2..=5 {
assert!(
site.join(format!("page/{n}/index.html")).exists(),
"page/{n}/ should exist"
);
}
assert!(!site.join("page/6/index.html").exists());
}
#[test]
fn after_compile_sorts_entries_by_date_descending() {
let (_tmp, site, meta, ctx) = make_layout();
let dates = [
("a", "2026-01-01"),
("m", "2026-01-05"),
("z", "2026-01-11"),
("b", "2026-01-02"),
("y", "2026-01-10"),
("c", "2026-01-03"),
("x", "2026-01-09"),
("d", "2026-01-04"),
("w", "2026-01-08"),
("e", "2026-01-06"),
("f", "2026-01-07"),
];
for (name, date) in dates {
write_sidecar(&meta, name, &format!("Post {name}"), date);
}
PaginationPlugin::with_per_page(10)
.after_compile(&ctx)
.unwrap();
let page2 = fs::read_to_string(site.join("page/2/index.html")).unwrap();
assert!(
page2.contains("2026-01-01"),
"page 2 should contain the oldest entry:\n{page2}"
);
assert!(
!page2.contains("2026-01-11"),
"page 2 should NOT contain the newest entry:\n{page2}"
);
}
#[test]
fn after_compile_emits_doctype_lang_and_charset() {
let (_tmp, site, meta, ctx) = make_layout();
write_n_dated_posts(&meta, 11);
PaginationPlugin::default().after_compile(&ctx).unwrap();
let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
assert!(html.starts_with("<!DOCTYPE html>"));
assert!(html.contains("<html lang=\"en\">"));
assert!(html.contains("<meta charset=\"utf-8\">"));
}
#[test]
fn after_compile_emits_pagination_nav_landmark() {
let (_tmp, site, meta, ctx) = make_layout();
write_n_dated_posts(&meta, 11);
PaginationPlugin::default().after_compile(&ctx).unwrap();
let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
assert!(html.contains("<nav aria-label=\"Pagination\">"));
}
#[test]
fn after_compile_page_two_prev_link_points_at_root() {
let (_tmp, site, meta, ctx) = make_layout();
write_n_dated_posts(&meta, 11);
PaginationPlugin::default().after_compile(&ctx).unwrap();
let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
assert!(
html.contains(r#"<a href="/" rel="prev">"#),
"page 2's prev should point to root:\n{html}"
);
}
#[test]
fn after_compile_page_three_prev_link_points_at_page_two() {
let (_tmp, site, meta, ctx) = make_layout();
write_n_dated_posts(&meta, 11);
PaginationPlugin::with_per_page(5)
.after_compile(&ctx)
.unwrap();
let html = fs::read_to_string(site.join("page/3/index.html")).unwrap();
assert!(
html.contains(r#"<a href="/page/2/" rel="prev">"#),
"page 3's prev should point to /page/2/:\n{html}"
);
}
#[test]
fn after_compile_last_page_has_no_next_link() {
let (_tmp, site, meta, ctx) = make_layout();
write_n_dated_posts(&meta, 11);
PaginationPlugin::with_per_page(5)
.after_compile(&ctx)
.unwrap();
let last = fs::read_to_string(site.join("page/3/index.html")).unwrap();
assert!(
!last.contains(r#"rel="next""#),
"last page must not emit a Next link:\n{last}"
);
}
#[test]
fn after_compile_middle_page_has_both_prev_and_next() {
let (_tmp, site, meta, ctx) = make_layout();
write_n_dated_posts(&meta, 16);
PaginationPlugin::with_per_page(5)
.after_compile(&ctx)
.unwrap();
let page3 = fs::read_to_string(site.join("page/3/index.html")).unwrap();
assert!(page3.contains(r#"rel="prev""#));
assert!(page3.contains(r#"rel="next""#));
}
#[test]
fn after_compile_renders_time_element_per_entry() {
let (_tmp, site, meta, ctx) = make_layout();
write_n_dated_posts(&meta, 11);
PaginationPlugin::default().after_compile(&ctx).unwrap();
let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
assert!(
html.contains("<time>2026-01-01</time>"),
"page 2 should render a <time> element:\n{html}"
);
}
#[test]
fn after_compile_idempotent_overwrites_existing_pages() {
let (_tmp, site, meta, ctx) = make_layout();
write_n_dated_posts(&meta, 11);
let plugin = PaginationPlugin::default();
plugin.after_compile(&ctx).expect("first run");
plugin.after_compile(&ctx).expect("second run");
assert!(site.join("page/2/index.html").exists());
}
#[test]
fn collect_json_files_returns_empty_for_missing_directory() {
let dir = tempdir().expect("tempdir");
let result =
collect_json_files(&dir.path().join("does-not-exist")).unwrap();
assert!(result.is_empty());
}
#[test]
fn collect_json_files_returns_empty_for_empty_directory() {
let dir = tempdir().expect("tempdir");
let result = collect_json_files(dir.path()).unwrap();
assert!(result.is_empty());
}
#[test]
fn collect_json_files_filters_non_json_extensions() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("a.json"), "{}").unwrap();
fs::write(dir.path().join("b.txt"), "x").unwrap();
fs::write(dir.path().join("c.md"), "x").unwrap();
fs::write(dir.path().join("noext"), "x").unwrap();
let result = collect_json_files(dir.path()).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].file_name().unwrap() == "a.json");
}
#[test]
fn collect_json_files_recurses_into_subdirectories() {
let dir = tempdir().expect("tempdir");
let nested = dir.path().join("a").join("b").join("c");
fs::create_dir_all(&nested).unwrap();
fs::write(dir.path().join("top.json"), "{}").unwrap();
fs::write(dir.path().join("a").join("mid.json"), "{}").unwrap();
fs::write(nested.join("deep.json"), "{}").unwrap();
let result = collect_json_files(dir.path()).unwrap();
assert_eq!(result.len(), 3);
}
#[test]
fn collect_json_files_returns_results_sorted() {
let dir = tempdir().expect("tempdir");
for name in ["zebra.json", "apple.json", "mango.json"] {
fs::write(dir.path().join(name), "{}").unwrap();
}
let result = collect_json_files(dir.path()).unwrap();
let names: Vec<&str> = result
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap())
.collect();
assert_eq!(names, vec!["apple.json", "mango.json", "zebra.json"]);
}
}